Facebook does not call back and the reconciliation was never built, so a row that stayed scheduled forever was a lie of omission. Decided by Mike: once the time passes with nothing else reported, the post is published. status keeps what the publisher last said; state is what a reader acts on, and the open-only list and cancel use state. Cancel on a post whose time has passed says so.
1250 lines
49 KiB
Python
1250 lines
49 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 hashlib
|
|
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);
|
|
-- 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 (
|
|
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)
|
|
);
|
|
|
|
-- Facebook posts (ops/open-items.md section 12, built 2026-09-04). scout-publisher
|
|
-- reports every post it drafts, schedules, holds or cancels by POSTing here; it
|
|
-- never opens this file. id is <unit>/<queue-file-stem>, stable across body
|
|
-- edits, so a redrafted post is one row. The image is copied, content-addressed
|
|
-- at /data/post-images/<sha256>.<ext>, hash verified on arrival; image_ref is
|
|
-- provenance only (the NAS path it came from). cancel_url is the publisher's
|
|
-- own signed per-post link, so the panel gets Cancel with no new secret.
|
|
CREATE TABLE IF NOT EXISTS fb_posts (
|
|
id TEXT PRIMARY KEY,
|
|
unit TEXT NOT NULL,
|
|
page_id TEXT,
|
|
fb_post_id TEXT,
|
|
status TEXT NOT NULL,
|
|
message TEXT,
|
|
link TEXT,
|
|
scheduled_for TEXT,
|
|
queue_file TEXT,
|
|
cancel_url TEXT,
|
|
image_sha256 TEXT,
|
|
image_ref TEXT,
|
|
image_mime TEXT,
|
|
image_bytes INTEGER,
|
|
image_width INTEGER,
|
|
image_height INTEGER,
|
|
reported_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
cancelled_at TEXT,
|
|
cancelled_by TEXT
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_fb_posts_fbid ON fb_posts(fb_post_id) WHERE fb_post_id IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_fb_posts_sched ON fb_posts(status, scheduled_for);
|
|
|
|
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()
|
|
|
|
|
|
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")]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Facebook posts. See the schema comment.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FbRejected(Rejected):
|
|
pass
|
|
|
|
|
|
FB_STATUSES = ("drafted", "scheduled", "handed_off", "cancelled", "published", "failed")
|
|
IMAGE_DIR = DB_PATH.parent / "post-images"
|
|
IMAGE_MIMES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
|
|
|
|
|
|
def upsert_fb_post(rec, image=None):
|
|
"""Record what the publisher reports. `image` is (bytes, mime, claimed_sha256)
|
|
or None; the hash is recomputed here and a mismatch is refused, so the row
|
|
never claims a version of the image nobody sent."""
|
|
pid = (rec.get("id") or "").strip()
|
|
if not pid or "/" not in pid:
|
|
raise FbRejected(422, "id must be <unit>/<queue-file-stem>")
|
|
status = (rec.get("status") or "").strip()
|
|
if status not in FB_STATUSES:
|
|
raise FbRejected(422, "status must be one of %s" % (FB_STATUSES,))
|
|
unit = (rec.get("unit") or pid.split("/", 1)[0]).strip()
|
|
img = {}
|
|
if image is not None:
|
|
data, mime, claimed = image
|
|
if mime not in IMAGE_MIMES:
|
|
raise FbRejected(422, "image mime must be one of %s" % sorted(IMAGE_MIMES))
|
|
if len(data) > 15 * 1024 * 1024:
|
|
raise FbRejected(413, "image over 15 MB")
|
|
actual = hashlib.sha256(data).hexdigest()
|
|
if claimed and claimed.lower() != actual:
|
|
raise FbRejected(422, "image sha256 mismatch: sent %s, is %s" % (claimed, actual))
|
|
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
dest = IMAGE_DIR / ("%s.%s" % (actual, IMAGE_MIMES[mime]))
|
|
if not dest.exists():
|
|
tmp = dest.with_suffix(".tmp")
|
|
tmp.write_bytes(data); tmp.replace(dest)
|
|
w, h = _image_size(data, mime)
|
|
img = {"image_sha256": actual, "image_mime": mime, "image_bytes": len(data), "image_width": w, "image_height": h}
|
|
con = connect()
|
|
try:
|
|
old = con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone()
|
|
fields = {"unit": unit, "page_id": rec.get("page_id"), "fb_post_id": (rec.get("fb_post_id") or None),
|
|
"status": status, "message": rec.get("message"), "link": rec.get("link"),
|
|
"scheduled_for": rec.get("scheduled_for"), "queue_file": rec.get("queue_file"),
|
|
"cancel_url": rec.get("cancel_url"), "image_ref": rec.get("image_ref"), "updated_at": _now()}
|
|
fields.update(img)
|
|
if status == "cancelled" and not (old and old["cancelled_at"]):
|
|
fields["cancelled_at"] = rec.get("cancelled_at") or _now()
|
|
fields["cancelled_by"] = rec.get("cancelled_by") or "publisher"
|
|
if old:
|
|
if fields.get("fb_post_id") is None:
|
|
fields.pop("fb_post_id")
|
|
sets = ", ".join("%s=?" % k for k in fields)
|
|
con.execute("UPDATE fb_posts SET %s WHERE id=?" % sets, (*fields.values(), pid))
|
|
else:
|
|
cols = ["id", "reported_at"] + list(fields)
|
|
con.execute("INSERT INTO fb_posts (%s) VALUES (%s)" % (", ".join(cols), ",".join("?" * len(cols))),
|
|
(pid, _now(), *fields.values()))
|
|
con.commit()
|
|
return dict(con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone())
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def _image_size(data, mime):
|
|
"""Width and height from the header, no image library. None if unsure."""
|
|
try:
|
|
if mime == "image/png" and data[:8] == b"\x89PNG\r\n\x1a\n":
|
|
return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
|
|
if mime == "image/jpeg":
|
|
i = 2
|
|
while i < len(data) - 9:
|
|
if data[i] != 0xFF:
|
|
i += 1; continue
|
|
marker = data[i + 1]
|
|
if marker in (0xC0, 0xC1, 0xC2):
|
|
return int.from_bytes(data[i + 7:i + 9], "big"), int.from_bytes(data[i + 5:i + 7], "big")
|
|
i += 2 + int.from_bytes(data[i + 2:i + 4], "big")
|
|
except Exception:
|
|
pass
|
|
return None, None
|
|
|
|
|
|
def fb_state(rec, now=None):
|
|
"""The effective state. A scheduled post whose publish time has passed is
|
|
published unless something said otherwise: Facebook does not call back,
|
|
and a row that stays 'scheduled' forever is a lie of omission. Decided
|
|
by Mike 2026-09-04. `status` stays what was last reported; `state` is
|
|
what a reader should act on."""
|
|
st = rec.get("status")
|
|
if st == "scheduled" and rec.get("scheduled_for") and rec["scheduled_for"] <= (now or _now()):
|
|
return "published"
|
|
return st
|
|
|
|
|
|
def list_fb_posts(limit=100, include_done=True):
|
|
con = connect()
|
|
try:
|
|
rows = [dict(r) for r in con.execute(
|
|
"SELECT * FROM fb_posts ORDER BY COALESCE(scheduled_for, updated_at) DESC LIMIT ?",
|
|
(max(1, min(int(limit), 500)),))]
|
|
finally:
|
|
con.close()
|
|
now = _now()
|
|
for r in rows:
|
|
r["state"] = fb_state(r, now)
|
|
if not include_done:
|
|
rows = [r for r in rows if r["state"] in ("drafted", "scheduled", "handed_off")]
|
|
return rows
|
|
|
|
|
|
def get_fb_post(pid):
|
|
con = connect()
|
|
try:
|
|
r = con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone()
|
|
finally:
|
|
con.close()
|
|
if not r:
|
|
return None
|
|
d = dict(r); d["state"] = fb_state(d)
|
|
return d
|
|
|
|
|
|
def mark_fb_cancelled(pid, by):
|
|
con = connect()
|
|
try:
|
|
con.execute("UPDATE fb_posts SET status='cancelled', cancelled_at=?, cancelled_by=?, updated_at=? WHERE id=?",
|
|
(_now(), by, _now(), pid))
|
|
con.commit()
|
|
return dict(con.execute("SELECT * FROM fb_posts WHERE id=?", (pid,)).fetchone())
|
|
finally:
|
|
con.close()
|
|
|
|
|
|
def image_path(rec):
|
|
if not rec or not rec.get("image_sha256"):
|
|
return None
|
|
p = IMAGE_DIR / ("%s.%s" % (rec["image_sha256"], IMAGE_MIMES.get(rec.get("image_mime"), "bin")))
|
|
return p if p.exists() else None
|