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 ('