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.
This commit is contained in:
2026-09-04 18:56:30 -04:00
parent 59559ad909
commit 504538567f
4 changed files with 403 additions and 1 deletions
+129
View File
@@ -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
+4 -1
View File
@@ -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):
+228
View File
@@ -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()