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
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
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}
|
||||
Reference in New Issue
Block a user