From 504538567f88b3988bb8f26eb7a68c1dff13dadf Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Fri, 4 Sep 2026 18:56:30 -0400 Subject: [PATCH] roster: households, scouts, a per-year checklist, lead import Decided by Mike 2026-09-04. my.scouting stays the record of registration; this holds what a den leader needs on a Tuesday: the families, which scout is in which den, and a per-program-year checklist of things collected - dues paid, health form handed in - recording THAT a thing was collected, by whom and when, never the thing. Health forms are never stored here; that is a policy, not a gap. A scout is a first name, last name, unit, den and an optional BSA member ID (the recharter join key), and nothing else: no date of birth, no address, nothing medical, and a test asserts no such column exists. A join lead can be imported as a household: contact copied, the children field carried as a note to sort by hand, the lead linked and untouched. Importing twice is 409. roster:write for leader and above, never on a script key. Every write lands in the action log. scout-website-backup.timer already copies the database nightly, which was the doc's first condition for naming scouts. tests/smoke_admin.py 122 -> 141. --- app/admin_api.py | 129 ++++++++++++++++++++++++ app/identity.py | 5 +- app/store.py | 228 +++++++++++++++++++++++++++++++++++++++++++ tests/smoke_admin.py | 42 ++++++++ 4 files changed, 403 insertions(+), 1 deletion(-) diff --git a/app/admin_api.py b/app/admin_api.py index 909a0de..7bf4a01 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -521,6 +521,8 @@ def route_capability(endpoint): return m.group(1) if "_key_owner(" in src: return "apikeys:own" + if "_roster_actor(" in src: + return "roster:write" return None @@ -798,3 +800,130 @@ def reset_link(request: Request, person_id: str, x_admin_token: str = Header(Non if not url: raise HTTPException(404, "no such person") return {"url": url, "expires_hours": identity.RESET_TTL_HOURS} + + +# ---------------------------------------------------------------------------- +# Roster (2026-09-04). roster:write, never on a script key. The program +# year is the site's PROGRAM_YEAR unless the caller names one. +# ---------------------------------------------------------------------------- + +def _year(year): + if year: + return year + import app as main_app + return getattr(main_app, "PROGRAM_YEAR", "2026-2027") + + +def _roster_actor(request, token): + _auth(request, token, "roster:write") + person = _person(request) + if not person: + raise HTTPException(403, "the roster needs a signed-in person") + return person + + +@router.get("/roster") +def get_roster(request: Request, unit: str = None, year: str = None, include_inactive: bool = False, + x_admin_token: str = Header(None)): + """Households with their scouts and this year's checklist. `unit` is a + slug or id. Health forms are never stored: a check says one was + collected, by whom and when, and nothing else.""" + _roster_actor(request, x_admin_token) + unit_id = None + if unit: + u = identity.get_unit(unit) + if not u: + raise HTTPException(404, "no such unit") + unit_id = u["id"] + y = _year(year) + return {"year": y, "items": list(store.ROSTER_ITEMS), "item_words": store.ROSTER_ITEM_WORDS, + "units": identity.list_units(), "households": store.list_roster(y, unit_id, include_inactive)} + + +@router.get("/roster/households/{hid}") +def get_household(request: Request, hid: str, year: str = None, x_admin_token: str = Header(None)): + _roster_actor(request, x_admin_token) + h = store.get_household(hid, _year(year)) + if not h: + raise HTTPException(404, "no such household") + return h + + +@router.post("/roster/households", status_code=201) +def create_household(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)): + """A family. Body: parent_name (required), email, phone, second_parent, + notes. Or `lead_id` alone to import a join lead as the family - the lead + is copied and linked, never changed.""" + actor = _roster_actor(request, x_admin_token) + try: + if payload.get("lead_id"): + hid = store.import_lead(payload["lead_id"], created_by=actor["email"]) + if not hid: + raise HTTPException(404, "no such lead") + _log(request, "roster.imported", "%s from lead %s" % (hid, payload["lead_id"])) + else: + hid = store.create_household(payload, created_by=actor["email"]) + _log(request, "roster.household_created", "%s %s" % (hid, payload.get("parent_name"))) + except store.RosterRejected as e: + raise _reject(e) + return store.get_household(hid, _year(None)) + + +@router.patch("/roster/households/{hid}") +def patch_household(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)): + _roster_actor(request, x_admin_token) + before = store.get_household(hid, _year(None)) + try: + rec = store.update_household(hid, payload) + except store.RosterRejected as e: + raise _reject(e) + if not rec: + raise HTTPException(404, "no such household") + _log(request, "roster.household_updated", "%s: %s" % (hid, _diff(before, rec, list(store.HOUSEHOLD_FIELDS) + ["active"]))) + return store.get_household(hid, _year(None)) + + +@router.post("/roster/households/{hid}/scouts", status_code=201) +def add_scout(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)): + """Body: first_name (required), last_name, unit_id (required), den, bsa_member_id.""" + _roster_actor(request, x_admin_token) + try: + rec = store.add_scout(hid, payload) + except store.RosterRejected as e: + raise _reject(e) + if not rec: + raise HTTPException(404, "no such household") + _log(request, "roster.scout_added", "%s %s (%s)" % (rec["id"], rec["first_name"], rec.get("den") or "-")) + return rec + + +@router.patch("/roster/scouts/{sid}") +def patch_scout(request: Request, sid: str, payload: dict = Body(...), x_admin_token: str = Header(None)): + _roster_actor(request, x_admin_token) + try: + res = store.update_scout(sid, payload) + except store.RosterRejected as e: + raise _reject(e) + if not res: + raise HTTPException(404, "no such scout") + before, after = res + _log(request, "roster.scout_updated", "%s %s: %s" % (sid, after["first_name"], + _diff(before, after, list(store.SCOUT_FIELDS) + ["active"]))) + return after + + +@router.put("/roster/scouts/{sid}/checks/{item}") +def put_check(request: Request, sid: str, item: str, payload: dict = Body(...), year: str = None, + x_admin_token: str = Header(None)): + """Mark an item collected for this year: body {done: true|false, note}. + Records who and when. `dues` and `health_form` today.""" + actor = _roster_actor(request, x_admin_token) + try: + rec = store.set_check(sid, _year(year), item, bool(payload.get("done")), done_by=actor["email"], + note=payload.get("note")) + except store.RosterRejected as e: + raise _reject(e) + if not rec: + raise HTTPException(404, "no such scout") + _log(request, "roster.check", "%s %s %s -> %s" % (sid, _year(year), item, "done" if payload.get("done") else "cleared")) + return rec diff --git a/app/identity.py b/app/identity.py index 55c9005..2cc1715 100644 --- a/app/identity.py +++ b/app/identity.py @@ -77,6 +77,7 @@ CAPS = { "nearby:write", "calendar:write", "unit:write_own", + "roster:write", "apikeys:own", "api:docs", "email:draft", @@ -89,6 +90,7 @@ CAPS = { "nearby:write", "calendar:write", "unit:write_own", + "roster:write", "units:write", "settings:write", "apikeys:own", @@ -985,7 +987,8 @@ KEY_MAX_DAYS = 365 # Scopes a key may never carry, whatever the owner holds. Minting keys from a # key is a loop; the rest are owner powers that belong to a person at a screen. KEY_UNSCOPABLE = {"account:self", "apikeys:own", "api:docs", "history:read", "people:manage", - "secrets:rotate", "people:invite_leader", "people:invite_admin"} + "secrets:rotate", "people:invite_leader", "people:invite_admin", + "roster:write"} # minors' names never ride a script key def scopable_caps(person): diff --git a/app/store.py b/app/store.py index f0138e2..0714748 100644 --- a/app/store.py +++ b/app/store.py @@ -90,6 +90,51 @@ CREATE TABLE IF NOT EXISTS lead_claims ( ); CREATE INDEX IF NOT EXISTS idx_lead_claims_lead ON lead_claims(lead_id, released_at); +-- Roster (decided by Mike 2026-09-04). my.scouting holds the record of +-- truth for registration; this holds what a den leader needs on a Tuesday: +-- who the families are, which scouts are in which den, and a per-year +-- checklist of things collected (dues paid, health form handed in). The +-- checklist records THAT a thing was collected, by whom and when - never +-- the thing. Health forms are never stored here; that is a policy, not a +-- gap. Scouts carry a first name, a last name, a den and an optional BSA +-- member ID (Mike: worth it, it is the recharter join key) and nothing +-- else: no date of birth, no address, nothing medical. +CREATE TABLE IF NOT EXISTS households ( + id TEXT PRIMARY KEY, + parent_name TEXT NOT NULL, + email TEXT, + phone TEXT, + second_parent TEXT, + notes TEXT, + source_lead_id TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + created_by TEXT, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS scouts ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL REFERENCES households(id), + first_name TEXT NOT NULL, + last_name TEXT, + unit_id TEXT NOT NULL, + den TEXT, + bsa_member_id TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_scouts_unit ON scouts(unit_id, active, den); +CREATE TABLE IF NOT EXISTS roster_checks ( + scout_id TEXT NOT NULL REFERENCES scouts(id), + year TEXT NOT NULL, + item TEXT NOT NULL, + done_at TEXT NOT NULL, + done_by TEXT, + note TEXT, + PRIMARY KEY (scout_id, year, item) +); + CREATE TABLE IF NOT EXISTS mirrors ( record_id TEXT NOT NULL, target TEXT NOT NULL, @@ -801,3 +846,186 @@ def deactivate_nearby(nid): return cur.rowcount > 0 finally: con.close() + + +# --------------------------------------------------------------------------- +# Roster. See the schema comment. Every write here is a person's Tuesday +# night bookkeeping; the API logs it, the store keeps it simple. +# --------------------------------------------------------------------------- + +class RosterRejected(Rejected): + pass + + +ROSTER_ITEMS = ("dues", "health_form") +ROSTER_ITEM_WORDS = {"dues": "Dues paid", "health_form": "Health form collected (kept on paper, never here)"} +HOUSEHOLD_FIELDS = ("parent_name", "email", "phone", "second_parent", "notes") +SCOUT_FIELDS = ("first_name", "last_name", "unit_id", "den", "bsa_member_id") + + +def _clean_text(d, fields, required=()): + out = {} + for k in fields: + if k in d: + v = d[k] + v = (v.strip() if isinstance(v, str) else v) or None + out[k] = v + for k in required: + if not out.get(k): + raise RosterRejected(422, "%s is required" % k) + if out.get("email") and "@" not in out["email"]: + raise RosterRejected(422, "email must contain @") + if out.get("bsa_member_id") and not str(out["bsa_member_id"]).isdigit(): + raise RosterRejected(422, "bsa_member_id is digits only") + return out + + +def list_roster(year, unit_id=None, include_inactive=False): + """Households with their scouts and this year's checks, ordered by + parent name. With unit_id, only households that have a scout in it.""" + con = connect() + try: + hh = {r["id"]: dict(r, scouts=[]) for r in con.execute( + "SELECT * FROM households" + ("" if include_inactive else " WHERE active=1") + " ORDER BY parent_name")} + sql = "SELECT s.*, u.slug AS unit_slug, u.short_name AS unit_name FROM scouts s JOIN units u ON u.id=s.unit_id" + sql += "" if include_inactive else " WHERE s.active=1" + sql += " ORDER BY u.sort_order, s.den, s.first_name" + checks = {} + for c in con.execute("SELECT * FROM roster_checks WHERE year=?", (year,)): + checks.setdefault(c["scout_id"], {})[c["item"]] = {"done_at": c["done_at"], "done_by": c["done_by"], "note": c["note"]} + for r in con.execute(sql): + s = dict(r); s["checks"] = checks.get(s["id"], {}) + if s["household_id"] in hh: + hh[s["household_id"]]["scouts"].append(s) + rows = list(hh.values()) + if unit_id: + rows = [h for h in rows if any(s["unit_id"] == unit_id for s in h["scouts"])] + return rows + finally: + con.close() + + +def get_household(hid, year): + con = connect() + try: + r = con.execute("SELECT * FROM households WHERE id=?", (hid,)).fetchone() + if not r: + return None + h = dict(r, scouts=[]) + for s in con.execute("SELECT s.*, u.slug AS unit_slug, u.short_name AS unit_name FROM scouts s" + " JOIN units u ON u.id=s.unit_id WHERE household_id=? ORDER BY first_name", (hid,)): + sd = dict(s); sd["checks"] = {c["item"]: {"done_at": c["done_at"], "done_by": c["done_by"], "note": c["note"]} + for c in con.execute("SELECT * FROM roster_checks WHERE scout_id=? AND year=?", (s["id"], year))} + h["scouts"].append(sd) + return h + finally: + con.close() + + +def create_household(fields, created_by=None, source_lead_id=None): + f = _clean_text(fields, HOUSEHOLD_FIELDS, required=("parent_name",)) + hid = str(uuid.uuid4()) + con = connect() + try: + if source_lead_id and con.execute("SELECT 1 FROM households WHERE source_lead_id=?", (source_lead_id,)).fetchone(): + raise RosterRejected(409, "that lead was already imported") + con.execute("INSERT INTO households (id, parent_name, email, phone, second_parent, notes, source_lead_id," + " active, created_at, created_by, updated_at) VALUES (?,?,?,?,?,?,?,1,?,?,?)", + (hid, f.get("parent_name"), f.get("email"), f.get("phone"), f.get("second_parent"), f.get("notes"), + source_lead_id, _now(), created_by, _now())) + con.commit() + finally: + con.close() + return hid + + +def import_lead(lead_id, created_by=None): + """A lead becomes a household: the parent's name and contact copied, + the lead untouched and linked. Children come as a note to sort out by + hand - the /join form's children field is free text.""" + lead = get_lead(lead_id) + if not lead: + return None + hid = create_household({"parent_name": lead.get("parent_name") or lead.get("email") or "Unknown", + "email": lead.get("email"), "phone": lead.get("phone"), + "notes": ("From the join form: %s" % lead["children"]) if lead.get("children") else None}, + created_by=created_by, source_lead_id=lead_id) + return hid + + +def update_household(hid, fields): + f = _clean_text(fields, HOUSEHOLD_FIELDS + ("active",)) + if not f: + raise RosterRejected(422, "nothing to update") + con = connect() + try: + if not con.execute("SELECT 1 FROM households WHERE id=?", (hid,)).fetchone(): + return None + if "active" in f: + f["active"] = 1 if f["active"] in (1, True, "1", "true") else 0 + sets = ", ".join("%s=?" % k for k in f) + con.execute("UPDATE households SET %s, updated_at=? WHERE id=?" % sets, (*f.values(), _now(), hid)) + con.commit() + return dict(con.execute("SELECT * FROM households WHERE id=?", (hid,)).fetchone()) + finally: + con.close() + + +def add_scout(hid, fields): + f = _clean_text(fields, SCOUT_FIELDS, required=("first_name", "unit_id")) + con = connect() + try: + if not con.execute("SELECT 1 FROM households WHERE id=?", (hid,)).fetchone(): + return None + if not con.execute("SELECT 1 FROM units WHERE id=?", (f["unit_id"],)).fetchone(): + raise RosterRejected(422, "unknown unit") + sid = str(uuid.uuid4()) + con.execute("INSERT INTO scouts (id, household_id, first_name, last_name, unit_id, den, bsa_member_id," + " active, created_at, updated_at) VALUES (?,?,?,?,?,?,?,1,?,?)", + (sid, hid, f["first_name"], f.get("last_name"), f["unit_id"], f.get("den"), f.get("bsa_member_id"), + _now(), _now())) + con.commit() + return dict(con.execute("SELECT * FROM scouts WHERE id=?", (sid,)).fetchone()) + finally: + con.close() + + +def update_scout(sid, fields): + f = _clean_text(fields, SCOUT_FIELDS + ("active",)) + if not f: + raise RosterRejected(422, "nothing to update") + con = connect() + try: + before = con.execute("SELECT * FROM scouts WHERE id=?", (sid,)).fetchone() + if not before: + return None + if "unit_id" in f and not con.execute("SELECT 1 FROM units WHERE id=?", (f["unit_id"],)).fetchone(): + raise RosterRejected(422, "unknown unit") + if "active" in f: + f["active"] = 1 if f["active"] in (1, True, "1", "true") else 0 + sets = ", ".join("%s=?" % k for k in f) + con.execute("UPDATE scouts SET %s, updated_at=? WHERE id=?" % sets, (*f.values(), _now(), sid)) + con.commit() + return dict(before), dict(con.execute("SELECT * FROM scouts WHERE id=?", (sid,)).fetchone()) + finally: + con.close() + + +def set_check(sid, year, item, done, done_by=None, note=None): + """Mark a checklist item collected (or not) for a scout and year.""" + if item not in ROSTER_ITEMS: + raise RosterRejected(422, "item must be one of %s" % (ROSTER_ITEMS,)) + con = connect() + try: + if not con.execute("SELECT 1 FROM scouts WHERE id=?", (sid,)).fetchone(): + return None + if done: + con.execute("INSERT OR REPLACE INTO roster_checks (scout_id, year, item, done_at, done_by, note)" + " VALUES (?,?,?,?,?,?)", (sid, year, item, _now(), done_by, (note or "").strip() or None)) + else: + con.execute("DELETE FROM roster_checks WHERE scout_id=? AND year=? AND item=?", (sid, year, item)) + con.commit() + r = con.execute("SELECT * FROM roster_checks WHERE scout_id=? AND year=? AND item=?", (sid, year, item)).fetchone() + return dict(r) if r else {"scout_id": sid, "year": year, "item": item, "done_at": None} + finally: + con.close() diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index 59cf698..645ab35 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -323,6 +323,48 @@ check("nothing on the lead itself changed", "status" not in S.get_lead(lid) and check("unknown lead is None", S.claim_lead("nope", "p", "e") is None and S.release_lead("nope", "p", "e") is None) con = S.connect(); con.execute("DELETE FROM lead_claims WHERE lead_id=?", (lid,)); con.execute("DELETE FROM join_leads WHERE id=?", (lid,)); con.commit(); con.close() +print("\nroster") +pk = I.get_unit("pack73"); tr = I.get_unit("troop73") +raises("household needs a parent name", 422, S.RosterRejected, S.create_household, {"email": "x@y.test"}) +raises("bad email", 422, S.RosterRejected, S.create_household, {"parent_name": "P", "email": "nope"}) +hid = S.create_household({"parent_name": "Pat Parent", "email": "pat@example.test", "phone": "555"}, created_by="me") +check("household created", S.get_household(hid, "2026-2027")["parent_name"] == "Pat Parent") +raises("scout needs a name and unit", 422, S.RosterRejected, S.add_scout, hid, {"unit_id": pk["id"]}) +raises("bsa id digits only", 422, S.RosterRejected, S.add_scout, hid, {"first_name": "Sam", "unit_id": pk["id"], "bsa_member_id": "12a"}) +raises("unknown unit", 422, S.RosterRejected, S.add_scout, hid, {"first_name": "Sam", "unit_id": "nope"}) +sc = S.add_scout(hid, {"first_name": "Sam", "last_name": "Parent", "unit_id": pk["id"], "den": "Bear", "bsa_member_id": "1234567"}) +sc2 = S.add_scout(hid, {"first_name": "Alex", "unit_id": tr["id"]}) +check("two scouts, two units, one household", len(S.get_household(hid, "2026-2027")["scouts"]) == 2) +check("roster by unit filters households", [h["id"] for h in S.list_roster("2026-2027", pk["id"])] == [hid] + and S.list_roster("2026-2027", "no-such-unit") == []) +raises("check item vocabulary", 422, S.RosterRejected, S.set_check, sc["id"], "2026-2027", "dob", True) +c = S.set_check(sc["id"], "2026-2027", "dues", True, done_by="me", note="cash") +check("check recorded with who and when", c["done_at"] and c["done_by"] == "me" and c["note"] == "cash") +check("check visible on the roster for that year only", + S.get_household(hid, "2026-2027")["scouts"][1 if S.get_household(hid, "2026-2027")["scouts"][0]["first_name"] == "Alex" else 0]["checks"].get("dues") + and not any(s["checks"] for s in S.get_household(hid, "2027-2028")["scouts"])) +check("clearing a check removes it", S.set_check(sc["id"], "2026-2027", "dues", False)["done_at"] is None) +before, after = S.update_scout(sc["id"], {"den": "Webelos"}) +check("scout update returns before and after", before["den"] == "Bear" and after["den"] == "Webelos") +check("scout deactivate hides from the default list", S.update_scout(sc2["id"], {"active": False})[1]["active"] == 0 + and len([s for h in S.list_roster("2026-2027") if h["id"] == hid for s in h["scouts"]]) == 1) +con = S.connect(); con.execute("INSERT INTO join_leads (id, submitted_at, recorded_at, parent_name, email, phone, children, payload) VALUES (?,?,?,?,?,?,?,?)", + ("lead-import-test", I._now(), I._now(), "Lee Lead", "lee@example.test", "555-1", "Kim, 7 and Jo, 9", "{}")); con.commit(); con.close() +h2 = S.import_lead("lead-import-test", created_by="me") +hh = S.get_household(h2, "2026-2027") +check("lead imported as a household, contact copied, children as a note, lead linked", + hh["parent_name"] == "Lee Lead" and hh["email"] == "lee@example.test" and "Kim, 7" in hh["notes"] and hh["source_lead_id"] == "lead-import-test") +raises("importing the same lead twice", 409, S.RosterRejected, S.import_lead, "lead-import-test") +check("the lead itself is untouched", S.get_lead("lead-import-test")["parent_name"] == "Lee Lead" and "household" not in S.get_lead("lead-import-test")) +check("no health data columns exist anywhere", + not any(c for t in ("households", "scouts", "roster_checks") for c in [x[1] for x in S.connect().execute("PRAGMA table_info(%s)" % t)] + if any(k in c for k in ("dob", "birth", "address", "medical", "health_data", "allerg")))) +check("roster:write is not scopable on a key", "roster:write" in I.KEY_UNSCOPABLE and "roster:write" in I.CAPS["leader"]) +con = S.connect() +for t, k in (("roster_checks", "scout_id IN (SELECT id FROM scouts WHERE household_id IN (?,?))"), ("scouts", "household_id IN (?,?)"), ("households", "id IN (?,?)")): + con.execute("DELETE FROM %s WHERE %s" % (t, k), (hid, h2)) +con.execute("DELETE FROM join_leads WHERE id='lead-import-test'"); con.commit(); con.close() + print("\napi docs registry") import admin_api as A reg = A.describe_routes()