""" admin_api.py - read/act API over the internal record store. This is the seam the future scout admin panel plugs into. The panel talks HTTP to these endpoints; it never opens the SQLite file directly. That keeps the panel deployable anywhere (separate container, separate host) and keeps this app the only writer to its own database. Auth: every route requires the X-Admin-Token header to match ADMIN_TOKEN. If ADMIN_TOKEN is unset the whole router returns 503 - it FAILS CLOSED. These endpoints expose parent names, emails and phone numbers for minors' families, so an unconfigured deployment must not serve them. """ import hmac import os from fastapi import APIRouter, Header, HTTPException, Query from pydantic import BaseModel import store ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip() router = APIRouter(prefix="/api/admin", tags=["admin"]) def _auth(token): if not ADMIN_TOKEN: raise HTTPException(503, "admin API disabled: ADMIN_TOKEN is not set") if not token or not hmac.compare_digest(token, ADMIN_TOKEN): raise HTTPException(401, "bad or missing X-Admin-Token") class RecordPatch(BaseModel): status: str | None = None assigned_to: str | None = None notes: str | None = None actor: str | None = None @router.get("/summary") def get_summary(x_admin_token: str = Header(None)): _auth(x_admin_token) return store.summary() @router.get("/records") def get_records(kind: str = None, status: str = None, since: str = None, q: str = None, limit: int = Query(100, ge=1, le=500), offset: int = 0, x_admin_token: str = Header(None)): _auth(x_admin_token) return {"records": store.list_records(kind=kind, status=status, since=since, q=q, limit=limit, offset=offset)} @router.get("/records/{record_id}") def get_one(record_id: str, x_admin_token: str = Header(None)): _auth(x_admin_token) rec = store.get_record(record_id, with_audit=True) if not rec: raise HTTPException(404, "no such record") return rec @router.patch("/records/{record_id}") def patch_one(record_id: str, patch: RecordPatch, x_admin_token: str = Header(None)): _auth(x_admin_token) try: rec = store.update_record(record_id, actor=patch.actor or "admin", status=patch.status, assigned_to=patch.assigned_to, notes=patch.notes) except ValueError as e: raise HTTPException(422, str(e)) if not rec: raise HTTPException(404, "no such record") return rec @router.get("/mirrors/failed") def failed(target: str = "google_sheet", x_admin_token: str = Header(None)): _auth(x_admin_token) return {"target": target, "records": store.failed_mirror_records(target)} @router.post("/mirrors/retry") def retry(target: str = "google_sheet", x_admin_token: str = Header(None)): """Replay records whose copy to an external target failed. Idempotent-ish: a record already marked ok is never retried.""" _auth(x_admin_token) if target != "google_sheet": raise HTTPException(422, "only google_sheet retry is implemented") import app as main_app done, failed_ids = 0, [] for rec in store.failed_mirror_records(target, limit=200): try: main_app.sheet_append(rec["payload"]) store.set_mirror(rec["id"], target, True) store.log(rec["id"], "admin", "mirror_retry_ok", target) done += 1 except Exception as e: store.set_mirror(rec["id"], target, False, e) failed_ids.append(rec["id"]) return {"retried_ok": done, "still_failing": failed_ids}