diff --git a/app/admin_api.py b/app/admin_api.py index 12a4615..135d155 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -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) diff --git a/app/identity.py b/app/identity.py index d87659c..1450878 100644 --- a/app/identity.py +++ b/app/identity.py @@ -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] diff --git a/app/nearby.py b/app/nearby.py index 4b46dd5..b2fe4f0 100644 --- a/app/nearby.py +++ b/app/nearby.py @@ -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("&", "&").replace("<", "<").replace(">", ">") - if s else "") + .replace('"', """) if s else "") def unit_name(u): @@ -176,8 +177,8 @@ def body_html(hero_html): 'has every unit in the area.') checked = checked_on() - src = (f'{SOURCE_NAME}') + src = (f'{_esc(identity.get_setting("nearby_source_name"))}') stamp = (f'Pulled from the {src} on {pretty_date(checked)}.' if checked else f'Pulled from the {src}.') diff --git a/app/store.py b/app/store.py index c3ac782..0a7d283 100644 --- a/app/store.py +++ b/app/store.py @@ -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() diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py new file mode 100644 index 0000000..fbd3052 --- /dev/null +++ b/tests/smoke_admin.py @@ -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"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)