family link: which accounts belong to a household, and GET /api/admin/family
household_people joins a roster family to the accounts of its parents; a
household can have two, a person can rarely be on two. PUT
/roster/households/{id}/people sets it (admin and above). GET /family
(account:self) returns the signed-in person's own households with scouts
and this year's checklist, and nothing else - the parent view of the
roster, read only. Inactive households drop off it.
tests/smoke_admin.py 141 -> 147.
This commit is contained in:
@@ -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))
|
h = store.get_household(hid, _year(year))
|
||||||
if not h:
|
if not h:
|
||||||
raise HTTPException(404, "no such household")
|
raise HTTPException(404, "no such household")
|
||||||
|
h["people"] = store.household_people(hid)
|
||||||
return h
|
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)
|
@router.post("/roster/households", status_code=201)
|
||||||
def create_household(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
def create_household(request: Request, payload: dict = Body(...), x_admin_token: str = Header(None)):
|
||||||
"""A family. Body: parent_name (required), email, phone, second_parent,
|
"""A family. Body: parent_name (required), email, phone, second_parent,
|
||||||
|
|||||||
@@ -125,6 +125,15 @@ CREATE TABLE IF NOT EXISTS scouts (
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_scouts_unit ON scouts(unit_id, active, den);
|
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 (
|
CREATE TABLE IF NOT EXISTS roster_checks (
|
||||||
scout_id TEXT NOT NULL REFERENCES scouts(id),
|
scout_id TEXT NOT NULL REFERENCES scouts(id),
|
||||||
year TEXT NOT NULL,
|
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}
|
return dict(r) if r else {"scout_id": sid, "year": year, "item": item, "done_at": None}
|
||||||
finally:
|
finally:
|
||||||
con.close()
|
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")]
|
||||||
|
|||||||
@@ -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 %s WHERE %s" % (t, k), (hid, h2))
|
||||||
con.execute("DELETE FROM join_leads WHERE id='lead-import-test'"); con.commit(); con.close()
|
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")
|
print("\napi docs registry")
|
||||||
import admin_api as A
|
import admin_api as A
|
||||||
reg = A.describe_routes()
|
reg = A.describe_routes()
|
||||||
|
|||||||
Reference in New Issue
Block a user