Replace the generic records table with a lean join_leads table
records was a leads table wearing a generic name. It carried display_name / email / phone, which only mean anything for a lead, plus status / assigned_to / notes, which were a guess at an outreach process that has not been designed. Contacting a family is one-to-many, so three columns on the lead row was always the wrong shape for it. join_leads is one row per /join submission with one column per form field, two timestamps, and payload holding the submission verbatim. Outreach gets its own table when the process is actually known. Also splits heard_from from heard_from_detail. app.py folded source_place / source_other into a composed "Other: ..." string and threw the raw answer away, which is the half that tells you which daycare the lead came from. The composed value is still built for the Sheet and the ntfy push. admin API: /records -> /leads, and PATCH is gone since a lead now has nothing mutable on it. mirrors and meta are unchanged.
This commit is contained in:
+17
-36
@@ -1,11 +1,16 @@
|
||||
"""
|
||||
admin_api.py - read/act API over the internal record store.
|
||||
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.
|
||||
|
||||
Read-only by design, for now. 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.
|
||||
|
||||
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,
|
||||
@@ -16,7 +21,6 @@ import hmac
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
import store
|
||||
|
||||
@@ -32,61 +36,39 @@ def _auth(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)):
|
||||
@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 {"records": store.list_records(kind=kind, status=status, since=since,
|
||||
q=q, limit=limit, offset=offset)}
|
||||
return {"leads": store.list_leads(since=since, q=q, limit=limit, offset=offset)}
|
||||
|
||||
|
||||
@router.get("/records/{record_id}")
|
||||
@router.get("/leads/{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)
|
||||
rec = store.get_lead(record_id)
|
||||
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")
|
||||
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, "records": store.failed_mirror_records(target)}
|
||||
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 records whose copy to an external target failed. Idempotent-ish:
|
||||
a record already marked ok is never retried."""
|
||||
"""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")
|
||||
@@ -96,7 +78,6 @@ def retry(target: str = "google_sheet", x_admin_token: str = Header(None)):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user