wrolpi/modules/flasher/api.py
lrnselfreliance bed25b429d
db: request-path library functions use the request's connection (#774)
* db: request-path library functions use the request's connection

Library functions an API request reaches now take the request's Session (first argument) instead of
opening their own session or cursor, so a request needs one SQLite connection.  Raw SQL runs through the
new `wrolpi.db.session_curs(session)`, a cursor on the Session's connection; `RequestDB.curs()` uses it.

- Search: files, videos, archives, flasher, and docs search share the request connection for the total,
  the page of ids, and the page's models (3 connections -> 1).
- Zim search, including tag-filtered search and estimates (2 -> 1).
- Channel detail (`Channel.get_statistics` reads on the Channel's own Session) and the channel list.
- Video detail, edit, description, captions, comments and transcode.  `get_video_for_app` and
  `update_video` no longer commit; their handlers use `request.ctx.db.write()`, so editing a Video and
  reading it back is one transaction.
- Tags (list, overlaps, recent), file statistics and estimates, other estimates, bulk tag preview, the
  download list, retry, kill and the status summary, and docs/videos/archive statistics.

Not converted: file operations that refresh files inside the request (delete, rename, upload, channel
and collection tag/move) commit page by page in code shared with the file worker; the cached zim entry
lookup; config import.

The connection-count tests now assert one connection for every endpoint they cover (no xfails left), and
cover the videos and archive statistics endpoints, which had no API test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* downloads: startup retries in a write session of its own

start_single_tasks still called retry_downloads() without a Session after it began requiring one, so any
start after a completed refresh raised TypeError there: incomplete downloads were not reset and the
download_on_startup enable after it never ran.  Startup is not a request, so
DownloadManager.retry_downloads_on_startup() opens and commits its own short write transaction.

Tested through the method, not start_single_tasks: importing main registers its startup listeners on the
shared app, and every later test in that worker then runs them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:05:20 -06:00

55 lines
2.2 KiB
Python

import asyncio
from http import HTTPStatus
from urllib.parse import unquote
from sanic import Blueprint, Request, response
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.flasher import lib, schema
from modules.flasher.config import get_flasher_config
from wrolpi.api_utils import json_response
flasher_bp = Blueprint('Flasher', '/api/flasher')
@flasher_bp.post('/search')
@openapi.definition(
summary='Search .bin firmware and filter to ESP images (optionally for a specific chip)',
body=schema.FlasherSearchRequest,
)
@validate(schema.FlasherSearchRequest)
async def post_flasher_search(request: Request, body: schema.FlasherSearchRequest):
# Reading many firmware headers touches the disk; run off the event loop.
file_groups, total = await asyncio.to_thread(
lib.search_esp_firmware, request.ctx.session, body.chip, body.path, body.limit)
return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)), HTTPStatus.OK)
@flasher_bp.get('/configs')
@openapi.definition(summary='List saved firmware flashing configurations')
async def get_flasher_configs(_: Request):
return json_response(dict(configurations=get_flasher_config().configurations), HTTPStatus.OK)
@flasher_bp.post('/configs')
@openapi.definition(
summary='Save (add or replace by name) a firmware flashing configuration',
body=schema.FlasherSaveConfigRequest,
)
@validate(schema.FlasherSaveConfigRequest)
async def save_flasher_config(_: Request, body: schema.FlasherSaveConfigRequest):
try:
configuration = get_flasher_config().save_configuration(body.name, body.files, body.erase_all)
except ValueError as e:
return response.json({'error': str(e)}, HTTPStatus.BAD_REQUEST)
return json_response(dict(configuration=configuration), HTTPStatus.CREATED)
@flasher_bp.delete('/configs/<name:str>')
@openapi.definition(summary='Delete a saved firmware flashing configuration by name')
async def delete_flasher_config(_: Request, name: str):
name = unquote(name)
if get_flasher_config().delete_configuration(name):
return response.empty()
return response.json({'error': f'No saved configuration named {name!r}'}, HTTPStatus.NOT_FOUND)