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:
@@ -323,6 +323,48 @@ check("nothing on the lead itself changed", "status" not in S.get_lead(lid) and
|
||||
check("unknown lead is None", S.claim_lead("nope", "p", "e") is None and S.release_lead("nope", "p", "e") is None)
|
||||
con = S.connect(); con.execute("DELETE FROM lead_claims WHERE lead_id=?", (lid,)); con.execute("DELETE FROM join_leads WHERE id=?", (lid,)); con.commit(); con.close()
|
||||
|
||||
print("\nroster")
|
||||
pk = I.get_unit("pack73"); tr = I.get_unit("troop73")
|
||||
raises("household needs a parent name", 422, S.RosterRejected, S.create_household, {"email": "x@y.test"})
|
||||
raises("bad email", 422, S.RosterRejected, S.create_household, {"parent_name": "P", "email": "nope"})
|
||||
hid = S.create_household({"parent_name": "Pat Parent", "email": "pat@example.test", "phone": "555"}, created_by="me")
|
||||
check("household created", S.get_household(hid, "2026-2027")["parent_name"] == "Pat Parent")
|
||||
raises("scout needs a name and unit", 422, S.RosterRejected, S.add_scout, hid, {"unit_id": pk["id"]})
|
||||
raises("bsa id digits only", 422, S.RosterRejected, S.add_scout, hid, {"first_name": "Sam", "unit_id": pk["id"], "bsa_member_id": "12a"})
|
||||
raises("unknown unit", 422, S.RosterRejected, S.add_scout, hid, {"first_name": "Sam", "unit_id": "nope"})
|
||||
sc = S.add_scout(hid, {"first_name": "Sam", "last_name": "Parent", "unit_id": pk["id"], "den": "Bear", "bsa_member_id": "1234567"})
|
||||
sc2 = S.add_scout(hid, {"first_name": "Alex", "unit_id": tr["id"]})
|
||||
check("two scouts, two units, one household", len(S.get_household(hid, "2026-2027")["scouts"]) == 2)
|
||||
check("roster by unit filters households", [h["id"] for h in S.list_roster("2026-2027", pk["id"])] == [hid]
|
||||
and S.list_roster("2026-2027", "no-such-unit") == [])
|
||||
raises("check item vocabulary", 422, S.RosterRejected, S.set_check, sc["id"], "2026-2027", "dob", True)
|
||||
c = S.set_check(sc["id"], "2026-2027", "dues", True, done_by="me", note="cash")
|
||||
check("check recorded with who and when", c["done_at"] and c["done_by"] == "me" and c["note"] == "cash")
|
||||
check("check visible on the roster for that year only",
|
||||
S.get_household(hid, "2026-2027")["scouts"][1 if S.get_household(hid, "2026-2027")["scouts"][0]["first_name"] == "Alex" else 0]["checks"].get("dues")
|
||||
and not any(s["checks"] for s in S.get_household(hid, "2027-2028")["scouts"]))
|
||||
check("clearing a check removes it", S.set_check(sc["id"], "2026-2027", "dues", False)["done_at"] is None)
|
||||
before, after = S.update_scout(sc["id"], {"den": "Webelos"})
|
||||
check("scout update returns before and after", before["den"] == "Bear" and after["den"] == "Webelos")
|
||||
check("scout deactivate hides from the default list", S.update_scout(sc2["id"], {"active": False})[1]["active"] == 0
|
||||
and len([s for h in S.list_roster("2026-2027") if h["id"] == hid for s in h["scouts"]]) == 1)
|
||||
con = S.connect(); con.execute("INSERT INTO join_leads (id, submitted_at, recorded_at, parent_name, email, phone, children, payload) VALUES (?,?,?,?,?,?,?,?)",
|
||||
("lead-import-test", I._now(), I._now(), "Lee Lead", "lee@example.test", "555-1", "Kim, 7 and Jo, 9", "{}")); con.commit(); con.close()
|
||||
h2 = S.import_lead("lead-import-test", created_by="me")
|
||||
hh = S.get_household(h2, "2026-2027")
|
||||
check("lead imported as a household, contact copied, children as a note, lead linked",
|
||||
hh["parent_name"] == "Lee Lead" and hh["email"] == "lee@example.test" and "Kim, 7" in hh["notes"] and hh["source_lead_id"] == "lead-import-test")
|
||||
raises("importing the same lead twice", 409, S.RosterRejected, S.import_lead, "lead-import-test")
|
||||
check("the lead itself is untouched", S.get_lead("lead-import-test")["parent_name"] == "Lee Lead" and "household" not in S.get_lead("lead-import-test"))
|
||||
check("no health data columns exist anywhere",
|
||||
not any(c for t in ("households", "scouts", "roster_checks") for c in [x[1] for x in S.connect().execute("PRAGMA table_info(%s)" % t)]
|
||||
if any(k in c for k in ("dob", "birth", "address", "medical", "health_data", "allerg"))))
|
||||
check("roster:write is not scopable on a key", "roster:write" in I.KEY_UNSCOPABLE and "roster:write" in I.CAPS["leader"])
|
||||
con = S.connect()
|
||||
for t, k in (("roster_checks", "scout_id IN (SELECT id FROM scouts WHERE household_id IN (?,?))"), ("scouts", "household_id IN (?,?)"), ("households", "id IN (?,?)")):
|
||||
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("\napi docs registry")
|
||||
import admin_api as A
|
||||
reg = A.describe_routes()
|
||||
|
||||
Reference in New Issue
Block a user