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:
+148
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user