Files
scout-website/app/store.py
T
thethreemagi 504538567f 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.
2026-09-04 18:56:30 -04:00

1032 lines
40 KiB
Python

"""
store.py - internal record store for greenlanescouts73.org
What this holds
---------------
One table per thing the site collects. Today that is `join_leads`: the /join
interest form, one row per submission, one column per form field.
A /join submission lands here FIRST. The Google Sheet and the ntfy push are
mirrors of a row that already exists locally, and each mirror's outcome is
recorded per row so a failure is replayable instead of just shouted.
Why join_leads and not a generic table
-------------------------------------
This started as a generic `records` table keyed by `kind`, carrying
status/assigned_to/notes for a future admin panel. That was wrong twice over.
The PII columns (display_name/email/phone) only make sense for a lead, and the
workflow columns describe an outreach process that has not been designed yet -
one that is plainly one-to-many, since a family gets contacted more than once.
Guessing at it in three columns would have locked in the wrong shape.
So: a table per form, columns for the form's own fields, and outreach gets its
own table when the process is actually known. Anything the form adds later that
is not worth a column still survives in `payload`, which holds the submission
verbatim.
Stdlib only - sqlite3 ships with Python, so this adds no image dependencies.
"""
import json
import os
import sqlite3
import uuid
import datetime
from pathlib import Path
DB_PATH = Path(os.environ.get("STORE_DB", "/data/scout73.db"))
# Mirror targets - external destinations a row is copied out to.
TARGETS = ("google_sheet", "ntfy")
# Announcement guardrails. Enforced at write time so a bad notice never
# reaches a render. Both are rejections, never silent truncation: clipping
# someone's cancellation mid-sentence is worse than making them shorten it.
ANNOUNCEMENT_MAX_CHARS = 200
ANNOUNCEMENT_MAX_LIVE = 3
ANNOUNCEMENT_LEVELS = ("info", "urgent")
# nearby_units guardrails. pack/troop/ship/club get a recognisable badge on
# /find-a-unit; crew and post are real unit types the district may list and
# render with the generic card. Anything else is a typo, and a typo'd type
# would silently render a unit as generic "Scouting" rather than fail, so it
# is rejected at write time instead.
NEARBY_UNIT_TYPES = ("pack", "troop", "crew", "ship", "post", "club")
NEARBY_SERVES = ("family", "boys", "girls", "coed")
SCHEMA = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS join_leads (
id TEXT PRIMARY KEY,
submitted_at TEXT NOT NULL,
recorded_at TEXT NOT NULL,
parent_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
interested_in TEXT,
children TEXT,
heard_from TEXT,
heard_from_detail TEXT,
message TEXT,
payload TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_join_leads_submitted ON join_leads(submitted_at DESC);
CREATE INDEX IF NOT EXISTS idx_join_leads_email ON join_leads(email);
-- Lead claims (outreach v1, decided by Mike 2026-09-04): who picked a lead
-- up and when, so nothing sits waiting unnoticed. No outcomes, no end
-- states - the lead stays the record of what the family said, this is only
-- who has it. One row per claim; the current claim is the latest with no
-- released_at. Nothing here touches join_leads.
CREATE TABLE IF NOT EXISTS lead_claims (
id TEXT PRIMARY KEY,
lead_id TEXT NOT NULL REFERENCES join_leads(id),
person_id TEXT NOT NULL,
email TEXT NOT NULL,
claimed_at TEXT NOT NULL,
released_at TEXT,
released_by TEXT
);
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,
state TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_attempt_at TEXT,
PRIMARY KEY (record_id, target)
);
CREATE INDEX IF NOT EXISTS idx_mirrors_state ON mirrors(target, state);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS announcements (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
message TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info',
starts_at TEXT NOT NULL,
ends_at TEXT NOT NULL,
link_url TEXT,
link_text TEXT,
created_by TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_announcements_window
ON announcements(revoked_at, starts_at, ends_at);
-- Other Scouting units in the Continental District, published as a courtesy
-- directory. This is somebody else's data: it comes from a hand-typed district
-- document and goes stale without telling us, so `verified_at` is shown to the
-- reader rather than kept as bookkeeping. Pack 73 and Troop 73 are deliberately
-- NOT rows here - the rest of this site is us.
CREATE TABLE IF NOT EXISTS nearby_units (
id TEXT PRIMARY KEY,
unit_type TEXT NOT NULL,
unit_number TEXT NOT NULL,
serves TEXT,
chartered_org TEXT,
street TEXT,
town TEXT,
area TEXT,
meets TEXT,
notes TEXT,
link_url TEXT,
contact TEXT,
sort_order INTEGER NOT NULL DEFAULT 100,
active INTEGER NOT NULL DEFAULT 1,
source TEXT,
verified_at TEXT,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_nearby_units_listing
ON nearby_units(active, sort_order, unit_number);
"""
def _now():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
def _norm(ts):
"""Normalise any incoming timestamp to UTC ISO-8601 with an offset.
app.py builds its submission timestamp with a naive datetime.now(), i.e.
local Eastern time. Storing that next to UTC rows would silently break both
sorting and `since=` filters, so everything that lands in the DB is
converted here.
"""
if not ts:
return _now()
try:
dt = datetime.datetime.fromisoformat(ts)
except Exception:
return _now()
if dt.tzinfo is None:
dt = dt.astimezone() # interpret as this host's local time
return dt.astimezone(datetime.timezone.utc).isoformat(timespec="seconds")
def connect():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH, timeout=10)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys=ON")
return con
def init(backfill_jsonl=None):
"""Create the schema. Safe to call on every boot."""
con = connect()
try:
con.executescript(SCHEMA)
con.commit()
if backfill_jsonl:
_backfill(con, Path(backfill_jsonl))
finally:
con.close()
# ----------------------------------------------------------------------------
# Writes
# ----------------------------------------------------------------------------
def insert_lead(parent_name, email, phone=None, interested_in=None, children=None,
heard_from=None, heard_from_detail=None, message=None,
payload=None, submitted_at=None, record_id=None):
"""Insert one /join submission and return its id. Raises on failure -
callers decide what a failed local write means."""
rid = record_id or str(uuid.uuid4())
submitted = _norm(submitted_at)
con = connect()
try:
con.execute(
"INSERT INTO join_leads (id, submitted_at, recorded_at, parent_name, email,"
" phone, interested_in, children, heard_from, heard_from_detail, message, payload)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(rid, submitted, _now(), parent_name, email, phone or None,
interested_in or None, children or None, heard_from or None,
heard_from_detail or None, message or None,
json.dumps(payload or {}, ensure_ascii=False)),
)
con.commit()
finally:
con.close()
return rid
def set_mirror_pending(record_id, target):
"""Register a mirror as owed, before it is attempted.
Without this a mirror that never ran leaves no row at all, which reads
identically to a mirror that was never owed. That is how two leads went
missing from the Sheet on 2026-08-26 with a clean `failed_mirrors: {}`.
Never downgrades an existing row - a mirror already ok or failed has been
attempted, and its outcome stands.
"""
con = connect()
try:
con.execute(
"INSERT INTO mirrors (record_id, target, state, attempts, last_attempt_at)"
" VALUES (?,?,'pending',0,NULL)"
" ON CONFLICT(record_id, target) DO NOTHING",
(record_id, target),
)
con.commit()
finally:
con.close()
def set_mirror(record_id, target, ok, error=None):
"""Record the outcome of an attempt to copy a row to an external target."""
ts = _now()
state = "ok" if ok else "failed"
con = connect()
try:
con.execute(
"INSERT INTO mirrors (record_id, target, state, attempts, last_error, last_attempt_at)"
" VALUES (?,?,?,1,?,?)"
" ON CONFLICT(record_id, target) DO UPDATE SET"
" state=excluded.state,"
" attempts=mirrors.attempts+1,"
" last_error=excluded.last_error,"
" last_attempt_at=excluded.last_attempt_at",
(record_id, target, state, (str(error)[:500] if error else None), ts),
)
con.commit()
finally:
con.close()
# ----------------------------------------------------------------------------
# Reads
# ----------------------------------------------------------------------------
def _row(r):
d = dict(r)
if d.get("payload"):
try:
d["payload"] = json.loads(d["payload"])
except Exception:
pass
return d
def get_lead(record_id):
con = connect()
try:
r = con.execute("SELECT * FROM join_leads WHERE id=?", (record_id,)).fetchone()
if not r:
return None
out = _row(r)
out["mirrors"] = [dict(m) for m in con.execute(
"SELECT target, state, attempts, last_error, last_attempt_at"
" FROM mirrors WHERE record_id=?", (record_id,))]
cur = current_claim(con, record_id)
out["claim"] = {"email": cur["email"], "claimed_at": cur["claimed_at"]} if cur else None
out["claim_history"] = [dict(c) for c in con.execute(
"SELECT email, claimed_at, released_at, released_by FROM lead_claims WHERE lead_id=?"
" ORDER BY claimed_at DESC", (record_id,))]
return out
finally:
con.close()
def list_leads(since=None, q=None, limit=100, offset=0):
where, vals = [], []
if since:
where.append("submitted_at>=?"); vals.append(since)
if q:
where.append("(parent_name LIKE ? OR email LIKE ? OR children LIKE ?"
" OR heard_from LIKE ? OR message LIKE ?)")
vals += ["%%%s%%" % q] * 5
sql = "SELECT * FROM join_leads"
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY submitted_at DESC LIMIT ? OFFSET ?"
vals += [max(1, min(int(limit), 500)), max(0, int(offset))]
con = connect()
try:
rows = [_row(r) for r in con.execute(sql, vals)]
ids = [r["id"] for r in rows]
if ids:
marks = ",".join("?" * len(ids))
mir = {}
for m in con.execute(
"SELECT record_id, target, state FROM mirrors WHERE record_id IN (%s)" % marks, ids):
mir.setdefault(m["record_id"], {})[m["target"]] = m["state"]
for r in rows:
r["mirrors"] = mir.get(r["id"], {})
claims = {}
for c in con.execute(
"SELECT lead_id, email, claimed_at FROM lead_claims WHERE released_at IS NULL"
" AND lead_id IN (%s)" % marks, ids):
claims[c["lead_id"]] = {"email": c["email"], "claimed_at": c["claimed_at"]}
for r in rows:
r["claim"] = claims.get(r["id"])
return rows
finally:
con.close()
def summary():
"""Counts for a dashboard: totals, interest split, referral sources, failed mirrors."""
con = connect()
try:
return {
"total": con.execute("SELECT COUNT(*) c FROM join_leads").fetchone()["c"],
"unclaimed": con.execute("SELECT count(*) FROM join_leads l WHERE NOT EXISTS"
" (SELECT 1 FROM lead_claims c WHERE c.lead_id=l.id AND c.released_at IS NULL)").fetchone()[0],
"by_interest": {r["interested_in"]: r["c"] for r in con.execute(
"SELECT interested_in, COUNT(*) c FROM join_leads GROUP BY interested_in")},
"by_heard_from": {r["heard_from"]: r["c"] for r in con.execute(
"SELECT heard_from, COUNT(*) c FROM join_leads GROUP BY heard_from"
" ORDER BY c DESC")},
"failed_mirrors": {r["target"]: r["c"] for r in con.execute(
"SELECT target, COUNT(*) c FROM mirrors WHERE state='failed' GROUP BY target")},
"pending_mirrors": {r["target"]: r["c"] for r in con.execute(
"SELECT target, COUNT(*) c FROM mirrors WHERE state='pending' GROUP BY target")},
}
finally:
con.close()
def failed_mirror_records(target, limit=50):
"""Rows owing a copy to `target`: attempted and failed, or never attempted.
`pending` is included deliberately. A mirror that never ran needs replaying
just as much as one that ran and failed, and it is the case that hid two
leads on 2026-08-26.
"""
con = connect()
try:
return [_row(r) for r in con.execute(
"SELECT l.* FROM join_leads l JOIN mirrors m ON m.record_id=l.id"
" WHERE m.target=? AND m.state IN ('failed','pending')"
" ORDER BY l.submitted_at LIMIT ?",
(target, limit))]
finally:
con.close()
# ----------------------------------------------------------------------------
# One-time backfill of the pre-existing raw log
# ----------------------------------------------------------------------------
def split_heard_from(source):
"""app.py used to fold source_place / source_other into one composed string
('Other: Chocolate booth'), destroying the raw answer. Recover both halves."""
s = (source or "").strip()
if s.startswith("School/Daycare: "):
return "Other school or daycare", s[len("School/Daycare: "):].strip()
if s.startswith("Other: "):
return "Other", s[len("Other: "):].strip()
return s or None, None
def _backfill(con, path):
"""Import /data/leads.jsonl once, so history is not stranded outside the DB."""
done = con.execute("SELECT value FROM meta WHERE key='backfill_leads_jsonl'").fetchone()
if done or not path.exists():
return
n = 0
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except Exception:
continue
heard, detail = split_heard_from(rec.get("source"))
ts = _norm(rec.get("ts"))
con.execute(
"INSERT INTO join_leads (id, submitted_at, recorded_at, parent_name, email,"
" phone, interested_in, children, heard_from, heard_from_detail, message, payload)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), ts, _now(),
rec.get("parent_name") or "(unknown)", rec.get("email") or "",
rec.get("phone") or None, rec.get("interested_in") or None,
rec.get("children") or None, heard, detail,
rec.get("message") or None,
json.dumps(rec, ensure_ascii=False)),
)
n += 1
con.execute("INSERT INTO meta (key, value) VALUES ('backfill_leads_jsonl', ?)",
("%s rows at %s" % (n, _now()),))
con.commit()
print("store: backfilled %s rows from %s" % (n, path), flush=True)
# ----------------------------------------------------------------------------
# Announcements
# ----------------------------------------------------------------------------
#
# A site-wide notice with a start and an end. It exists because "tonight's
# meeting is cancelled, the lot is flooded" at 4pm on a Tuesday is the one
# string on this site where a deploy is the wrong latency.
#
# ends_at is REQUIRED. That is the whole point: nothing has to be remembered
# and taken down. An announcement with no end is site copy, and site copy
# lives in git where it has a diff.
#
# Nothing is ever hard-deleted. Taking one down early sets revoked_at, so the
# record of what the site said, and when, survives.
class Rejected(Exception):
"""Raised when a write breaks a guardrail. Carries the HTTP status the
admin API should return, so the rules live here rather than in the route."""
def __init__(self, status, detail, extra=None):
super().__init__(detail)
self.status = status
self.detail = detail
self.extra = extra or {}
class AnnouncementRejected(Rejected):
pass
def _live_at(con, when):
return con.execute(
"SELECT * FROM announcements"
" WHERE revoked_at IS NULL AND starts_at <= ? AND ends_at > ?"
" ORDER BY CASE level WHEN 'urgent' THEN 0 ELSE 1 END, starts_at DESC",
(when, when),
).fetchall()
def active_announcements(now=None):
"""The notices that should render, best first.
Urgent outranks info, then most recent. Recency alone would let a routine
Wednesday notice bury a Tuesday cancellation that is still live.
Capped at ANNOUNCEMENT_MAX_LIVE as a floor under the render even if rows
got in past the write check - the banner is never allowed to be unbounded.
"""
when = now or _now()
con = connect()
try:
return [dict(r) for r in _live_at(con, when)[:ANNOUNCEMENT_MAX_LIVE]]
finally:
con.close()
def list_announcements(include_expired=False, limit=100):
"""Every announcement with a computed live/expired/revoked state.
The state is returned rather than inferred, so 'why is my notice not
showing' is answerable from the API instead of from the homepage.
"""
now = _now()
con = connect()
try:
sql = "SELECT * FROM announcements"
if not include_expired:
sql += " WHERE revoked_at IS NULL AND ends_at > '%s'" % now
sql += " ORDER BY starts_at DESC LIMIT ?"
rows = [dict(r) for r in con.execute(sql, (limit,)).fetchall()]
finally:
con.close()
live_ids = {r["id"] for r in active_announcements(now)}
for r in rows:
if r["revoked_at"]:
r["state"] = "revoked"
elif r["ends_at"] <= now:
r["state"] = "expired"
elif r["starts_at"] > now:
r["state"] = "scheduled"
elif r["id"] in live_ids:
r["state"] = "live"
else:
r["state"] = "over_cap"
return rows
def create_announcement(message, ends_at, starts_at=None, level="info",
link_url=None, link_text=None, created_by=None):
message = (message or "").strip()
if not message:
raise AnnouncementRejected(422, "message is required")
if len(message) > ANNOUNCEMENT_MAX_CHARS:
raise AnnouncementRejected(422, (
"message is %d characters and the cap is %d. Put the long version on a "
"documents page and link to it with link_url."
% (len(message), ANNOUNCEMENT_MAX_CHARS)))
if level not in ANNOUNCEMENT_LEVELS:
raise AnnouncementRejected(422, "level must be one of %s" % (ANNOUNCEMENT_LEVELS,))
if not ends_at:
raise AnnouncementRejected(422, (
"ends_at is required. An announcement that never expires is site copy, "
"and site copy belongs in the repo where it has a diff."))
if link_text and not link_url:
raise AnnouncementRejected(422, "link_text without link_url has nothing to point at")
if link_url and not str(link_url).startswith(("/", "https://")):
raise AnnouncementRejected(422, "link_url must be site-relative or https")
starts = _norm(starts_at) if starts_at else _now()
ends = _norm(ends_at)
if ends <= starts:
raise AnnouncementRejected(422, "ends_at must be after starts_at")
aid = str(uuid.uuid4())
con = connect()
try:
live = _live_at(con, starts)
if len(live) >= ANNOUNCEMENT_MAX_LIVE:
raise AnnouncementRejected(409, (
"%d announcements are already live at that start time and the cap is %d. "
"Revoke one first." % (len(live), ANNOUNCEMENT_MAX_LIVE)),
{"live": [{k: r[k] for k in ("id", "message", "level", "ends_at")}
for r in live]})
con.execute(
"INSERT INTO announcements (id, created_at, message, level, starts_at,"
" ends_at, link_url, link_text, created_by, revoked_at)"
" VALUES (?,?,?,?,?,?,?,?,?,NULL)",
(aid, _now(), message, level, starts, ends,
link_url or None, link_text or None, created_by or None))
con.commit()
finally:
con.close()
return get_announcement(aid)
def get_announcement(aid):
con = connect()
try:
row = con.execute("SELECT * FROM announcements WHERE id=?", (aid,)).fetchone()
return dict(row) if row else None
finally:
con.close()
def revoke_announcement(aid):
"""Take one down early. Never deletes - the site's history is the point."""
con = connect()
try:
cur = con.execute(
"UPDATE announcements SET revoked_at=? WHERE id=? AND revoked_at IS NULL",
(_now(), aid))
con.commit()
return cur.rowcount > 0
finally:
con.close()
# ----------------------------------------------------------------------------
# Nearby units - writes behind the /find-a-unit courtesy directory.
#
# This is somebody else's data, hand-copied from a district document, and two
# rules follow from that.
#
# verified_at is bumped to today on every row write unless the caller passes
# one explicitly. Saving a row IS the claim that a person just checked it
# against the district list - verified_at is the date the honesty line on the
# public page shows a family, not bookkeeping.
#
# Rows are deactivated, never deleted. A unit that folds or moves keeps its
# row with active=0, so "why did that pack disappear from the page" stays
# answerable. Reactivation is an update setting active back to 1.
# ---------------------------------------------------------------------------
# Lead claims - outreach v1. See the schema comment.
# ---------------------------------------------------------------------------
class ClaimRejected(Rejected):
pass
def current_claim(con, lead_id):
r = con.execute("SELECT * FROM lead_claims WHERE lead_id=? AND released_at IS NULL"
" ORDER BY claimed_at DESC LIMIT 1", (lead_id,)).fetchone()
return dict(r) if r else None
def claim_lead(lead_id, person_id, email):
"""Take a lead. If someone else holds it, 409 with who - taking it over
is a deliberate release-then-claim, never a silent overwrite."""
con = connect()
try:
if not con.execute("SELECT 1 FROM join_leads WHERE id=?", (lead_id,)).fetchone():
return None
cur = current_claim(con, lead_id)
if cur:
if cur["person_id"] == person_id:
raise ClaimRejected(409, "you already have this lead")
raise ClaimRejected(409, "%s has this lead; release it first" % cur["email"], {"claimed_by": cur["email"]})
con.execute("INSERT INTO lead_claims (id, lead_id, person_id, email, claimed_at) VALUES (?,?,?,?,?)",
(str(uuid.uuid4()), lead_id, person_id, email, _now()))
con.commit()
return current_claim(con, lead_id)
finally:
con.close()
def release_lead(lead_id, person_id, email, can_release_any=False):
"""Let a lead go. The holder may; so may someone with people:manage
(an owner reassigning), which is what can_release_any means."""
con = connect()
try:
if not con.execute("SELECT 1 FROM join_leads WHERE id=?", (lead_id,)).fetchone():
return None
cur = current_claim(con, lead_id)
if not cur:
raise ClaimRejected(409, "nobody has this lead")
if cur["person_id"] != person_id and not can_release_any:
raise ClaimRejected(403, "%s has this lead; only they or an owner can release it" % cur["email"])
con.execute("UPDATE lead_claims SET released_at=?, released_by=? WHERE id=?", (_now(), email, cur["id"]))
con.commit()
return cur
finally:
con.close()
def unclaimed_count():
con = connect()
try:
return con.execute("SELECT count(*) FROM join_leads l WHERE NOT EXISTS"
" (SELECT 1 FROM lead_claims c WHERE c.lead_id=l.id AND c.released_at IS NULL)").fetchone()[0]
finally:
con.close()
# ----------------------------------------------------------------------------
class NearbyRejected(Rejected):
pass
# Everything a caller may set. id and updated_at are the store's own.
NEARBY_FIELDS = ("unit_type", "unit_number", "serves", "chartered_org", "street",
"town", "area", "meets", "notes", "link_url", "contact",
"sort_order", "active", "source", "verified_at")
def _clean_nearby(fields, creating):
"""Validate and normalise a payload. Unknown keys are rejected rather than
dropped - a silently ignored typo ("unit_typo": "pack") would read as a
successful save that changed nothing."""
unknown = sorted(set(fields) - set(NEARBY_FIELDS))
if unknown:
raise NearbyRejected(422, "unknown fields: %s. Editable fields are %s"
% (", ".join(unknown), ", ".join(NEARBY_FIELDS)))
out = {}
for k, v in fields.items():
if isinstance(v, str):
v = v.strip() or None
out[k] = v
if creating or "unit_type" in out:
if out.get("unit_type") not in NEARBY_UNIT_TYPES:
raise NearbyRejected(422, "unit_type must be one of %s" % (NEARBY_UNIT_TYPES,))
if creating or "unit_number" in out:
if not out.get("unit_number"):
raise NearbyRejected(422, "unit_number is required")
out["unit_number"] = str(out["unit_number"])
if out.get("serves") is not None and out["serves"] not in NEARBY_SERVES:
raise NearbyRejected(422, "serves must be one of %s, or null" % (NEARBY_SERVES,))
# Both of these land inside href="..." attributes on the public page, so a
# quote or bracket is an attribute breakout, not a formatting nit. Same
# character set identity._setting_https_url blocks, for the same reason.
if out.get("link_url") is not None:
v = str(out["link_url"])
if not v.startswith("https://") or any(c in v for c in " \"'<>"):
raise NearbyRejected(422, "link_url must be a plain https:// URL")
if out.get("contact") is not None:
v = str(out["contact"])
if "@" not in v or any(c in v for c in " \"'<>"):
raise NearbyRejected(422, "contact is rendered as a mailto: link and must be an email address")
if "sort_order" in out and out["sort_order"] is not None:
try:
out["sort_order"] = int(out["sort_order"])
except (TypeError, ValueError):
raise NearbyRejected(422, "sort_order must be an integer")
if "active" in out:
if out["active"] not in (0, 1, True, False):
raise NearbyRejected(422, "active must be 0 or 1")
out["active"] = int(out["active"])
if "verified_at" in out and out["verified_at"] is not None:
try:
datetime.date.fromisoformat(str(out["verified_at"]))
except ValueError:
raise NearbyRejected(422, "verified_at must be a plain YYYY-MM-DD date")
out["verified_at"] = str(out["verified_at"])
return out
def _today():
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
def list_nearby(include_inactive=False):
con = connect()
try:
sql = "SELECT * FROM nearby_units"
if not include_inactive:
sql += " WHERE active = 1"
sql += " ORDER BY sort_order, unit_number"
return [dict(r) for r in con.execute(sql).fetchall()]
finally:
con.close()
def get_nearby(nid):
con = connect()
try:
row = con.execute("SELECT * FROM nearby_units WHERE id=?", (nid,)).fetchone()
return dict(row) if row else None
finally:
con.close()
def create_nearby(fields):
out = _clean_nearby(fields or {}, creating=True)
if not out.get("verified_at"):
out["verified_at"] = _today()
out.setdefault("active", 1)
out.setdefault("sort_order", 100)
nid = str(uuid.uuid4())
cols = ["id"] + list(out) + ["updated_at"]
vals = [nid] + [out[k] for k in out] + [_now()]
con = connect()
try:
con.execute("INSERT INTO nearby_units (%s) VALUES (%s)"
% (", ".join(cols), ", ".join("?" * len(cols))), vals)
con.commit()
finally:
con.close()
return get_nearby(nid)
def update_nearby(nid, fields):
if not fields:
raise NearbyRejected(422, "nothing to update")
out = _clean_nearby(fields, creating=False)
if not out.get("verified_at"):
out["verified_at"] = _today()
out["updated_at"] = _now()
con = connect()
try:
cur = con.execute("UPDATE nearby_units SET %s WHERE id=?"
% ", ".join("%s=?" % k for k in out),
list(out.values()) + [nid])
con.commit()
if cur.rowcount == 0:
return None
finally:
con.close()
return get_nearby(nid)
def deactivate_nearby(nid):
"""Take a unit off the page. Sets active=0; the row and its history stay."""
con = connect()
try:
cur = con.execute(
"UPDATE nearby_units SET active=0, updated_at=? WHERE id=? AND active=1",
(_now(), nid))
con.commit()
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()