Files
cali_scraper/scraper/dashboard/api.py
T

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)