feat(members): add full member bio, contact info, and service history
Lazy-enriches member profiles on first view via Congress.gov detail API. Adds office address, phone, official website, congress.gov link, birth year, terms history, leadership roles, and sponsored/cosponsored counts. Includes DB migration 0007 for new member columns. Co-Authored-By: Jack Levy
This commit is contained in:
37
backend/alembic/versions/0007_add_member_bio_fields.py
Normal file
37
backend/alembic/versions/0007_add_member_bio_fields.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""add member bio and contact fields
|
||||
|
||||
Revision ID: 0007
|
||||
Revises: 0006
|
||||
Create Date: 2026-03-01
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0007"
|
||||
down_revision = "0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("members", sa.Column("congress_url", sa.String(), nullable=True))
|
||||
op.add_column("members", sa.Column("birth_year", sa.String(10), nullable=True))
|
||||
op.add_column("members", sa.Column("address", sa.String(), nullable=True))
|
||||
op.add_column("members", sa.Column("phone", sa.String(50), nullable=True))
|
||||
op.add_column("members", sa.Column("terms_json", sa.JSON(), nullable=True))
|
||||
op.add_column("members", sa.Column("leadership_json", sa.JSON(), nullable=True))
|
||||
op.add_column("members", sa.Column("sponsored_count", sa.Integer(), nullable=True))
|
||||
op.add_column("members", sa.Column("cosponsored_count", sa.Integer(), nullable=True))
|
||||
op.add_column("members", sa.Column("detail_fetched", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("members", "congress_url")
|
||||
op.drop_column("members", "birth_year")
|
||||
op.drop_column("members", "address")
|
||||
op.drop_column("members", "phone")
|
||||
op.drop_column("members", "terms_json")
|
||||
op.drop_column("members", "leadership_json")
|
||||
op.drop_column("members", "sponsored_count")
|
||||
op.drop_column("members", "cosponsored_count")
|
||||
op.drop_column("members", "detail_fetched")
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -8,6 +10,9 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database import get_db
|
||||
from app.models import Bill, Member
|
||||
from app.schemas.schemas import BillSchema, MemberSchema, PaginatedResponse
|
||||
from app.services import congress_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -60,6 +65,21 @@ async def get_member(bioguide_id: str, db: AsyncSession = Depends(get_db)):
|
||||
member = await db.get(Member, bioguide_id)
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
# Lazy-enrich with detail data from Congress.gov on first view
|
||||
if member.detail_fetched is None:
|
||||
try:
|
||||
detail_raw = congress_api.get_member_detail(bioguide_id)
|
||||
enriched = congress_api.parse_member_detail_from_api(detail_raw)
|
||||
for field, value in enriched.items():
|
||||
if value is not None:
|
||||
setattr(member, field, value)
|
||||
member.detail_fetched = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
await db.refresh(member)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not enrich member detail for {bioguide_id}: {e}")
|
||||
|
||||
return member
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, String, DateTime
|
||||
from sqlalchemy import Column, Integer, JSON, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -18,6 +18,15 @@ class Member(Base):
|
||||
district = Column(String(50))
|
||||
photo_url = Column(String)
|
||||
official_url = Column(String)
|
||||
congress_url = Column(String)
|
||||
birth_year = Column(String(10))
|
||||
address = Column(String)
|
||||
phone = Column(String(50))
|
||||
terms_json = Column(JSON)
|
||||
leadership_json = Column(JSON)
|
||||
sponsored_count = Column(Integer)
|
||||
cosponsored_count = Column(Integer)
|
||||
detail_fetched = Column(DateTime(timezone=True))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ class MemberSchema(BaseModel):
|
||||
chamber: Optional[str] = None
|
||||
district: Optional[str] = None
|
||||
photo_url: Optional[str] = None
|
||||
official_url: Optional[str] = None
|
||||
congress_url: Optional[str] = None
|
||||
birth_year: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
terms_json: Optional[list[Any]] = None
|
||||
leadership_json: Optional[list[Any]] = None
|
||||
sponsored_count: Optional[int] = None
|
||||
cosponsored_count: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ def parse_bill_from_api(data: dict, congress: int) -> dict:
|
||||
|
||||
|
||||
def parse_member_from_api(data: dict) -> dict:
|
||||
"""Normalize raw API member data into our model fields."""
|
||||
"""Normalize raw API member list data into our model fields."""
|
||||
terms = data.get("terms", {}).get("item", [])
|
||||
current_term = terms[-1] if terms else {}
|
||||
return {
|
||||
@@ -118,3 +118,50 @@ def parse_member_from_api(data: dict) -> dict:
|
||||
"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"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user