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:
+70
-5
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user