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
+117 -3
View File
@@ -51,17 +51,23 @@ ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "").strip()
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _auth(request, token, capability):
def _auth(request, token, capability, unit_id=None):
"""Authorise, and return a label naming who acted, for created_by.
Order matters: session first, so a normal signed-in leader never depends on
the shared token, and the token stays a fallback rather than the everyday
path.
unit_id scopes a membership capability to one unit: a pack leader holds
unit:write_own, but only within the pack. Global roles pass a scoped check
for every unit, and the break-glass token reaches everything - it exists
for when identity is broken, so it cannot depend on identity's scoping.
"""
person = auth.current_person(request)
if person:
if not identity.can(person, capability):
raise HTTPException(403, "your account does not have %s" % capability)
if not identity.can(person, capability, unit_id):
raise HTTPException(403, "your account does not have %s" % capability
+ (" for this unit" if unit_id else ""))
return person["email"]
if not ADMIN_TOKEN:
raise HTTPException(503, "admin API disabled: sign in, or set ADMIN_TOKEN")
@@ -179,3 +185,111 @@ def revoke_announcement(request: Request, announcement_id: str, x_admin_token: s
if not store.revoke_announcement(announcement_id):
raise HTTPException(409, "already revoked")
return store.get_announcement(announcement_id)
# ----------------------------------------------------------------------------
# Nearby units - the /find-a-unit directory, and the first writable directory
# data. The verified_at and deactivate-not-delete rules live in store.py so
# any future client inherits them.
# ----------------------------------------------------------------------------
@router.get("/nearby")
def list_nearby(request: Request, include_inactive: bool = False,
x_admin_token: str = Header(None)):
"""The editing view, so deactivated rows are reachable. nearby:write
rather than a read capability: the public page IS the read surface, and
this list exists only to be edited."""
_auth(request, x_admin_token, "nearby:write")
return {"nearby_units": store.list_nearby(include_inactive=include_inactive)}
@router.post("/nearby", status_code=201)
def create_nearby(request: Request, payload: dict = Body(...),
x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "nearby:write")
try:
return store.create_nearby(payload)
except store.NearbyRejected as e:
raise _reject(e)
@router.patch("/nearby/{nearby_id}")
def update_nearby(request: Request, nearby_id: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
"""Partial update. Saving bumps verified_at to today unless the payload
carries an explicit date - see store.py for why saving is verifying."""
_auth(request, x_admin_token, "nearby:write")
try:
rec = store.update_nearby(nearby_id, payload)
except store.NearbyRejected as e:
raise _reject(e)
if not rec:
raise HTTPException(404, "no such nearby unit")
return rec
@router.delete("/nearby/{nearby_id}")
def deactivate_nearby(request: Request, nearby_id: str, x_admin_token: str = Header(None)):
"""Take a unit off the page. Sets active=0; never deletes the row."""
_auth(request, x_admin_token, "nearby:write")
if not store.get_nearby(nearby_id):
raise HTTPException(404, "no such nearby unit")
if not store.deactivate_nearby(nearby_id):
raise HTTPException(409, "already inactive")
return store.get_nearby(nearby_id)
# ----------------------------------------------------------------------------
# Our own units - meeting time and place only. unit:write_own is checked
# against the unit in the URL, so a pack leader edits the pack and not the
# troop; admins hold it globally and reach both.
# ----------------------------------------------------------------------------
@router.get("/units")
def list_units(request: Request, x_admin_token: str = Header(None)):
"""The units the caller may edit, which is what the screen this feeds
shows. A pack leader gets the pack; a global role or the break-glass
token gets everything. The answer IS the scope - no second filter for
the console to get wrong."""
_auth(request, x_admin_token, "unit:write_own")
units = identity.list_units(include_inactive=True)
person = auth.current_person(request)
if person:
units = [u for u in units if identity.can(person, "unit:write_own", u["id"])]
return {"units": units}
@router.patch("/units/{slug_or_id}")
def update_unit(request: Request, slug_or_id: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
unit = identity.get_unit(slug_or_id)
# Scope to the unit when it exists; an unknown slug still goes through
# _auth first, so probing paths answers 401/403 before it answers 404.
_auth(request, x_admin_token, "unit:write_own",
unit_id=unit["id"] if unit else None)
try:
return identity.update_unit_meets(slug_or_id, payload)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
# ----------------------------------------------------------------------------
# Site settings - the typed key registry lives in identity.SETTINGS_KEYS;
# unknown keys are rejected there, not silently stored.
# ----------------------------------------------------------------------------
@router.get("/settings")
def list_settings(request: Request, x_admin_token: str = Header(None)):
_auth(request, x_admin_token, "settings:write")
return {"settings": identity.all_settings()}
@router.put("/settings/{key}")
def put_setting(request: Request, key: str, payload: dict = Body(...),
x_admin_token: str = Header(None)):
"""Set one value, or clear it back to the code default with value: null."""
actor = _auth(request, x_admin_token, "settings:write")
try:
return identity.set_setting(key, payload.get("value"), actor=actor)
except identity.IdentityError as e:
raise HTTPException(e.status, e.detail)
+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]
+10 -9
View File
@@ -17,6 +17,7 @@ and from this page. The rest of the site is our pitch; this one is a door out
for a family we are not the right fit for.
"""
import identity
import store
# unit_type -> the badge a parent actually recognises.
@@ -39,12 +40,10 @@ SERVES_LABEL = {
BEASCOUT = "https://beascout.scouting.org"
# The district's own short link to its unit list. This is the page the district
# hands out, so it is the right thing to credit and the right thing to link.
# One URL for the whole page rather than a per-row column: when the admin panel
# exists this belongs in a settings row, not in seventeen copies.
SOURCE_NAME = "Continental District unit list"
SOURCE_URL = "https://tinyurl.com/ContinentalScouts"
# The district's short link to its unit list is one URL for the whole page
# rather than a per-row column, and it now lives in settings (defaults and
# validation in identity.SETTINGS_KEYS) so the admin panel can change it when
# the district moves the document.
MONTHS = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"]
@@ -103,8 +102,10 @@ def checked_on():
def _esc(s):
"""Quotes included: several of these values are interpolated into
href="..." attributes, where a bare quote is a breakout."""
return (str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
if s else "")
.replace('"', "&quot;") if s else "")
def unit_name(u):
@@ -176,8 +177,8 @@ def body_html(hero_html):
'has every unit in the area.</div>')
checked = checked_on()
src = (f'<a class="nu-src" href="{SOURCE_URL}" target="_blank" '
f'rel="noopener">{SOURCE_NAME}</a>')
src = (f'<a class="nu-src" href="{_esc(identity.get_setting("nearby_source_url"))}" target="_blank" '
f'rel="noopener">{_esc(identity.get_setting("nearby_source_name"))}</a>')
stamp = (f'Pulled from the {src} on {pretty_date(checked)}.' if checked
else f'Pulled from the {src}.')
+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()
+163
View File
@@ -0,0 +1,163 @@
"""
smoke_admin.py - the P2 admin write paths against a throwaway DB.
Runs in-process with no container, no network and no dependencies beyond the
stdlib, so it can be run before anything is committed.
STORE_DB=/tmp/x.db python3 tests/smoke_admin.py
It covers the rules that are expensive to get wrong and invisible when they
are: nearby rows bump verified_at on every save and deactivate rather than
delete, unit edits are meeting fields only and unit-scoped, and settings
accept only registered keys and fall back to the code default on anything
absent or mangled.
"""
import os, sys, tempfile
DB = os.environ.get("STORE_DB") or os.path.join(tempfile.mkdtemp(), "smoke.db")
os.environ["STORE_DB"] = DB
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app"))
import identity as I
import nearby as N
import store as S
PASS = FAIL = 0
def check(label, cond):
global PASS, FAIL
if cond:
PASS += 1
print(" ok %s" % label)
else:
FAIL += 1
print(" FAIL %s" % label)
def raises(label, status, exc, fn, *a, **kw):
try:
fn(*a, **kw)
except exc as e:
check("%s -> %d" % (label, status), e.status == status)
return
except Exception as e:
check("%s -> %d (got %r)" % (label, status, e), False)
return
check("%s -> %d (no error raised)" % (label, status), False)
print("db: %s\n" % DB)
S.init()
I.init()
print("nearby: writes are validated")
raises("bad unit_type", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "trop", "unit_number": "1"})
raises("missing unit_number", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack"})
raises("bad serves", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1", "serves": "everyone"})
raises("http link", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1", "link_url": "http://x.test"})
raises("link with an attribute breakout", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1",
"link_url": 'https://x.test" onmouseover="alert(1)'})
raises("contact without @", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1", "contact": "call Steve"})
raises("contact with an attribute breakout", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1",
"contact": 'a@b.test" onfocus="alert(1)'})
check("renderer escapes quotes as a second line", N._esc('a"b') == "a&quot;b")
raises("unknown field rejected, not dropped", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1", "unit_typo": "pack"})
raises("verified_at must be a date", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1", "verified_at": "yesterday"})
raises("sort_order must be an int", 422, S.NearbyRejected, S.create_nearby,
{"unit_type": "pack", "unit_number": "1", "sort_order": "soon"})
print("\nnearby: create, update, verified_at")
a = S.create_nearby({"unit_type": "pack", "unit_number": "244", "town": "Harleysville",
"area": "North Penn", "serves": "family"})
check("row created", a and a["unit_number"] == "244")
check("active and sort_order defaulted", a["active"] == 1 and a["sort_order"] == 100)
check("verified_at defaults to today", a["verified_at"] == S._today())
b = S.create_nearby({"unit_type": "troop", "unit_number": "27", "area": "North Penn",
"verified_at": "2026-08-01"})
check("explicit verified_at kept", b["verified_at"] == "2026-08-01")
b2 = S.update_nearby(b["id"], {"town": "Lansdale"})
check("partial update lands", b2["town"] == "Lansdale" and b2["unit_number"] == "27")
check("saving bumps verified_at", b2["verified_at"] == S._today())
b3 = S.update_nearby(b["id"], {"notes": "meets in the annexe", "verified_at": "2026-08-15"})
check("explicit verified_at wins on update", b3["verified_at"] == "2026-08-15")
check("update of missing row is None", S.update_nearby("nope", {"town": "x"}) is None)
raises("empty update", 422, S.NearbyRejected, S.update_nearby, b["id"], {})
print("\nnearby: deactivate, never delete")
check("deactivate", S.deactivate_nearby(a["id"]))
check("second deactivate is a no-op", not S.deactivate_nearby(a["id"]))
check("row survives", S.get_nearby(a["id"])["active"] == 0)
check("default list hides it", all(r["id"] != a["id"] for r in S.list_nearby()))
check("include_inactive shows it",
any(r["id"] == a["id"] for r in S.list_nearby(include_inactive=True)))
back = S.update_nearby(a["id"], {"active": 1})
check("reactivate via update", back["active"] == 1)
groups = N.listing()
check("public page groups the live rows",
len(groups) == 1 and groups[0][0] == "North Penn" and len(groups[0][1]) == 2)
print("\nunits: meeting fields only, structured")
pack = I.get_unit("pack73")
check("seed row present", pack and pack["meets_weekday"] == 2)
u = I.update_unit_meets("pack73", {"meets_time": "18:30"})
check("time updated", u["meets_time"] == "18:30")
u = I.update_unit_meets(pack["id"], {"meets_weekday": 4, "meets_at": None})
check("update by id, null allowed", u["meets_weekday"] == 4 and u["meets_at"] is None)
check("other fields untouched", u["display_name"] == pack["display_name"])
raises("weekday 8", 422, I.IdentityError, I.update_unit_meets, "pack73", {"meets_weekday": 8})
raises("weekday as bool", 422, I.IdentityError, I.update_unit_meets, "pack73", {"meets_weekday": True})
raises("display time string", 422, I.IdentityError, I.update_unit_meets, "pack73", {"meets_time": "7pm"})
raises("renaming is not a meeting edit", 422, I.IdentityError, I.update_unit_meets,
"pack73", {"display_name": "Pack 99"})
raises("unknown unit", 404, I.IdentityError, I.update_unit_meets, "pack99", {"meets_time": "18:00"})
raises("nothing to update", 422, I.IdentityError, I.update_unit_meets, "pack73", {})
print("\nsettings: typed keys, default fallback")
check("default when unset",
I.get_setting("nearby_source_name") == "Continental District unit list")
s = I.set_setting("nearby_source_url", "https://example.test/units", actor="a@example.test")
check("set and read back", I.get_setting("nearby_source_url") == "https://example.test/units")
check("set_setting reports itself", s["is_set"] and s["updated_by"] == "a@example.test")
raises("unknown key", 422, I.IdentityError, I.set_setting, "nearby_src_url", "https://x.test")
raises("blank value", 422, I.IdentityError, I.set_setting, "nearby_source_name", " ")
raises("http url", 422, I.IdentityError, I.set_setting, "nearby_source_url", "http://x.test")
cleared = I.set_setting("nearby_source_url", None)
check("null clears to default", not cleared["is_set"]
and I.get_setting("nearby_source_url") == "https://tinyurl.com/ContinentalScouts")
con = I.connect()
con.execute("INSERT INTO settings (key, value, updated_at) VALUES (?,?,?)",
("nearby_source_url", "ftp://mangled", I._now()))
con.commit(); con.close()
check("mangled row falls back to default",
I.get_setting("nearby_source_url") == "https://tinyurl.com/ContinentalScouts")
check("all_settings covers the registry",
{s["key"] for s in I.all_settings()} == set(I.SETTINGS_KEYS))
print("\ncapabilities behind the new routes")
leader = {"memberships": [{"unit_id": "u1", "role": "leader"}]}
member = {"memberships": [{"unit_id": "u1", "role": "member"}]}
admin = {"global_role": "admin", "memberships": []}
check("leader can edit nearby", I.can(leader, "nearby:write"))
check("member cannot", not I.can(member, "nearby:write"))
check("leader edits own unit", I.can(leader, "unit:write_own", "u1"))
check("but not the other one", not I.can(leader, "unit:write_own", "u2"))
check("leader cannot touch settings", not I.can(leader, "settings:write"))
check("admin spans units", I.can(admin, "unit:write_own", "u1") and I.can(admin, "unit:write_own", "u2"))
check("admin holds settings:write", I.can(admin, "settings:write"))
print("\n%d passed, %d failed" % (PASS, FAIL))
sys.exit(1 if FAIL else 0)