diff --git a/app/admin_api.py b/app/admin_api.py index 0635d03..4e2f664 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -6,10 +6,18 @@ 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. +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 @@ -20,7 +28,7 @@ so an unconfigured deployment must not serve them. import hmac import os -from fastapi import APIRouter, Header, HTTPException, Query +from fastapi import APIRouter, Body, Header, HTTPException, Query import store @@ -83,3 +91,60 @@ def retry(target: str = "google_sheet", x_admin_token: str = Header(None)): 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) diff --git a/app/app.py b/app/app.py index d1c02f3..4cab548 100644 --- a/app/app.py +++ b/app/app.py @@ -1,4 +1,4 @@ -import json, os, re, time, datetime, urllib.request, urllib.parse +import html, json, os, re, time, datetime, urllib.request, urllib.parse from pathlib import Path from fastapi import FastAPI, Form, Request from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse @@ -268,8 +268,104 @@ footer a.fb{display:inline-flex;align-items:center;gap:8px} .docd{display:block;margin-top:3px;color:#6A7280;font-size:14px} .docmeta{font-size:12.5px;font-weight:600;color:#8B93A3;white-space:nowrap} @media(max-width:560px){.docrow{flex-wrap:wrap}.docmeta{width:100%;padding-left:74px}} +.annc{border-bottom:1px solid;font-family:'Public Sans',system-ui,sans-serif} +.annc-info{background:#F3EBD8;border-color:#E8DCBB;color:#6B5510} +.annc-urgent{background:#F6E3DA;border-color:#DFB9A4;color:#8A3B1D} +.anncrow{display:flex;align-items:center;gap:9px;padding:11px 0} +.annc summary{cursor:pointer;list-style:none;-webkit-tap-highlight-color:transparent} +.annc summary::-webkit-details-marker{display:none} +.annc summary:focus-visible{outline:2px solid currentColor;outline-offset:-3px} +.anncico,.anncchev{flex:none;display:flex;align-items:center} +.anncchev{transition:transform .15s ease} +.annc[open] .anncchev{transform:rotate(180deg)} +.anncline{flex:1;min-width:0;font-size:14px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.anncopen{display:none} +.annc[open] .ancctop{display:none} +.annc[open] .anncopen{display:block;font-weight:600} +.annccount{flex:none;font-size:11px;font-weight:700;padding:1px 7px;border-radius:999px;border:1px solid currentColor} +.annc[open] .annccount{display:none} +.anncbody{max-height:40vh;overflow-y:auto;padding-bottom:12px} +.anncitem{padding:10px 0 0;font-size:14px;line-height:1.5;overflow-wrap:anywhere} +.anncitem+.anncitem{border-top:1px solid rgba(0,0,0,.09);padding-top:10px;margin-top:10px} +.anncitem a{color:inherit;text-decoration:underline;font-size:13px} +.ancclink{flex:none;color:inherit;text-decoration:underline;font-size:13px;white-space:nowrap} """ +ICO_INFO = ('') + +ICO_URGENT = ('') + +ICO_CHEV = ('') + + +def _annc_item(a): + """One announcement in the expanded list. Text is escaped; the link is + rendered only from a url store.create_announcement already validated.""" + out = '
%s' % html.escape(a["message"]) + if a.get("link_url"): + out += '
%s' % ( + html.escape(a["link_url"], quote=True), + html.escape(a.get("link_text") or "More details")) + return out + "
" + + +def announcement_bar(): + """Render the live announcements, or nothing at all. + + FAILS OPEN, deliberately. The admin API fails closed because it serves + family phone numbers; this is the opposite case. A broken announcement + must never take down the public homepage, so any error here renders an + empty string and logs. + + Collapsed shows the top-ranked notice on one clamped line plus a count. + Expanded lists all of them. Native
, so there is no JavaScript + and nothing to load - which matters on a read-only rootfs with no build + step. The single-notice case, which is nearly always the case, renders + with no chevron and no count: it must not look like a widget. + """ + try: + items = store.active_announcements() + except Exception as e: + print("announcement_bar: %s" % e, flush=True) + return "" + if not items: + return "" + + top = items[0] + level = "urgent" if any(i["level"] == "urgent" for i in items) else "info" + ico = ICO_URGENT if level == "urgent" else ICO_INFO + label = "Urgent notice" if level == "urgent" else "Notice" + + if len(items) == 1: + link = "" + if top.get("link_url"): + link = ('%s' % ( + html.escape(top["link_url"], quote=True), + html.escape(top.get("link_text") or "More details"))) + return ('
' + '%s' + '%s%s' + '
' % (level, label, ico, html.escape(top["message"]), link)) + + body = "".join(_annc_item(i) for i in items) + return ('
' + '%s' + '%s' + '%d notices' + '+%d' + '%s' + '
%s
' + % (level, ico, html.escape(top["message"]), len(items), + len(items) - 1, ICO_CHEV, body)) + + def page(title, body, active=""): def on(k): return ' class="on"' if active == k else "" return f""" @@ -290,6 +386,7 @@ def page(title, body, active=""): +{announcement_bar()}