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
+148
View File
@@ -333,6 +333,56 @@ def get_unit(slug_or_id):
con.close()
# The only unit fields the admin API lets a leader change. Renaming a unit or
# changing its type is a rechartering event, not a Tuesday edit, and stays a
# code change.
UNIT_MEETS_FIELDS = ("meets_weekday", "meets_time", "meets_at")
def update_unit_meets(slug_or_id, fields):
"""Change when and where a unit meets.
meets_weekday is ISO (Monday=1 .. Sunday=7) and meets_time is 24h "HH:MM",
matching how the seed rows store them; the site composes the display
sentence, so a display string is never accepted here. Any field may be set
to null - a unit between meeting places is a real state.
"""
unit = get_unit(slug_or_id)
if not unit:
raise IdentityError(404, "no such unit")
if not fields:
raise IdentityError(422, "nothing to update")
unknown = sorted(set(fields) - set(UNIT_MEETS_FIELDS))
if unknown:
raise IdentityError(422, "only meeting fields are editable here: %s"
% (", ".join(UNIT_MEETS_FIELDS)))
out = dict(fields)
if "meets_weekday" in out and out["meets_weekday"] is not None:
v = out["meets_weekday"]
if isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= 7:
raise IdentityError(422, "meets_weekday is ISO: 1 (Monday) to 7 (Sunday), or null")
if "meets_time" in out and out["meets_time"] is not None:
try:
datetime.datetime.strptime(str(out["meets_time"]), "%H:%M")
except ValueError:
raise IdentityError(422, 'meets_time must be 24h "HH:MM", or null')
out["meets_time"] = str(out["meets_time"])
if "meets_at" in out and out["meets_at"] is not None:
out["meets_at"] = str(out["meets_at"]).strip() or None
out["updated_at"] = _now()
con = connect()
try:
con.execute("UPDATE units SET %s WHERE id=?"
% ", ".join("%s=?" % k for k in out),
list(out.values()) + [unit["id"]])
con.commit()
finally:
con.close()
return get_unit(unit["id"])
def _person_row(con, r):
if not r:
return None
@@ -710,3 +760,101 @@ def bootstrap():
except Exception as e:
print("identity: bootstrap failed: %s" % e, flush=True)
return None, False
# ---------------------------------------------------------------------------
# Site settings
# ---------------------------------------------------------------------------
#
# Typed key-value pairs for the handful of site-wide values that change more
# often than the code does. SETTINGS_KEYS is the whole contract: a key not in
# it cannot be written, and a key absent from the table - or holding a value
# its checker no longer accepts - falls back to the code default. A mangled
# row can make the site stale, never make it crash.
#
# Setting a value to null clears the row, which IS the fallback: there is no
# stored-but-empty state to reason about.
def _setting_text(v):
v = str(v).strip()
if not v:
raise IdentityError(422, "value must not be blank; send null to clear to the default")
return v
def _setting_https_url(v):
v = _setting_text(v)
if not v.startswith("https://") or any(c in v for c in " \"'<>"):
raise IdentityError(422, "value must be a plain https:// URL")
return v
# key -> (code default, checker)
SETTINGS_KEYS = {
"nearby_source_name": ("Continental District unit list", _setting_text),
"nearby_source_url": ("https://tinyurl.com/ContinentalScouts", _setting_https_url),
}
def get_setting(key):
"""The effective value: the stored one if present and still valid, else
the code default. Unknown keys are a programming error and raise."""
default, check = SETTINGS_KEYS[key]
con = connect()
try:
r = con.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
finally:
con.close()
if not r:
return default
try:
return check(r["value"])
except IdentityError:
return default
def all_settings():
"""Every known key with its effective value, for the settings screen.
Rows for keys the registry no longer knows are not shown - they are dead
weight, not settings."""
con = connect()
try:
stored = {r["key"]: dict(r) for r in con.execute("SELECT * FROM settings")}
finally:
con.close()
out = []
for key, (default, _check) in SETTINGS_KEYS.items():
row = stored.get(key)
out.append({
"key": key,
"value": get_setting(key),
"default": default,
"is_set": row is not None,
"updated_at": row["updated_at"] if row else None,
"updated_by": row["updated_by"] if row else None,
})
return out
def set_setting(key, value, actor=None):
"""Write one setting, or clear it back to the default with value=null."""
if key not in SETTINGS_KEYS:
raise IdentityError(422, "unknown setting %r. Known keys: %s"
% (key, ", ".join(sorted(SETTINGS_KEYS))))
default, check = SETTINGS_KEYS[key]
con = connect()
try:
if value is None:
con.execute("DELETE FROM settings WHERE key=?", (key,))
else:
value = check(value)
con.execute(
"INSERT INTO settings (key, value, updated_at, updated_by)"
" VALUES (?,?,?,?) ON CONFLICT(key) DO UPDATE SET"
" value=excluded.value, updated_at=excluded.updated_at,"
" updated_by=excluded.updated_by",
(key, value, _now(), actor))
con.commit()
finally:
con.close()
return [s for s in all_settings() if s["key"] == key][0]