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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user