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
105 lines
3.6 KiB
Python
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}
|