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
|
||||
|
||||
+4
-1
@@ -77,6 +77,7 @@ CAPS = {
|
||||
"nearby:write",
|
||||
"calendar:write",
|
||||
"unit:write_own",
|
||||
"roster:write",
|
||||
"apikeys:own",
|
||||
"api:docs",
|
||||
"email:draft",
|
||||
@@ -89,6 +90,7 @@ CAPS = {
|
||||
"nearby:write",
|
||||
"calendar:write",
|
||||
"unit:write_own",
|
||||
"roster:write",
|
||||
"units:write",
|
||||
"settings:write",
|
||||
"apikeys:own",
|
||||
@@ -985,7 +987,8 @@ KEY_MAX_DAYS = 365
|
||||
# Scopes a key may never carry, whatever the owner holds. Minting keys from a
|
||||
# key is a loop; the rest are owner powers that belong to a person at a screen.
|
||||
KEY_UNSCOPABLE = {"account:self", "apikeys:own", "api:docs", "history:read", "people:manage",
|
||||
"secrets:rotate", "people:invite_leader", "people:invite_admin"}
|
||||
"secrets:rotate", "people:invite_leader", "people:invite_admin",
|
||||
"roster:write"} # minors' names never ride a script key
|
||||
|
||||
|
||||
def scopable_caps(person):
|
||||
|
||||
+228
@@ -90,6 +90,51 @@ CREATE TABLE IF NOT EXISTS lead_claims (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_claims_lead ON lead_claims(lead_id, released_at);
|
||||
|
||||
-- Roster (decided by Mike 2026-09-04). my.scouting holds the record of
|
||||
-- truth for registration; this holds what a den leader needs on a Tuesday:
|
||||
-- who the families are, which scouts are in which den, and a per-year
|
||||
-- checklist of things collected (dues paid, health form handed in). The
|
||||
-- checklist records 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. Scouts carry a first name, a last name, a den and an optional BSA
|
||||
-- member ID (Mike: worth it, it is the recharter join key) and nothing
|
||||
-- else: no date of birth, no address, nothing medical.
|
||||
CREATE TABLE IF NOT EXISTS households (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
second_parent TEXT,
|
||||
notes TEXT,
|
||||
source_lead_id TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS scouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id),
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
unit_id TEXT NOT NULL,
|
||||
den TEXT,
|
||||
bsa_member_id TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scouts_unit ON scouts(unit_id, active, den);
|
||||
CREATE TABLE IF NOT EXISTS roster_checks (
|
||||
scout_id TEXT NOT NULL REFERENCES scouts(id),
|
||||
year TEXT NOT NULL,
|
||||
item TEXT NOT NULL,
|
||||
done_at TEXT NOT NULL,
|
||||
done_by TEXT,
|
||||
note TEXT,
|
||||
PRIMARY KEY (scout_id, year, item)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mirrors (
|
||||
record_id TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
@@ -801,3 +846,186 @@ def deactivate_nearby(nid):
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Roster. See the schema comment. Every write here is a person's Tuesday
|
||||
# night bookkeeping; the API logs it, the store keeps it simple.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RosterRejected(Rejected):
|
||||
pass
|
||||
|
||||
|
||||
ROSTER_ITEMS = ("dues", "health_form")
|
||||
ROSTER_ITEM_WORDS = {"dues": "Dues paid", "health_form": "Health form collected (kept on paper, never here)"}
|
||||
HOUSEHOLD_FIELDS = ("parent_name", "email", "phone", "second_parent", "notes")
|
||||
SCOUT_FIELDS = ("first_name", "last_name", "unit_id", "den", "bsa_member_id")
|
||||
|
||||
|
||||
def _clean_text(d, fields, required=()):
|
||||
out = {}
|
||||
for k in fields:
|
||||
if k in d:
|
||||
v = d[k]
|
||||
v = (v.strip() if isinstance(v, str) else v) or None
|
||||
out[k] = v
|
||||
for k in required:
|
||||
if not out.get(k):
|
||||
raise RosterRejected(422, "%s is required" % k)
|
||||
if out.get("email") and "@" not in out["email"]:
|
||||
raise RosterRejected(422, "email must contain @")
|
||||
if out.get("bsa_member_id") and not str(out["bsa_member_id"]).isdigit():
|
||||
raise RosterRejected(422, "bsa_member_id is digits only")
|
||||
return out
|
||||
|
||||
|
||||
def list_roster(year, unit_id=None, include_inactive=False):
|
||||
"""Households with their scouts and this year's checks, ordered by
|
||||
parent name. With unit_id, only households that have a scout in it."""
|
||||
con = connect()
|
||||
try:
|
||||
hh = {r["id"]: dict(r, scouts=[]) for r in con.execute(
|
||||
"SELECT * FROM households" + ("" if include_inactive else " WHERE active=1") + " ORDER BY parent_name")}
|
||||
sql = "SELECT s.*, u.slug AS unit_slug, u.short_name AS unit_name FROM scouts s JOIN units u ON u.id=s.unit_id"
|
||||
sql += "" if include_inactive else " WHERE s.active=1"
|
||||
sql += " ORDER BY u.sort_order, s.den, s.first_name"
|
||||
checks = {}
|
||||
for c in con.execute("SELECT * FROM roster_checks WHERE year=?", (year,)):
|
||||
checks.setdefault(c["scout_id"], {})[c["item"]] = {"done_at": c["done_at"], "done_by": c["done_by"], "note": c["note"]}
|
||||
for r in con.execute(sql):
|
||||
s = dict(r); s["checks"] = checks.get(s["id"], {})
|
||||
if s["household_id"] in hh:
|
||||
hh[s["household_id"]]["scouts"].append(s)
|
||||
rows = list(hh.values())
|
||||
if unit_id:
|
||||
rows = [h for h in rows if any(s["unit_id"] == unit_id for s in h["scouts"])]
|
||||
return rows
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def get_household(hid, year):
|
||||
con = connect()
|
||||
try:
|
||||
r = con.execute("SELECT * FROM households WHERE id=?", (hid,)).fetchone()
|
||||
if not r:
|
||||
return None
|
||||
h = dict(r, scouts=[])
|
||||
for s in con.execute("SELECT s.*, u.slug AS unit_slug, u.short_name AS unit_name FROM scouts s"
|
||||
" JOIN units u ON u.id=s.unit_id WHERE household_id=? ORDER BY first_name", (hid,)):
|
||||
sd = dict(s); sd["checks"] = {c["item"]: {"done_at": c["done_at"], "done_by": c["done_by"], "note": c["note"]}
|
||||
for c in con.execute("SELECT * FROM roster_checks WHERE scout_id=? AND year=?", (s["id"], year))}
|
||||
h["scouts"].append(sd)
|
||||
return h
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def create_household(fields, created_by=None, source_lead_id=None):
|
||||
f = _clean_text(fields, HOUSEHOLD_FIELDS, required=("parent_name",))
|
||||
hid = str(uuid.uuid4())
|
||||
con = connect()
|
||||
try:
|
||||
if source_lead_id and con.execute("SELECT 1 FROM households WHERE source_lead_id=?", (source_lead_id,)).fetchone():
|
||||
raise RosterRejected(409, "that lead was already imported")
|
||||
con.execute("INSERT INTO households (id, parent_name, email, phone, second_parent, notes, source_lead_id,"
|
||||
" active, created_at, created_by, updated_at) VALUES (?,?,?,?,?,?,?,1,?,?,?)",
|
||||
(hid, f.get("parent_name"), f.get("email"), f.get("phone"), f.get("second_parent"), f.get("notes"),
|
||||
source_lead_id, _now(), created_by, _now()))
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return hid
|
||||
|
||||
|
||||
def import_lead(lead_id, created_by=None):
|
||||
"""A lead becomes a household: the parent's name and contact copied,
|
||||
the lead untouched and linked. Children come as a note to sort out by
|
||||
hand - the /join form's children field is free text."""
|
||||
lead = get_lead(lead_id)
|
||||
if not lead:
|
||||
return None
|
||||
hid = create_household({"parent_name": lead.get("parent_name") or lead.get("email") or "Unknown",
|
||||
"email": lead.get("email"), "phone": lead.get("phone"),
|
||||
"notes": ("From the join form: %s" % lead["children"]) if lead.get("children") else None},
|
||||
created_by=created_by, source_lead_id=lead_id)
|
||||
return hid
|
||||
|
||||
|
||||
def update_household(hid, fields):
|
||||
f = _clean_text(fields, HOUSEHOLD_FIELDS + ("active",))
|
||||
if not f:
|
||||
raise RosterRejected(422, "nothing to update")
|
||||
con = connect()
|
||||
try:
|
||||
if not con.execute("SELECT 1 FROM households WHERE id=?", (hid,)).fetchone():
|
||||
return None
|
||||
if "active" in f:
|
||||
f["active"] = 1 if f["active"] in (1, True, "1", "true") else 0
|
||||
sets = ", ".join("%s=?" % k for k in f)
|
||||
con.execute("UPDATE households SET %s, updated_at=? WHERE id=?" % sets, (*f.values(), _now(), hid))
|
||||
con.commit()
|
||||
return dict(con.execute("SELECT * FROM households WHERE id=?", (hid,)).fetchone())
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def add_scout(hid, fields):
|
||||
f = _clean_text(fields, SCOUT_FIELDS, required=("first_name", "unit_id"))
|
||||
con = connect()
|
||||
try:
|
||||
if not con.execute("SELECT 1 FROM households WHERE id=?", (hid,)).fetchone():
|
||||
return None
|
||||
if not con.execute("SELECT 1 FROM units WHERE id=?", (f["unit_id"],)).fetchone():
|
||||
raise RosterRejected(422, "unknown unit")
|
||||
sid = str(uuid.uuid4())
|
||||
con.execute("INSERT INTO scouts (id, household_id, first_name, last_name, unit_id, den, bsa_member_id,"
|
||||
" active, created_at, updated_at) VALUES (?,?,?,?,?,?,?,1,?,?)",
|
||||
(sid, hid, f["first_name"], f.get("last_name"), f["unit_id"], f.get("den"), f.get("bsa_member_id"),
|
||||
_now(), _now()))
|
||||
con.commit()
|
||||
return dict(con.execute("SELECT * FROM scouts WHERE id=?", (sid,)).fetchone())
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def update_scout(sid, fields):
|
||||
f = _clean_text(fields, SCOUT_FIELDS + ("active",))
|
||||
if not f:
|
||||
raise RosterRejected(422, "nothing to update")
|
||||
con = connect()
|
||||
try:
|
||||
before = con.execute("SELECT * FROM scouts WHERE id=?", (sid,)).fetchone()
|
||||
if not before:
|
||||
return None
|
||||
if "unit_id" in f and not con.execute("SELECT 1 FROM units WHERE id=?", (f["unit_id"],)).fetchone():
|
||||
raise RosterRejected(422, "unknown unit")
|
||||
if "active" in f:
|
||||
f["active"] = 1 if f["active"] in (1, True, "1", "true") else 0
|
||||
sets = ", ".join("%s=?" % k for k in f)
|
||||
con.execute("UPDATE scouts SET %s, updated_at=? WHERE id=?" % sets, (*f.values(), _now(), sid))
|
||||
con.commit()
|
||||
return dict(before), dict(con.execute("SELECT * FROM scouts WHERE id=?", (sid,)).fetchone())
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def set_check(sid, year, item, done, done_by=None, note=None):
|
||||
"""Mark a checklist item collected (or not) for a scout and year."""
|
||||
if item not in ROSTER_ITEMS:
|
||||
raise RosterRejected(422, "item must be one of %s" % (ROSTER_ITEMS,))
|
||||
con = connect()
|
||||
try:
|
||||
if not con.execute("SELECT 1 FROM scouts WHERE id=?", (sid,)).fetchone():
|
||||
return None
|
||||
if done:
|
||||
con.execute("INSERT OR REPLACE INTO roster_checks (scout_id, year, item, done_at, done_by, note)"
|
||||
" VALUES (?,?,?,?,?,?)", (sid, year, item, _now(), done_by, (note or "").strip() or None))
|
||||
else:
|
||||
con.execute("DELETE FROM roster_checks WHERE scout_id=? AND year=? AND item=?", (sid, year, item))
|
||||
con.commit()
|
||||
r = con.execute("SELECT * FROM roster_checks WHERE scout_id=? AND year=? AND item=?", (sid, year, item)).fetchone()
|
||||
return dict(r) if r else {"scout_id": sid, "year": year, "item": item, "done_at": None}
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
@@ -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