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