Announcements: a site-wide notice with a start and an end

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.
This commit is contained in:
claude
2026-09-01 19:22:04 -04:00
parent 985253422d
commit e02125392a
3 changed files with 344 additions and 6 deletions
+70 -5
View File
@@ -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)