Who picked a lead up and when, so nothing sits waiting unnoticed. No outcomes, no end states, nothing on join_leads changes: the lead stays the record of what the family said and the claim is only who has it. One row per claim in lead_claims; the current claim is the latest unreleased. Taking over is release then claim, never a silent overwrite: 409 names the holder. The holder or an owner may release. Every claim and release lands in the action log; the summary carries an unclaimed count. tests/smoke_admin.py 112 -> 122.
804 lines
30 KiB
Python
804 lines
30 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);
|
|
|
|
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()
|