Notifications: - New /notifications page accessible to all users (ntfy + RSS config) - ntfy now supports no-auth, Bearer token, and HTTP Basic auth (for ACL-protected self-hosted servers) - RSS enabled/disabled independently of ntfy; token auto-generated on first GET - Notification settings removed from admin-only Settings page; replaced with link card - Sidebar adds Notifications nav link for all users - notification_dispatcher.py: fan-out now marks RSS events dispatched independently Action history: - Migration 0012: deduplicates existing bill_actions rows and adds UNIQUE(bill_id, action_date, action_text) - congress_poller.py: replaces existence-check inserts with ON CONFLICT DO NOTHING (race-condition safe) - Added backfill_all_bill_actions task (no date filter) + admin endpoint POST /backfill-all-actions Authored-By: Jack Levy
192 lines
7.0 KiB
Python
192 lines
7.0 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_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),
|
|
}
|
|
|
|
|
|
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": data.get("state"),
|
|
"chamber": current_term.get("chamber"),
|
|
"district": str(current_term.get("district")) if current_term.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"),
|
|
}
|