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:
+176
@@ -39,6 +39,13 @@ DB_PATH = Path(os.environ.get("STORE_DB", "/data/scout73.db"))
|
||||
# Mirror targets - external destinations a row is copied out to.
|
||||
TARGETS = ("google_sheet", "ntfy")
|
||||
|
||||
# Announcement guardrails. Enforced at write time so a bad notice never
|
||||
# reaches a render. Both are rejections, never silent truncation: clipping
|
||||
# someone's cancellation mid-sentence is worse than making them shorten it.
|
||||
ANNOUNCEMENT_MAX_CHARS = 200
|
||||
ANNOUNCEMENT_MAX_LIVE = 3
|
||||
ANNOUNCEMENT_LEVELS = ("info", "urgent")
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
@@ -74,6 +81,21 @@ CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS announcements (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
level TEXT NOT NULL DEFAULT 'info',
|
||||
starts_at TEXT NOT NULL,
|
||||
ends_at TEXT NOT NULL,
|
||||
link_url TEXT,
|
||||
link_text TEXT,
|
||||
created_by TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_window
|
||||
ON announcements(revoked_at, starts_at, ends_at);
|
||||
"""
|
||||
|
||||
|
||||
@@ -336,3 +358,157 @@ def _backfill(con, path):
|
||||
("%s rows at %s" % (n, _now()),))
|
||||
con.commit()
|
||||
print("store: backfilled %s rows from %s" % (n, path), flush=True)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Announcements
|
||||
# ----------------------------------------------------------------------------
|
||||
#
|
||||
# A site-wide notice with a start and an end. It exists because "tonight's
|
||||
# meeting is cancelled, the lot is flooded" at 4pm on a Tuesday is the one
|
||||
# string on this site where a deploy is the wrong latency.
|
||||
#
|
||||
# ends_at is REQUIRED. That is the whole point: nothing has to be remembered
|
||||
# and taken down. An announcement with no end is site copy, and site copy
|
||||
# lives in git where it has a diff.
|
||||
#
|
||||
# Nothing is ever hard-deleted. Taking one down early sets revoked_at, so the
|
||||
# record of what the site said, and when, survives.
|
||||
|
||||
|
||||
class AnnouncementRejected(Exception):
|
||||
"""Raised when a write breaks a guardrail. Carries the HTTP status the
|
||||
admin API should return, so the caps live here rather than in the route."""
|
||||
|
||||
def __init__(self, status, detail, extra=None):
|
||||
super().__init__(detail)
|
||||
self.status = status
|
||||
self.detail = detail
|
||||
self.extra = extra or {}
|
||||
|
||||
|
||||
def _live_at(con, when):
|
||||
return con.execute(
|
||||
"SELECT * FROM announcements"
|
||||
" WHERE revoked_at IS NULL AND starts_at <= ? AND ends_at > ?"
|
||||
" ORDER BY CASE level WHEN 'urgent' THEN 0 ELSE 1 END, starts_at DESC",
|
||||
(when, when),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def active_announcements(now=None):
|
||||
"""The notices that should render, best first.
|
||||
|
||||
Urgent outranks info, then most recent. Recency alone would let a routine
|
||||
Wednesday notice bury a Tuesday cancellation that is still live.
|
||||
|
||||
Capped at ANNOUNCEMENT_MAX_LIVE as a floor under the render even if rows
|
||||
got in past the write check - the banner is never allowed to be unbounded.
|
||||
"""
|
||||
when = now or _now()
|
||||
con = connect()
|
||||
try:
|
||||
return [dict(r) for r in _live_at(con, when)[:ANNOUNCEMENT_MAX_LIVE]]
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def list_announcements(include_expired=False, limit=100):
|
||||
"""Every announcement with a computed live/expired/revoked state.
|
||||
|
||||
The state is returned rather than inferred, so 'why is my notice not
|
||||
showing' is answerable from the API instead of from the homepage.
|
||||
"""
|
||||
now = _now()
|
||||
con = connect()
|
||||
try:
|
||||
sql = "SELECT * FROM announcements"
|
||||
if not include_expired:
|
||||
sql += " WHERE revoked_at IS NULL AND ends_at > '%s'" % now
|
||||
sql += " ORDER BY starts_at DESC LIMIT ?"
|
||||
rows = [dict(r) for r in con.execute(sql, (limit,)).fetchall()]
|
||||
finally:
|
||||
con.close()
|
||||
live_ids = {r["id"] for r in active_announcements(now)}
|
||||
for r in rows:
|
||||
if r["revoked_at"]:
|
||||
r["state"] = "revoked"
|
||||
elif r["ends_at"] <= now:
|
||||
r["state"] = "expired"
|
||||
elif r["starts_at"] > now:
|
||||
r["state"] = "scheduled"
|
||||
elif r["id"] in live_ids:
|
||||
r["state"] = "live"
|
||||
else:
|
||||
r["state"] = "over_cap"
|
||||
return rows
|
||||
|
||||
|
||||
def create_announcement(message, ends_at, starts_at=None, level="info",
|
||||
link_url=None, link_text=None, created_by=None):
|
||||
message = (message or "").strip()
|
||||
if not message:
|
||||
raise AnnouncementRejected(422, "message is required")
|
||||
if len(message) > ANNOUNCEMENT_MAX_CHARS:
|
||||
raise AnnouncementRejected(422, (
|
||||
"message is %d characters and the cap is %d. Put the long version on a "
|
||||
"documents page and link to it with link_url."
|
||||
% (len(message), ANNOUNCEMENT_MAX_CHARS)))
|
||||
if level not in ANNOUNCEMENT_LEVELS:
|
||||
raise AnnouncementRejected(422, "level must be one of %s" % (ANNOUNCEMENT_LEVELS,))
|
||||
if not ends_at:
|
||||
raise AnnouncementRejected(422, (
|
||||
"ends_at is required. An announcement that never expires is site copy, "
|
||||
"and site copy belongs in the repo where it has a diff."))
|
||||
if link_text and not link_url:
|
||||
raise AnnouncementRejected(422, "link_text without link_url has nothing to point at")
|
||||
if link_url and not str(link_url).startswith(("/", "https://")):
|
||||
raise AnnouncementRejected(422, "link_url must be site-relative or https")
|
||||
|
||||
starts = _norm(starts_at) if starts_at else _now()
|
||||
ends = _norm(ends_at)
|
||||
if ends <= starts:
|
||||
raise AnnouncementRejected(422, "ends_at must be after starts_at")
|
||||
|
||||
aid = str(uuid.uuid4())
|
||||
con = connect()
|
||||
try:
|
||||
live = _live_at(con, starts)
|
||||
if len(live) >= ANNOUNCEMENT_MAX_LIVE:
|
||||
raise AnnouncementRejected(409, (
|
||||
"%d announcements are already live at that start time and the cap is %d. "
|
||||
"Revoke one first." % (len(live), ANNOUNCEMENT_MAX_LIVE)),
|
||||
{"live": [{k: r[k] for k in ("id", "message", "level", "ends_at")}
|
||||
for r in live]})
|
||||
con.execute(
|
||||
"INSERT INTO announcements (id, created_at, message, level, starts_at,"
|
||||
" ends_at, link_url, link_text, created_by, revoked_at)"
|
||||
" VALUES (?,?,?,?,?,?,?,?,?,NULL)",
|
||||
(aid, _now(), message, level, starts, ends,
|
||||
link_url or None, link_text or None, created_by or None))
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return get_announcement(aid)
|
||||
|
||||
|
||||
def get_announcement(aid):
|
||||
con = connect()
|
||||
try:
|
||||
row = con.execute("SELECT * FROM announcements WHERE id=?", (aid,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def revoke_announcement(aid):
|
||||
"""Take one down early. Never deletes - the site's history is the point."""
|
||||
con = connect()
|
||||
try:
|
||||
cur = con.execute(
|
||||
"UPDATE announcements SET revoked_at=? WHERE id=? AND revoked_at IS NULL",
|
||||
(_now(), aid))
|
||||
con.commit()
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
Reference in New Issue
Block a user