448 lines
14 KiB
Python
448 lines
14 KiB
Python
# noqa: E501
|
|
"""API handlers producing the frozen API_CONTRACT.md shapes (spec §5).
|
|
|
|
Handler(req) -> (status, body). Driven by the stdlib HTTP server and by unit
|
|
tests with no sockets. A Services bundle carries collaborators so handlers stay
|
|
pure of instantiation. Errors per §6; empty results are 200 with empty lists.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from . import data as _data
|
|
from . import ranking
|
|
from . import resolve as _resolve
|
|
from . import bill_status
|
|
from .data import (load_tag_cloud, list_events,
|
|
load_legislators, committee_roster_for)
|
|
from .ranking import hearing_sort, full_queue, personal_band
|
|
from .context import from_prefs
|
|
from .prefs import PrefsStore, LocalAuth
|
|
|
|
# error model (§6)
|
|
|
|
def api_error(code, message, status=400, field=None):
|
|
err = {"error": {"code": code, "message": message}}
|
|
if field:
|
|
err["error"]["field"] = field
|
|
return status, err
|
|
|
|
|
|
# service bundle
|
|
|
|
class Services:
|
|
|
|
def __init__(self, db_path, prefs, auth):
|
|
self.db_path = db_path
|
|
self.prefs = prefs
|
|
self.auth = auth
|
|
|
|
@classmethod
|
|
def default(cls, db_path=None,
|
|
prefs_path="out/prefs.json",
|
|
auth_path="out/auth.json"):
|
|
from .. import relational
|
|
db = db_path or relational.DEFAULT_DB
|
|
return cls(db_path=db,
|
|
prefs=PrefsStore(prefs_path),
|
|
auth=LocalAuth(auth_path))
|
|
|
|
def prefs_for_user(self, user_id):
|
|
if not user_id:
|
|
return {}
|
|
return self.prefs.get(user_id)
|
|
|
|
def user_from_token(self, token):
|
|
if not token:
|
|
return None
|
|
return self.auth.verify_token(token)
|
|
|
|
|
|
# request
|
|
|
|
class Request:
|
|
|
|
def __init__(self, method, path, query=None, body=None,
|
|
token=None, services=None, user_id=None):
|
|
self.method = str(method).upper()
|
|
self.path = str(path).lstrip("/")
|
|
self.query = query or {}
|
|
self.body = body or {}
|
|
self.token = token
|
|
self.services = services
|
|
self.user_id = user_id
|
|
|
|
@property
|
|
def svc(self):
|
|
if self.services is None:
|
|
raise RuntimeError("Request built without a Services bundle.")
|
|
return self.services
|
|
|
|
def require_auth(self):
|
|
uid = self.user_id or self.svc.user_from_token(self.token)
|
|
if not uid:
|
|
return None, api_error("not_authenticated",
|
|
"A bearer token is required for this endpoint.",
|
|
401)
|
|
self.user_id = uid
|
|
return uid, None
|
|
|
|
|
|
# query helpers
|
|
|
|
def q_list(key, query):
|
|
v = query.get(key)
|
|
if v is None:
|
|
return []
|
|
if isinstance(v, list):
|
|
return [str(x).strip() for x in v if str(x).strip()]
|
|
return [s.strip() for s in str(v).split(",") if s.strip()]
|
|
|
|
|
|
def _now_iso():
|
|
import datetime
|
|
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
|
"%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _truthy(v):
|
|
if v is None:
|
|
return False
|
|
if isinstance(v, str):
|
|
return v.strip().lower() in ("1", "true", "yes", "on")
|
|
return bool(v)
|
|
|
|
|
|
# GET /api/v1/bills (§1/§2)
|
|
|
|
def handle_bills(req):
|
|
q = req.query
|
|
events = list_events(
|
|
db_path=req.svc.db_path,
|
|
upcoming=_truthy(q.get("upcoming")),
|
|
from_date=q.get("from") or q.get("date_from"),
|
|
to_date=q.get("to") or q.get("date_to"),
|
|
chamber=q.get("chamber"),
|
|
tags=q_list("tags", q),
|
|
q=q.get("q"),
|
|
author=q.get("author"),
|
|
as_of=q.get("as_of"),
|
|
)
|
|
if _truthy(q.get("personal")):
|
|
uid, err = req.require_auth()
|
|
if err:
|
|
return err
|
|
prefs = req.svc.prefs_for_user(uid)
|
|
roster = committee_roster_for(
|
|
req.svc.db_path,
|
|
set(prefs.get("watched_legislators") or []))
|
|
ctx = from_prefs(prefs, committee_roster=roster)
|
|
for ev in events:
|
|
ev["relevance"] = ctx.score(ev)
|
|
if _truthy(q.get("full")):
|
|
events = full_queue(events)
|
|
else:
|
|
events = personal_band(
|
|
events, int(q.get("band_n") or ranking.PERSONAL_BAND_N))
|
|
session = _detect_session(events, req.svc.db_path)
|
|
items = [_client_bill(e) for e in events]
|
|
groups = {}
|
|
for e in items:
|
|
key = e.get("date") or "TBD"
|
|
groups[key] = groups.get(key, 0) + 1
|
|
chamber_avail = _chamber_availability(items, req.svc.db_path)
|
|
body = {
|
|
"session": session,
|
|
"as_of": q.get("as_of") or _now_iso(),
|
|
"chamber_availability": chamber_avail,
|
|
"items": items,
|
|
"groups": groups,
|
|
}
|
|
return 200, body
|
|
|
|
|
|
def _client_bill(ev):
|
|
out = {k: ev.get(k) for k in _data._EVENT_FIELDS}
|
|
out["hearing_sort"] = hearing_sort(ev.get("hearing_time"))
|
|
out["tags"] = ev.get("tags")
|
|
# API_CONTRACT §5 "upcoming" item 2: label the bill's current state (a
|
|
# past/terminal floor action shouldn't read as a vague "pending"), and
|
|
# flag possibly_closed when the source can't disambiguate.
|
|
st = bill_status.derive_status(ev.get("description"),
|
|
ev.get("bill_number"))
|
|
out["status"] = st["status"]
|
|
out["reason"] = st["reason"]
|
|
out["possibly_closed"] = st["possibly_closed"]
|
|
if "relevance" in ev:
|
|
out["relevance"] = ev["relevance"]
|
|
return out
|
|
|
|
|
|
def _detect_session(events, db_path):
|
|
for e in events:
|
|
if e.get("session"):
|
|
return str(e["session"])
|
|
return "2025-2026"
|
|
|
|
|
|
def _chamber_availability(items, db_path):
|
|
have = {str(e.get("chamber_origin") or "ASSEMBLY").upper() for e in items}
|
|
if not have:
|
|
for e in list_events(db_path=db_path):
|
|
have.add(str(e.get("chamber_origin") or "ASSEMBLY").upper())
|
|
out = {}
|
|
for ch in ("ASSEMBLY", "SENATE"):
|
|
present = ch in have or any(h == ch for h in have)
|
|
out[ch] = "available" if present else "pending"
|
|
return out
|
|
|
|
|
|
# GET /api/v1/tags (§4.1)
|
|
|
|
def handle_tags(req):
|
|
top = req.query.get("top")
|
|
top = int(top) if top and str(top).isdigit() else None
|
|
return 200, load_tag_cloud(req.svc.db_path, top=top)
|
|
|
|
|
|
# GET /api/v1/legislators (§3.2 / §4.2)
|
|
|
|
def handle_legislators(req):
|
|
q = req.query.get("q")
|
|
if isinstance(q, list):
|
|
q = q[0]
|
|
return 200, {"items": load_legislators(req.svc.db_path, q=q)}
|
|
|
|
|
|
# GET /api/v1/districts (§4.2)
|
|
|
|
def handle_districts(req):
|
|
seen = {}
|
|
for entry in _resolve.DEMO_ZIPS.values():
|
|
for d in entry:
|
|
seen[d["district_id"]] = d
|
|
items = [
|
|
{"district_id": d["district_id"], "type": d["type"],
|
|
"number": d.get("number"),
|
|
"assembly_member": d.get("assembly_member"),
|
|
"senate_member": d.get("senate_member")}
|
|
for d in seen.values()
|
|
]
|
|
return 200, {"items": sorted(items, key=lambda x: x["district_id"])}
|
|
|
|
|
|
# POST /api/v1/districts/resolve (§3.1 — proposes, never persists)
|
|
|
|
def handle_resolve(req):
|
|
b = req.body or {}
|
|
zip_ = b.get("zip")
|
|
address = b.get("address")
|
|
district = b.get("district")
|
|
if not zip_ and not address and not district:
|
|
return api_error("invalid_zip",
|
|
"Provide one of: zip, address, or district.",
|
|
400, field="zip")
|
|
result = _resolve.resolve(zip=zip_, address=address, district=district)
|
|
conf = result["confidence"]
|
|
if conf == "out-of-state":
|
|
return api_error("out_of_state",
|
|
"That input is not in California.",
|
|
400, field="zip")
|
|
if conf == "zero":
|
|
return api_error("district_not_found",
|
|
"Could not match the input to a California district.",
|
|
404, field="zip" if zip_ else "address")
|
|
if conf == "ambiguous":
|
|
out = dict(result)
|
|
out["note"] = ("Multiple districts match; confirm one before "
|
|
"watching (never auto-picked).")
|
|
return 200, out
|
|
return 200, result
|
|
|
|
|
|
# /api/v1/prefs (§3.3)
|
|
|
|
def handle_get_prefs(req):
|
|
uid, err = req.require_auth()
|
|
if err:
|
|
return err
|
|
prefs = req.svc.prefs_for_user(uid)
|
|
if not prefs:
|
|
return api_error("prefs_not_set",
|
|
"No preferences stored; set a ZIP / watch / topics.",
|
|
404)
|
|
prefs = dict(prefs)
|
|
prefs["user_id"] = uid
|
|
prefs["updated_at"] = prefs.get("updated_at") or _now_iso()
|
|
return 200, prefs
|
|
|
|
|
|
def handle_put_prefs(req):
|
|
uid, err = req.require_auth()
|
|
if err:
|
|
return err
|
|
b = dict(req.body or {})
|
|
allowed = {"zip", "address", "home_chamber", "districts",
|
|
"watched_legislators", "opted_in_tags"}
|
|
valid_tags = _valid_tag_set(req)
|
|
known_legs = {x["legislator_id"] for x in
|
|
load_legislators(req.svc.db_path)}
|
|
for k, v in b.items():
|
|
if k not in allowed:
|
|
return api_error("invalid_config",
|
|
"Unknown prefs field: %s" % k, 400, field=k)
|
|
if k == "opted_in_tags":
|
|
for t in v:
|
|
if str(t).lower() not in valid_tags:
|
|
return api_error("tag_unknown",
|
|
"Unknown topic: %s" % t, 400,
|
|
field="opted_in_tags")
|
|
if k == "watched_legislators":
|
|
for w in v:
|
|
if known_legs and w not in known_legs:
|
|
return api_error("legislator_unknown",
|
|
"Unknown legislator: %s" % w, 400,
|
|
field="watched_legislators")
|
|
clean = {k: b[k] for k in b if k in allowed}
|
|
clean["updated_at"] = _now_iso()
|
|
updated = req.svc.prefs.put(uid, clean)
|
|
updated["user_id"] = uid
|
|
return 200, updated
|
|
|
|
|
|
def _valid_tag_set(req):
|
|
tags = load_tag_cloud(req.svc.db_path)["tags"]
|
|
return {t["tag_id"].split(":", 1)[-1].lower() for t in tags}
|
|
|
|
|
|
# watch / topic toggles (§3.4 / §3.5 — idempotent)
|
|
|
|
def handle_add_watch(req):
|
|
uid, err = req.require_auth()
|
|
if err:
|
|
return err
|
|
b = req.body or {}
|
|
lid = b.get("legislator_id") or b.get("id")
|
|
if not lid:
|
|
return api_error("invalid_legislator",
|
|
"legislator_id is required.", 400,
|
|
field="legislator_id")
|
|
if not _known_legislator(req, lid):
|
|
return api_error("legislator_unknown",
|
|
"Unknown legislator: %s" % lid, 400, field="id")
|
|
prefs = req.svc.prefs.add_watch(uid, lid)
|
|
prefs["user_id"] = uid
|
|
return 201, prefs
|
|
|
|
|
|
def handle_remove_watch(req, legislator_id):
|
|
uid, err = req.require_auth()
|
|
if err:
|
|
return err
|
|
prefs = req.svc.prefs.remove_watch(uid, legislator_id)
|
|
prefs["user_id"] = uid
|
|
return 200, prefs
|
|
|
|
|
|
def handle_add_topic(req):
|
|
uid, err = req.require_auth()
|
|
if err:
|
|
return err
|
|
b = req.body or {}
|
|
tag = b.get("tag") or b.get("tag_id") or b.get("id")
|
|
if not tag:
|
|
return api_error("invalid_tag", "tag is required.", 400, field="tag")
|
|
if str(tag).lower() not in _valid_tag_set(req):
|
|
return api_error("tag_unknown",
|
|
"Unknown topic: %s" % tag, 400, field="tag")
|
|
prefs = req.svc.prefs.add_topic(uid, str(tag).lower())
|
|
prefs["user_id"] = uid
|
|
return 201, prefs
|
|
|
|
|
|
def handle_remove_topic(req, tag):
|
|
uid, err = req.require_auth()
|
|
if err:
|
|
return err
|
|
prefs = req.svc.prefs.remove_topic(uid, tag)
|
|
prefs["user_id"] = uid
|
|
return 200, prefs
|
|
|
|
|
|
def _known_legislator(req, lid):
|
|
return any(x.get("legislator_id") == lid
|
|
for x in load_legislators(req.svc.db_path))
|
|
|
|
|
|
# Minimal local-account auth (D1a default — dev placeholder)
|
|
|
|
def handle_register(req):
|
|
email = (req.body or {}).get("email", "").strip().lower()
|
|
if not email or "@" not in email:
|
|
return api_error("invalid_email",
|
|
"A valid email is required.", 400, field="email")
|
|
rec = req.svc.auth.register(email)
|
|
req.svc.prefs.put(rec["user_id"], {"email": rec["email"]})
|
|
return 201, rec
|
|
|
|
|
|
def handle_whoami(req):
|
|
uid = req.user_id or req.svc.user_from_token(req.token)
|
|
if not uid:
|
|
return {"user_id": None}
|
|
return {"user_id": uid, "prefs": req.svc.prefs_for_user(uid)}
|
|
|
|
|
|
# router
|
|
|
|
def _strip(req):
|
|
p = req.path
|
|
base = "api/v1/"
|
|
return p[len(base):] if p.startswith(base) else p
|
|
|
|
|
|
def run(req):
|
|
m = req.method
|
|
p2 = _strip(req)
|
|
if m == "POST" and p2 == "auth/register":
|
|
return handle_register(req)
|
|
if m == "GET" and p2 == "whoami":
|
|
return 200, handle_whoami(req)
|
|
if m == "GET" and p2 == "bills":
|
|
return handle_bills(req)
|
|
if m == "GET" and p2 == "tags":
|
|
return handle_tags(req)
|
|
if m == "GET" and p2 == "legislators":
|
|
return handle_legislators(req)
|
|
if m == "GET" and p2 == "districts":
|
|
return handle_districts(req)
|
|
if m == "POST" and p2 == "districts/resolve":
|
|
return handle_resolve(req)
|
|
if m == "GET" and p2 == "prefs":
|
|
return handle_get_prefs(req)
|
|
if m == "PUT" and p2 == "prefs":
|
|
return handle_put_prefs(req)
|
|
if m == "POST" and p2 == "prefs/watch":
|
|
return handle_add_watch(req)
|
|
if m == "DELETE" and p2.startswith("prefs/watch/"):
|
|
return handle_remove_watch(req, p2.split("/", 3)[2])
|
|
if m == "POST" and p2 == "prefs/topics":
|
|
return handle_add_topic(req)
|
|
if m == "DELETE" and p2.startswith("prefs/topics/"):
|
|
return handle_remove_topic(req, p2.split("/", 3)[2])
|
|
if m == "GET" and p2 == "":
|
|
return 200, {"endpoints": [
|
|
"GET /api/v1/bills?upcoming=1&personal=1",
|
|
"GET /api/v1/tags",
|
|
"GET /api/v1/legislators?q=",
|
|
"GET /api/v1/districts",
|
|
"POST /api/v1/districts/resolve",
|
|
"GET/PUT /api/v1/prefs",
|
|
"POST/DELETE /api/v1/prefs/watch[/:id]",
|
|
"POST/DELETE /api/v1/prefs/topics[/:id]",
|
|
"POST /api/v1/auth/register",
|
|
"GET /api/v1/whoami",
|
|
]}
|
|
return api_error("not_found",
|
|
"No such endpoint: %s %s" % (m, req.path), 404)
|