diff --git a/app/admin_api.py b/app/admin_api.py index 7bf4a01..979f26f 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -846,9 +846,44 @@ def get_household(request: Request, hid: str, year: str = None, x_admin_token: s h = store.get_household(hid, _year(year)) if not h: raise HTTPException(404, "no such household") + h["people"] = store.household_people(hid) return h +@router.put("/roster/households/{hid}/people") +def put_household_people(request: Request, hid: str, payload: dict = Body(...), x_admin_token: str = Header(None)): + """Which accounts belong to this family: body {person_ids: [...]}. This is + what a parent's Family page is built from. Admin and above.""" + _auth(request, x_admin_token, "people:invite_leader") + if not _person(request): + raise HTTPException(403, "needs a signed-in person") + ids = [str(p) for p in payload.get("person_ids") or []] + known = {p["id"] for p in identity.list_people()} + bad = [p for p in ids if p not in known] + if bad: + raise HTTPException(422, "unknown person ids: %s" % ", ".join(bad)) + res = store.set_household_people(hid, ids) + if res is None: + raise HTTPException(404, "no such household") + _log(request, "roster.household_people", "%s -> %d account(s)" % (hid, len(res))) + return {"person_ids": res} + + +@router.get("/family") +def my_family(request: Request, year: str = None, x_admin_token: str = Header(None)): + """The signed-in person's own families: scouts, dens, and this year's + checklist as seen from the family's side. account:self, so a parent with + a member account gets exactly their own and nothing else.""" + _auth(request, x_admin_token, "account:self") + person = _person(request) + if not person: + raise HTTPException(403, "needs a signed-in person") + y = _year(year) + return {"year": y, "items": list(store.ROSTER_ITEMS), "item_words": store.ROSTER_ITEM_WORDS, + "households": store.households_for_person(person["id"], y), + "me": {"email": person["email"], "name": person.get("preferred_name") or person.get("full_name")}} + + @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, diff --git a/app/store.py b/app/store.py index 0714748..bcfbee9 100644 --- a/app/store.py +++ b/app/store.py @@ -125,6 +125,15 @@ CREATE TABLE IF NOT EXISTS scouts ( updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_scouts_unit ON scouts(unit_id, active, den); +-- Which accounts belong to which family. A household can have two parents +-- with accounts; a person can, rarely, be on two households. This is what +-- lets a signed-in parent see their own scouts and nobody else's. +CREATE TABLE IF NOT EXISTS household_people ( + household_id TEXT NOT NULL REFERENCES households(id), + person_id TEXT NOT NULL, + PRIMARY KEY (household_id, person_id) +); +CREATE INDEX IF NOT EXISTS idx_household_people_person ON household_people(person_id); CREATE TABLE IF NOT EXISTS roster_checks ( scout_id TEXT NOT NULL REFERENCES scouts(id), year TEXT NOT NULL, @@ -1029,3 +1038,35 @@ def set_check(sid, year, item, done, done_by=None, note=None): return dict(r) if r else {"scout_id": sid, "year": year, "item": item, "done_at": None} finally: con.close() + + +def household_people(hid): + con = connect() + try: + return [r["person_id"] for r in con.execute("SELECT person_id FROM household_people WHERE household_id=?", (hid,))] + finally: + con.close() + + +def set_household_people(hid, person_ids): + """Replace the accounts linked to a family.""" + con = connect() + try: + if not con.execute("SELECT 1 FROM households WHERE id=?", (hid,)).fetchone(): + return None + con.execute("DELETE FROM household_people WHERE household_id=?", (hid,)) + for pid in sorted(set(p for p in person_ids or [] if p)): + con.execute("INSERT INTO household_people (household_id, person_id) VALUES (?,?)", (hid, pid)) + con.commit() + return sorted(set(p for p in person_ids or [] if p)) + finally: + con.close() + + +def households_for_person(person_id, year): + con = connect() + try: + ids = [r["household_id"] for r in con.execute("SELECT household_id FROM household_people WHERE person_id=?", (person_id,))] + finally: + con.close() + return [h for h in (get_household(i, year) for i in ids) if h and h.get("active")] diff --git a/tests/smoke_admin.py b/tests/smoke_admin.py index 645ab35..7429456 100644 --- a/tests/smoke_admin.py +++ b/tests/smoke_admin.py @@ -365,6 +365,19 @@ for t, k in (("roster_checks", "scout_id IN (SELECT id FROM scouts WHERE househo 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("\nfamily link") +hid = S.create_household({"parent_name": "Link Parent"}, created_by="me") +S.add_scout(hid, {"first_name": "Lin", "unit_id": pk["id"], "den": "Wolf"}) +check("no link, no families", S.households_for_person("p-x", "2026-2027") == []) +check("link set", S.set_household_people(hid, ["p-x", "p-y", "p-x"]) == ["p-x", "p-y"] and set(S.household_people(hid)) == {"p-x", "p-y"}) +fam = S.households_for_person("p-x", "2026-2027") +check("a linked person sees the household with scouts and checks", len(fam) == 1 and fam[0]["scouts"][0]["first_name"] == "Lin" and "checks" in fam[0]["scouts"][0]) +check("unlinked person sees nothing", S.households_for_person("p-z", "2026-2027") == []) +S.update_household(hid, {"active": False}) +check("an inactive household drops off the family view", S.households_for_person("p-x", "2026-2027") == []) +check("unknown household is None", S.set_household_people("nope", ["p-x"]) is None) +con = S.connect(); con.execute("DELETE FROM household_people WHERE household_id=?", (hid,)); con.execute("DELETE FROM scouts WHERE household_id=?", (hid,)); con.execute("DELETE FROM households WHERE id=?", (hid,)); con.commit(); con.close() + print("\napi docs registry") import admin_api as A reg = A.describe_routes()