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
225 lines
8.6 KiB
Python
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"),
|
|
}
|