# 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 .. import relational 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 # "Upcoming" is a FORWARD view: when the caller asks for upcoming without # pinning an as_of, floor it at *today*, not the most-recent data date. # Flooring at today is the fix for "upcoming shows the latest PAST day"; # a None as_of would fall back to the DB max date and re-introduce it. upcoming = _truthy(q.get("upcoming")) as_of = q.get("as_of") if upcoming and not as_of: as_of = _today_iso() events = list_events( db_path=req.svc.db_path, upcoming=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=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) # The bill queue is a backward daily-file snapshot; the chamber "upcoming # events" calendar (scheduled_events, schema v2) is the forward-looking # signal. Surface "next legislative day" + the next-7-days events so a bill # like AB-1099 shows what is scheduled next, not just the past. A None # as_of (the "All"/history view) still answers "next" from today onward. floor = as_of if as_of else _today_iso() next_day = _next_legislative_day(req.svc.db_path, floor, q.get("chamber")) scheduled = _scheduled_events(req.svc.db_path, floor, q.get("chamber")) body = { "session": session, "as_of": as_of or floor, "chamber_availability": chamber_avail, "next_legislative_day": next_day, "scheduled_events": scheduled, "items": items, "groups": groups, } return 200, body def _today_iso() -> str: import datetime return datetime.date.today().isoformat() def _next_legislative_day(db_path, as_of, chamber=None): """Earliest scheduled chamber event on/after as_of (DESIGN section 6.1). Degrades gracefully: when the scheduled_events table is absent (pre-v2 DB) or empty for the window (a recess), returns ``{"date": None, "recess": True}`` so the UI can show "next legislative day unknown". Never raises. """ try: return relational.next_legislative_day(db_path, as_of=as_of, chamber=chamber) except Exception: # noqa: BLE001 - forward source must not break /bills return {"date": None, "recess": True} def _scheduled_events(db_path, as_of, chamber=None, window_days=7): """Near-term scheduled chamber events (the "next 7 days" band). Returns events from as_of through as_of+window_days, de-duplicated by (date, kind, title). Degrades to [] when the table is absent / empty (a recess). Never raises. """ try: import datetime start = _today_iso() if not as_of else str(as_of) d0 = datetime.date.fromisoformat(start[:10]) end = (d0 + datetime.timedelta(days=window_days)).isoformat() evs = relational.load_scheduled_events(db_path, as_of=start, chamber=chamber) seen = set() out = [] for e in evs: if e.get("date") and e["date"] > end: continue k = (e.get("date"), e.get("kind"), e.get("title")) if k in seen: continue seen.add(k) out.append(e) out.sort(key=lambda e: (str(e.get("date") or "z"), str(e.get("kind") or ""), str(e.get("title") or ""))) return out except Exception: # noqa: BLE001 return [] 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)