The one string on this site where a deploy is the wrong latency is "tonight's meeting is cancelled, the lot is flooded" at 4pm on a Tuesday. That is a record with a lifecycle, not site copy, so it gets a table and a write path rather than a commit. store.py announcements table, created by the existing IF NOT EXISTS path so there is no migration. ends_at is REQUIRED: an announcement that never expires is site copy, and site copy belongs in the repo where it has a diff. Nothing is hard-deleted; taking one down early sets revoked_at, so what the site said and when survives. Ranking is urgent first, then most recent. Recency alone would let a routine Wednesday notice bury a Tuesday cancellation still live. Two caps, enforced here rather than in the route so the future panel inherits them: 200 characters, and 3 live at once. Both reject rather than truncate. Clipping a cancellation mid-sentence is worse than making someone shorten it, and a 4th live notice is a signal nobody is expiring things rather than something to render. app.py announcement_bar() above the sticky nav. Native <details>, no JavaScript, which matters on a read-only rootfs with no build step. Collapsed clamps to one line with a count; expanded lists all of them and caps at 40vh. One notice renders with no chevron and no count: the common case must not look like a widget. FAILS OPEN. The admin API fails closed because it serves family phone numbers. This is the opposite case, and a broken announcement must never take down the public homepage. Colours are the existing note and rust token pairs from the brand standard. No new colour enters the palette. admin_api.py GET/POST/DELETE on /api/admin/announcements. Leads stay read-only; announcements are the deliberate exception, because being mutable and expiring is the entire feature rather than a guess at a process. list returns a computed state per row (live, scheduled, expired, revoked, over_cap) so "why is my notice not showing" is answerable from the API and not from the homepage.
151 lines
5.8 KiB
Python
151 lines
5.8 KiB
Python
"""
|
|
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)
|