P2: nearby units CRUD, unit meeting times, site settings - the first admin writes

Nearby rows bump verified_at on every save and deactivate rather than delete.
Unit edits are meeting fields only, gated by unit:write_own scoped to the unit
in the URL. Settings are a typed key registry falling back to code defaults;
find-a-unit now reads its source name and URL from it. link_url and contact
reject attribute-breakout characters and the nearby renderer escapes quotes.
Covered by tests/smoke_admin.py, 53 checks in-process.
This commit is contained in:
2026-09-04 14:10:29 -04:00
parent 8c8dab12e3
commit 32e1c67a6f
5 changed files with 609 additions and 14 deletions
+171 -2
View File
@@ -46,6 +46,14 @@ ANNOUNCEMENT_MAX_CHARS = 200
ANNOUNCEMENT_MAX_LIVE = 3
ANNOUNCEMENT_LEVELS = ("info", "urgent")
# nearby_units guardrails. pack/troop/ship/club get a recognisable badge on
# /find-a-unit; crew and post are real unit types the district may list and
# render with the generic card. Anything else is a typo, and a typo'd type
# would silently render a unit as generic "Scouting" rather than fail, so it
# is rejected at write time instead.
NEARBY_UNIT_TYPES = ("pack", "troop", "crew", "ship", "post", "club")
NEARBY_SERVES = ("family", "boys", "girls", "coed")
SCHEMA = """
PRAGMA journal_mode=WAL;
@@ -403,9 +411,9 @@ def _backfill(con, path):
# record of what the site said, and when, survives.
class AnnouncementRejected(Exception):
class Rejected(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."""
admin API should return, so the rules live here rather than in the route."""
def __init__(self, status, detail, extra=None):
super().__init__(detail)
@@ -414,6 +422,10 @@ class AnnouncementRejected(Exception):
self.extra = extra or {}
class AnnouncementRejected(Rejected):
pass
def _live_at(con, when):
return con.execute(
"SELECT * FROM announcements"
@@ -539,3 +551,160 @@ def revoke_announcement(aid):
return cur.rowcount > 0
finally:
con.close()
# ----------------------------------------------------------------------------
# Nearby units - writes behind the /find-a-unit courtesy directory.
#
# This is somebody else's data, hand-copied from a district document, and two
# rules follow from that.
#
# verified_at is bumped to today on every row write unless the caller passes
# one explicitly. Saving a row IS the claim that a person just checked it
# against the district list - verified_at is the date the honesty line on the
# public page shows a family, not bookkeeping.
#
# Rows are deactivated, never deleted. A unit that folds or moves keeps its
# row with active=0, so "why did that pack disappear from the page" stays
# answerable. Reactivation is an update setting active back to 1.
# ----------------------------------------------------------------------------
class NearbyRejected(Rejected):
pass
# Everything a caller may set. id and updated_at are the store's own.
NEARBY_FIELDS = ("unit_type", "unit_number", "serves", "chartered_org", "street",
"town", "area", "meets", "notes", "link_url", "contact",
"sort_order", "active", "source", "verified_at")
def _clean_nearby(fields, creating):
"""Validate and normalise a payload. Unknown keys are rejected rather than
dropped - a silently ignored typo ("unit_typo": "pack") would read as a
successful save that changed nothing."""
unknown = sorted(set(fields) - set(NEARBY_FIELDS))
if unknown:
raise NearbyRejected(422, "unknown fields: %s. Editable fields are %s"
% (", ".join(unknown), ", ".join(NEARBY_FIELDS)))
out = {}
for k, v in fields.items():
if isinstance(v, str):
v = v.strip() or None
out[k] = v
if creating or "unit_type" in out:
if out.get("unit_type") not in NEARBY_UNIT_TYPES:
raise NearbyRejected(422, "unit_type must be one of %s" % (NEARBY_UNIT_TYPES,))
if creating or "unit_number" in out:
if not out.get("unit_number"):
raise NearbyRejected(422, "unit_number is required")
out["unit_number"] = str(out["unit_number"])
if out.get("serves") is not None and out["serves"] not in NEARBY_SERVES:
raise NearbyRejected(422, "serves must be one of %s, or null" % (NEARBY_SERVES,))
# Both of these land inside href="..." attributes on the public page, so a
# quote or bracket is an attribute breakout, not a formatting nit. Same
# character set identity._setting_https_url blocks, for the same reason.
if out.get("link_url") is not None:
v = str(out["link_url"])
if not v.startswith("https://") or any(c in v for c in " \"'<>"):
raise NearbyRejected(422, "link_url must be a plain https:// URL")
if out.get("contact") is not None:
v = str(out["contact"])
if "@" not in v or any(c in v for c in " \"'<>"):
raise NearbyRejected(422, "contact is rendered as a mailto: link and must be an email address")
if "sort_order" in out and out["sort_order"] is not None:
try:
out["sort_order"] = int(out["sort_order"])
except (TypeError, ValueError):
raise NearbyRejected(422, "sort_order must be an integer")
if "active" in out:
if out["active"] not in (0, 1, True, False):
raise NearbyRejected(422, "active must be 0 or 1")
out["active"] = int(out["active"])
if "verified_at" in out and out["verified_at"] is not None:
try:
datetime.date.fromisoformat(str(out["verified_at"]))
except ValueError:
raise NearbyRejected(422, "verified_at must be a plain YYYY-MM-DD date")
out["verified_at"] = str(out["verified_at"])
return out
def _today():
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
def list_nearby(include_inactive=False):
con = connect()
try:
sql = "SELECT * FROM nearby_units"
if not include_inactive:
sql += " WHERE active = 1"
sql += " ORDER BY sort_order, unit_number"
return [dict(r) for r in con.execute(sql).fetchall()]
finally:
con.close()
def get_nearby(nid):
con = connect()
try:
row = con.execute("SELECT * FROM nearby_units WHERE id=?", (nid,)).fetchone()
return dict(row) if row else None
finally:
con.close()
def create_nearby(fields):
out = _clean_nearby(fields or {}, creating=True)
if not out.get("verified_at"):
out["verified_at"] = _today()
out.setdefault("active", 1)
out.setdefault("sort_order", 100)
nid = str(uuid.uuid4())
cols = ["id"] + list(out) + ["updated_at"]
vals = [nid] + [out[k] for k in out] + [_now()]
con = connect()
try:
con.execute("INSERT INTO nearby_units (%s) VALUES (%s)"
% (", ".join(cols), ", ".join("?" * len(cols))), vals)
con.commit()
finally:
con.close()
return get_nearby(nid)
def update_nearby(nid, fields):
if not fields:
raise NearbyRejected(422, "nothing to update")
out = _clean_nearby(fields, creating=False)
if not out.get("verified_at"):
out["verified_at"] = _today()
out["updated_at"] = _now()
con = connect()
try:
cur = con.execute("UPDATE nearby_units SET %s WHERE id=?"
% ", ".join("%s=?" % k for k in out),
list(out.values()) + [nid])
con.commit()
if cur.rowcount == 0:
return None
finally:
con.close()
return get_nearby(nid)
def deactivate_nearby(nid):
"""Take a unit off the page. Sets active=0; the row and its history stay."""
con = connect()
try:
cur = con.execute(
"UPDATE nearby_units SET active=0, updated_at=? WHERE id=? AND active=1",
(_now(), nid))
con.commit()
return cur.rowcount > 0
finally:
con.close()