""" admin_api.py - read 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. Leads are read-only, and that is deliberate. The old PATCH route set status/assigned_to/notes on a lead - columns that were removed because they were a guess at an outreach process nobody has designed. When that process exists it gets its own table and its own write routes; until then there is nothing on a lead to mutate. Announcements ARE writable, and are the deliberate exception to that. The read-only rule exists because a lead has nothing mutable on it. An announcement is defined by being mutable and expiring - it is posted, it shows, it comes down. Writing it is the entire feature. The caps that keep the banner from becoming a mess live in store.py, not here, so the panel and any future client inherit them rather than reimplementing them. 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, Body, Header, HTTPException, Query 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") @router.get("/summary") def get_summary(x_admin_token: str = Header(None)): _auth(x_admin_token) return store.summary() @router.get("/leads") def get_leads(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 {"leads": store.list_leads(since=since, q=q, limit=limit, offset=offset)} @router.get("/leads/{record_id}") def get_one(record_id: str, x_admin_token: str = Header(None)): _auth(x_admin_token) rec = store.get_lead(record_id) if not rec: raise HTTPException(404, "no such lead") 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, "leads": store.failed_mirror_records(target)} @router.post("/mirrors/retry") def retry(target: str = "google_sheet", x_admin_token: str = Header(None)): """Replay leads whose copy to an external target failed. Idempotent-ish: a lead 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) 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} # ---------------------------------------------------------------------------- # Announcements - the one writable object here. See the module docstring. # ---------------------------------------------------------------------------- def _reject(e): """Turn a store guardrail into its HTTP answer, carrying the detail so the caller is told what to do rather than just refused.""" payload = {"error": e.detail} payload.update(e.extra) return HTTPException(e.status, payload) @router.get("/announcements") def list_announcements(include_expired: bool = False, limit: int = Query(100, ge=1, le=500), x_admin_token: str = Header(None)): """Every announcement with its computed state: live, scheduled, expired, revoked, or over_cap. State is returned rather than left to be inferred from what the homepage happens to render.""" _auth(x_admin_token) return {"announcements": store.list_announcements( include_expired=include_expired, limit=limit)} @router.post("/announcements", status_code=201) def create_announcement(payload: dict = Body(...), x_admin_token: str = Header(None)): """Post a notice. ends_at is required. 422 if the message is over the character cap or the window is invalid. 409 if the live cap is already reached, listing what is up so you can decide what to revoke.""" _auth(x_admin_token) try: return store.create_announcement( message=payload.get("message"), ends_at=payload.get("ends_at"), starts_at=payload.get("starts_at"), level=payload.get("level", "info"), link_url=payload.get("link_url"), link_text=payload.get("link_text"), created_by=payload.get("created_by"), ) except store.AnnouncementRejected as e: raise _reject(e) @router.delete("/announcements/{announcement_id}") def revoke_announcement(announcement_id: str, x_admin_token: str = Header(None)): """Take one down early. Sets revoked_at; never deletes the row.""" _auth(x_admin_token) if not store.get_announcement(announcement_id): raise HTTPException(404, "no such announcement") if not store.revoke_announcement(announcement_id): raise HTTPException(409, "already revoked") return store.get_announcement(announcement_id)