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:
+171
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user