fix(members): link sponsors to bills and fix member search
- Poller now fetches bill detail on insert to get sponsor (list endpoint has no sponsor data) - Add backfill_sponsor_ids task + admin endpoint + UI button to fix the 1,616 existing bills with NULL sponsor_id - Member name search now matches both "Last, First" and "First Last" using split_part() on the stored name column; same fix applied to global search - Load Bill.sponsor relationship eagerly in get_member_bills to prevent MissingGreenlet error during Pydantic serialization - Remove .trim() on search onChange so spaces can be typed Authored-By: Jack Levy
This commit is contained in:
@@ -110,6 +110,13 @@ async def get_stats(
|
|||||||
|
|
||||||
# ── Celery Tasks ──────────────────────────────────────────────────────────────
|
# ── Celery Tasks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/backfill-sponsors")
|
||||||
|
async def backfill_sponsors(current_user: User = Depends(get_current_admin)):
|
||||||
|
from app.workers.congress_poller import backfill_sponsor_ids
|
||||||
|
task = backfill_sponsor_ids.delay()
|
||||||
|
return {"task_id": task.id, "status": "queued"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger-poll")
|
@router.post("/trigger-poll")
|
||||||
async def trigger_poll(current_user: User = Depends(get_current_admin)):
|
async def trigger_poll(current_user: User = Depends(get_current_admin)):
|
||||||
from app.workers.congress_poller import poll_congress_bills
|
from app.workers.congress_poller import poll_congress_bills
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import desc, func, select
|
from sqlalchemy import desc, func, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -30,7 +30,15 @@ async def list_members(
|
|||||||
if state:
|
if state:
|
||||||
query = query.where(Member.state == state)
|
query = query.where(Member.state == state)
|
||||||
if q:
|
if q:
|
||||||
query = query.where(Member.name.ilike(f"%{q}%"))
|
# name is stored as "Last, First" — also match "First Last" order
|
||||||
|
first_last = func.concat(
|
||||||
|
func.split_part(Member.name, ", ", 2), " ",
|
||||||
|
func.split_part(Member.name, ", ", 1),
|
||||||
|
)
|
||||||
|
query = query.where(or_(
|
||||||
|
Member.name.ilike(f"%{q}%"),
|
||||||
|
first_last.ilike(f"%{q}%"),
|
||||||
|
))
|
||||||
|
|
||||||
total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
query = query.order_by(Member.last_name, Member.first_name).offset((page - 1) * per_page).limit(per_page)
|
query = query.order_by(Member.last_name, Member.first_name).offset((page - 1) * per_page).limit(per_page)
|
||||||
@@ -62,7 +70,7 @@ async def get_member_bills(
|
|||||||
per_page: int = Query(20, ge=1, le=100),
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
query = select(Bill).options(selectinload(Bill.briefs)).where(Bill.sponsor_id == bioguide_id)
|
query = select(Bill).options(selectinload(Bill.briefs), selectinload(Bill.sponsor)).where(Bill.sponsor_id == bioguide_id)
|
||||||
total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
total = await db.scalar(select(func.count()).select_from(query.subquery())) or 0
|
||||||
query = query.order_by(desc(Bill.introduced_date)).offset((page - 1) * per_page).limit(per_page)
|
query = query.order_by(desc(Bill.introduced_date)).offset((page - 1) * per_page).limit(per_page)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import func, or_, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -38,10 +38,17 @@ async def search(
|
|||||||
seen.add(b.bill_id)
|
seen.add(b.bill_id)
|
||||||
bills.append(b)
|
bills.append(b)
|
||||||
|
|
||||||
# Fuzzy member search
|
# Fuzzy member search — matches "Last, First" and "First Last"
|
||||||
|
first_last = func.concat(
|
||||||
|
func.split_part(Member.name, ", ", 2), " ",
|
||||||
|
func.split_part(Member.name, ", ", 1),
|
||||||
|
)
|
||||||
member_results = await db.execute(
|
member_results = await db.execute(
|
||||||
select(Member)
|
select(Member)
|
||||||
.where(Member.name.ilike(f"%{q}%"))
|
.where(or_(
|
||||||
|
Member.name.ilike(f"%{q}%"),
|
||||||
|
first_last.ilike(f"%{q}%"),
|
||||||
|
))
|
||||||
.order_by(Member.last_name)
|
.order_by(Member.last_name)
|
||||||
.limit(10)
|
.limit(10)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,8 +71,14 @@ def poll_congress_bills(self):
|
|||||||
existing = db.get(Bill, bill_id)
|
existing = db.get(Bill, bill_id)
|
||||||
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
# Upsert sponsor member if referenced
|
# Bill list endpoint has no sponsor data — fetch detail to get it
|
||||||
sponsor_id = _sync_sponsor(db, bill_data)
|
try:
|
||||||
|
detail = congress_api.get_bill_detail(
|
||||||
|
current_congress, parsed["bill_type"], parsed["bill_number"]
|
||||||
|
)
|
||||||
|
sponsor_id = _sync_sponsor(db, detail.get("bill", {}))
|
||||||
|
except Exception:
|
||||||
|
sponsor_id = None
|
||||||
parsed["sponsor_id"] = sponsor_id
|
parsed["sponsor_id"] = sponsor_id
|
||||||
parsed["last_checked_at"] = datetime.now(timezone.utc)
|
parsed["last_checked_at"] = datetime.now(timezone.utc)
|
||||||
db.add(Bill(**parsed))
|
db.add(Bill(**parsed))
|
||||||
@@ -165,6 +171,33 @@ def _sync_sponsor(db, bill_data: dict) -> str | None:
|
|||||||
return bioguide_id
|
return bioguide_id
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(bind=True, name="app.workers.congress_poller.backfill_sponsor_ids")
|
||||||
|
def backfill_sponsor_ids(self):
|
||||||
|
"""Backfill sponsor_id for all bills where it is NULL by fetching bill detail from Congress.gov."""
|
||||||
|
import time
|
||||||
|
db = get_sync_db()
|
||||||
|
try:
|
||||||
|
bills = db.query(Bill).filter(Bill.sponsor_id.is_(None)).all()
|
||||||
|
total = len(bills)
|
||||||
|
updated = 0
|
||||||
|
logger.info(f"Backfilling sponsors for {total} bills")
|
||||||
|
for bill in bills:
|
||||||
|
try:
|
||||||
|
detail = congress_api.get_bill_detail(bill.congress_number, bill.bill_type, bill.bill_number)
|
||||||
|
sponsor_id = _sync_sponsor(db, detail.get("bill", {}))
|
||||||
|
if sponsor_id:
|
||||||
|
bill.sponsor_id = sponsor_id
|
||||||
|
db.commit()
|
||||||
|
updated += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not backfill sponsor for {bill.bill_id}: {e}")
|
||||||
|
time.sleep(0.1) # ~10 req/sec, well under Congress.gov 5000/hr limit
|
||||||
|
logger.info(f"Sponsor backfill complete: {updated}/{total} updated")
|
||||||
|
return {"total": total, "updated": updated}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
|
def _update_bill_if_changed(db, existing: Bill, parsed: dict) -> bool:
|
||||||
"""Update bill fields if anything has changed. Returns True if updated."""
|
"""Update bill fields if anything has changed. Returns True if updated."""
|
||||||
changed = False
|
changed = False
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function BillsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search bills..."
|
placeholder="Search bills..."
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }}
|
onChange={(e) => { setQ(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function MembersPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name..."
|
placeholder="Search by name..."
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => { setQ(e.target.value.trim()); setPage(1); }}
|
onChange={(e) => { setQ(e.target.value); setPage(1); }}
|
||||||
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
|
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-md focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -346,6 +346,12 @@ export default function SettingsPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" /> Calculate Trends
|
<RefreshCw className="w-3.5 h-3.5" /> Calculate Trends
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => trigger("sponsors", adminAPI.backfillSponsors)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-muted hover:bg-accent rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" /> Backfill Sponsors
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{Object.entries(taskIds).map(([name, id]) => (
|
{Object.entries(taskIds).map(([name, id]) => (
|
||||||
<p key={name} className="text-xs text-muted-foreground">{name}: task {id} queued</p>
|
<p key={name} className="text-xs text-muted-foreground">{name}: task {id} queued</p>
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ export const adminAPI = {
|
|||||||
apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data),
|
apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data),
|
||||||
triggerTrendScores: () =>
|
triggerTrendScores: () =>
|
||||||
apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data),
|
apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data),
|
||||||
|
backfillSponsors: () =>
|
||||||
|
apiClient.post("/api/admin/backfill-sponsors").then((r) => r.data),
|
||||||
getTaskStatus: (taskId: string) =>
|
getTaskStatus: (taskId: string) =>
|
||||||
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
|
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user