Files
scout-website/app/admin_api.py
T
Mike Wichers be2d81b3ab Lead pipeline: local SQLite store as source of truth
Form submissions now land in /data/scout73.db before anything leaves the
box. The Google Sheet and the ntfy push become mirrors whose per-record
outcome is recorded, so a failed sheet write is replayable instead of
surviving only as a push telling you to retype it.

The table is a generic record store keyed by 'kind', with workflow columns
and an audit trail, so RSVPs and other forms can land in the same place
later without a migration. Admin panel talks to /api/admin over HTTP and
never opens the DB file.

- app/store.py     schema, writes, reads, one-time leads.jsonl backfill
- app/admin_api.py token-gated API; fails closed when ADMIN_TOKEN is unset
- app/app.py       three hunks: imports, boot init, join_post
- compose          ADMIN_TOKEN passed through from the Portainer stack env
2026-08-26 19:38:33 -04:00

105 lines
3.6 KiB
Python

"""
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}