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