"""Burn-in endpoints — start, cancel, CSV export, job detail. POST /api/v1/burnin/start POST /api/v1/burnin/{job_id}/cancel GET /api/v1/burnin/export.csv — must register before /{job_id} so int("export.csv") doesn't 422 GET /api/v1/burnin/{job_id} """ from __future__ import annotations import csv import io import aiosqlite from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse from app import burnin from app.database import get_db from app.models import ( BurninJobResponse, BurninStageResponse, CancelBurninRequest, StartBurninRequest, ) from ._helpers import operator_for router = APIRouter() def _row_to_burnin(row: aiosqlite.Row, stages: list[aiosqlite.Row]) -> BurninJobResponse: return BurninJobResponse( id=row["id"], drive_id=row["drive_id"], profile=row["profile"], state=row["state"], percent=row["percent"] or 0, stage_name=row["stage_name"], operator=row["operator"], created_at=row["created_at"], started_at=row["started_at"], finished_at=row["finished_at"], error_text=row["error_text"], stages=[ BurninStageResponse( id=s["id"], stage_name=s["stage_name"], state=s["state"], percent=s["percent"] or 0, started_at=s["started_at"], finished_at=s["finished_at"], error_text=s["error_text"], ) for s in stages ], ) @router.post("/api/v1/burnin/start") async def burnin_start(request: Request, req: StartBurninRequest): operator = operator_for(request, req.operator) results = [] errors = [] for drive_id in req.drive_ids: try: job_id = await burnin.start_job( drive_id, req.profile, operator, stage_order=req.stage_order ) results.append({"drive_id": drive_id, "job_id": job_id}) except burnin.PoolMemberError as exc: errors.append({ "drive_id": drive_id, "error": str(exc), "pool_name": exc.pool_name, "pool_role": exc.pool_role, "pool_locked": True, }) except ValueError as exc: errors.append({"drive_id": drive_id, "error": str(exc)}) if errors and not results: # Surface the first error's structured fields so the UI can render # an unlock affordance instead of a generic toast. raise HTTPException(status_code=409, detail=errors[0]) return {"queued": results, "errors": errors} @router.post("/api/v1/burnin/{job_id}/cancel") async def burnin_cancel(job_id: int, request: Request, req: CancelBurninRequest): operator = operator_for(request, req.operator) ok = await burnin.cancel_job(job_id, operator) if not ok: raise HTTPException(status_code=409, detail="Job not found or not cancellable") return {"cancelled": True} # /api/v1/burnin/export.csv MUST be declared BEFORE /api/v1/burnin/{job_id} # so FastAPI's path matching tries the literal first; otherwise the int # coercion fires int("export.csv") and 422s. @router.get("/api/v1/burnin/export.csv") async def burnin_export_csv(db: aiosqlite.Connection = Depends(get_db)): cur = await db.execute(""" SELECT bj.id AS job_id, bj.drive_id, d.devname, d.serial, d.model, bj.profile, bj.state, bj.operator, bj.created_at, bj.started_at, bj.finished_at, CAST( (julianday(bj.finished_at) - julianday(bj.started_at)) * 86400 AS INTEGER ) AS duration_seconds, bj.error_text FROM burnin_jobs bj JOIN drives d ON d.id = bj.drive_id ORDER BY bj.id DESC """) rows = await cur.fetchall() buf = io.StringIO() writer = csv.writer(buf) writer.writerow([ "job_id", "drive_id", "devname", "serial", "model", "profile", "state", "operator", "created_at", "started_at", "finished_at", "duration_seconds", "error_text", ]) for r in rows: writer.writerow(list(r)) buf.seek(0) return StreamingResponse( iter([buf.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=burnin_history.csv"}, ) @router.get("/api/v1/burnin/{job_id}", response_model=BurninJobResponse) async def burnin_get(job_id: int, db: aiosqlite.Connection = Depends(get_db)): db.row_factory = aiosqlite.Row cur = await db.execute("SELECT * FROM burnin_jobs WHERE id=?", (job_id,)) row = await cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Burn-in job not found") cur = await db.execute( "SELECT * FROM burnin_stages WHERE burnin_job_id=? ORDER BY id", (job_id,) ) stages = await cur.fetchall() return _row_to_burnin(row, stages)