Files
PocketVeto/backend/app/services/congress_api.py
Jack Levy 91473e6464 feat: roll-call votes + granular alert filter fix (v0.9.5)
Roll-call votes:
- Migration 0017: bill_votes + member_vote_positions tables
- Fetch vote XML directly from House Clerk / Senate LIS URLs
  embedded in bill actions recordedVotes objects
- GET /api/bills/{id}/votes triggers background fetch on first view
- VotePanel on bill detail: yea/nay bar, result badge, followed
  member positions with Sen./Rep. title, party badge, and state

Alert filter fix:
- _should_dispatch returns True when alert_filters is None so users
  who haven't saved filters still receive all notifications

Authored-By: Jack Levy
2026-03-02 20:33:32 -05:00

225 lines
8.6 KiB
Python

"""
Congress.gov API client.
Rate limit: 5,000 requests/hour (enforced server-side by Congress.gov).
We track usage in Redis to stay well under the limit.
"""
import time
from datetime import datetime
from typing import Optional
import requests
from tenacity import retry, stop_after_attempt, wait_exponential
from app.config import settings
BASE_URL = "https://api.congress.gov/v3"
_BILL_TYPE_SLUG = {
"hr": "house-bill",
"s": "senate-bill",
"hjres": "house-joint-resolution",
"sjres": "senate-joint-resolution",
"hres": "house-resolution",
"sres": "senate-resolution",
"hconres": "house-concurrent-resolution",
"sconres": "senate-concurrent-resolution",
}
def _congress_ordinal(n: int) -> str:
if 11 <= n % 100 <= 13:
return f"{n}th"
suffixes = {1: "st", 2: "nd", 3: "rd"}
return f"{n}{suffixes.get(n % 10, 'th')}"
def build_bill_public_url(congress: int, bill_type: str, bill_number: int) -> str:
"""Return the public congress.gov page URL for a bill (not the API endpoint)."""
slug = _BILL_TYPE_SLUG.get(bill_type.lower(), bill_type.lower())
return f"https://www.congress.gov/bill/{_congress_ordinal(congress)}-congress/{slug}/{bill_number}"
def _get_current_congress() -> int:
"""Calculate the current Congress number. 119th started Jan 3, 2025."""
year = datetime.utcnow().year
# Congress changes on odd years (Jan 3)
if datetime.utcnow().month == 1 and datetime.utcnow().day < 3:
year -= 1
return 118 + ((year - 2023) // 2 + (1 if year % 2 == 1 else 0))
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
def _get(endpoint: str, params: dict) -> dict:
params["api_key"] = settings.DATA_GOV_API_KEY
params["format"] = "json"
response = requests.get(f"{BASE_URL}{endpoint}", params=params, timeout=30)
response.raise_for_status()
return response.json()
def get_current_congress() -> int:
return _get_current_congress()
def build_bill_id(congress: int, bill_type: str, bill_number: int) -> str:
return f"{congress}-{bill_type.lower()}-{bill_number}"
def get_bills(
congress: int,
offset: int = 0,
limit: int = 250,
from_date_time: Optional[str] = None,
) -> dict:
params: dict = {"offset": offset, "limit": limit, "sort": "updateDate+desc"}
if from_date_time:
params["fromDateTime"] = from_date_time
return _get(f"/bill/{congress}", params)
def get_bill_detail(congress: int, bill_type: str, bill_number: int) -> dict:
return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}", {})
def get_bill_actions(congress: int, bill_type: str, bill_number: int, offset: int = 0) -> dict:
return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}/actions", {"offset": offset, "limit": 250})
def get_bill_text_versions(congress: int, bill_type: str, bill_number: int) -> dict:
return _get(f"/bill/{congress}/{bill_type.lower()}/{bill_number}/text", {})
def get_vote_detail(congress: int, chamber: str, session: int, roll_number: int) -> dict:
chamber_slug = "house" if chamber.lower() == "house" else "senate"
return _get(f"/vote/{congress}/{chamber_slug}/{session}/{roll_number}", {})
def get_members(offset: int = 0, limit: int = 250, current_member: bool = True) -> dict:
params: dict = {"offset": offset, "limit": limit}
if current_member:
params["currentMember"] = "true"
return _get("/member", params)
def get_member_detail(bioguide_id: str) -> dict:
return _get(f"/member/{bioguide_id}", {})
def get_committees(offset: int = 0, limit: int = 250) -> dict:
return _get("/committee", {"offset": offset, "limit": limit})
def parse_bill_from_api(data: dict, congress: int) -> dict:
"""Normalize raw API bill data into our model fields."""
bill_type = data.get("type", "").lower()
bill_number = data.get("number", 0)
latest_action = data.get("latestAction") or {}
return {
"bill_id": build_bill_id(congress, bill_type, bill_number),
"congress_number": congress,
"bill_type": bill_type,
"bill_number": bill_number,
"title": data.get("title"),
"short_title": data.get("shortTitle"),
"introduced_date": data.get("introducedDate"),
"latest_action_date": latest_action.get("actionDate"),
"latest_action_text": latest_action.get("text"),
"status": latest_action.get("text", "")[:100] if latest_action.get("text") else None,
"chamber": "House" if bill_type.startswith("h") else "Senate",
"congress_url": build_bill_public_url(congress, bill_type, bill_number),
}
_STATE_NAME_TO_CODE: dict[str, str] = {
"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR",
"California": "CA", "Colorado": "CO", "Connecticut": "CT", "Delaware": "DE",
"Florida": "FL", "Georgia": "GA", "Hawaii": "HI", "Idaho": "ID",
"Illinois": "IL", "Indiana": "IN", "Iowa": "IA", "Kansas": "KS",
"Kentucky": "KY", "Louisiana": "LA", "Maine": "ME", "Maryland": "MD",
"Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV",
"New Hampshire": "NH", "New Jersey": "NJ", "New Mexico": "NM", "New York": "NY",
"North Carolina": "NC", "North Dakota": "ND", "Ohio": "OH", "Oklahoma": "OK",
"Oregon": "OR", "Pennsylvania": "PA", "Rhode Island": "RI", "South Carolina": "SC",
"South Dakota": "SD", "Tennessee": "TN", "Texas": "TX", "Utah": "UT",
"Vermont": "VT", "Virginia": "VA", "Washington": "WA", "West Virginia": "WV",
"Wisconsin": "WI", "Wyoming": "WY",
"American Samoa": "AS", "Guam": "GU", "Northern Mariana Islands": "MP",
"Puerto Rico": "PR", "Virgin Islands": "VI", "District of Columbia": "DC",
}
def _normalize_state(state: str | None) -> str | None:
if not state:
return None
s = state.strip()
if len(s) == 2:
return s.upper()
return _STATE_NAME_TO_CODE.get(s, s)
def parse_member_from_api(data: dict) -> dict:
"""Normalize raw API member list data into our model fields."""
terms = data.get("terms", {}).get("item", [])
current_term = terms[-1] if terms else {}
return {
"bioguide_id": data.get("bioguideId"),
"name": data.get("name", ""),
"first_name": data.get("firstName"),
"last_name": data.get("lastName"),
"party": data.get("partyName") or None,
"state": _normalize_state(data.get("state")),
"chamber": current_term.get("chamber"),
"district": str(data.get("district")) if data.get("district") else None,
"photo_url": data.get("depiction", {}).get("imageUrl"),
"official_url": data.get("officialWebsiteUrl"),
}
def parse_member_detail_from_api(data: dict) -> dict:
"""Normalize Congress.gov member detail response into enrichment fields."""
member = data.get("member", data)
addr = member.get("addressInformation") or {}
terms_raw = member.get("terms", [])
if isinstance(terms_raw, dict):
terms_raw = terms_raw.get("item", [])
leadership_raw = member.get("leadership") or []
if isinstance(leadership_raw, dict):
leadership_raw = leadership_raw.get("item", [])
first = member.get("firstName", "")
last = member.get("lastName", "")
bioguide_id = member.get("bioguideId", "")
slug = f"{first}-{last}".lower().replace(" ", "-").replace("'", "")
return {
"birth_year": str(member["birthYear"]) if member.get("birthYear") else None,
"address": addr.get("officeAddress"),
"phone": addr.get("phoneNumber"),
"official_url": member.get("officialWebsiteUrl"),
"photo_url": (member.get("depiction") or {}).get("imageUrl"),
"congress_url": f"https://www.congress.gov/member/{slug}/{bioguide_id}" if bioguide_id else None,
"terms_json": [
{
"congress": t.get("congress"),
"chamber": t.get("chamber"),
"partyName": t.get("partyName"),
"stateCode": t.get("stateCode"),
"stateName": t.get("stateName"),
"startYear": t.get("startYear"),
"endYear": t.get("endYear"),
"district": t.get("district"),
}
for t in terms_raw
],
"leadership_json": [
{
"type": l.get("type"),
"congress": l.get("congress"),
"current": l.get("current"),
}
for l in leadership_raw
],
"sponsored_count": (member.get("sponsoredLegislation") or {}).get("count"),
"cosponsored_count": (member.get("cosponsoredLegislation") or {}).get("count"),
}