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
165 lines
5.6 KiB
Python
165 lines
5.6 KiB
Python
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import desc, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.database import get_db
|
|
from app.models import Bill, Member, MemberTrendScore, MemberNewsArticle
|
|
from app.schemas.schemas import (
|
|
BillSchema, MemberSchema, MemberTrendScoreSchema,
|
|
MemberNewsArticleSchema, PaginatedResponse,
|
|
)
|
|
from app.services import congress_api
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("", response_model=PaginatedResponse[MemberSchema])
|
|
async def list_members(
|
|
chamber: Optional[str] = Query(None),
|
|
party: Optional[str] = Query(None),
|
|
state: Optional[str] = Query(None),
|
|
q: Optional[str] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(50, ge=1, le=250),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
query = select(Member)
|
|
if chamber:
|
|
query = query.where(Member.chamber == chamber)
|
|
if party:
|
|
query = query.where(Member.party == party)
|
|
if state:
|
|
query = query.where(Member.state == state)
|
|
if 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
|
|
query = query.order_by(Member.last_name, Member.first_name).offset((page - 1) * per_page).limit(per_page)
|
|
|
|
result = await db.execute(query)
|
|
members = result.scalars().all()
|
|
|
|
return PaginatedResponse(
|
|
items=members,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
pages=max(1, (total + per_page - 1) // per_page),
|
|
)
|
|
|
|
|
|
@router.get("/{bioguide_id}", response_model=MemberSchema)
|
|
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")
|
|
|
|
# Kick off member interest on first view — single combined task avoids duplicate API calls
|
|
if member.detail_fetched is None:
|
|
try:
|
|
from app.workers.member_interest import sync_member_interest
|
|
sync_member_interest.delay(bioguide_id)
|
|
except Exception:
|
|
pass
|
|
|
|
# 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}")
|
|
|
|
# Attach latest trend score
|
|
result_schema = MemberSchema.model_validate(member)
|
|
latest_trend = (
|
|
await db.execute(
|
|
select(MemberTrendScore)
|
|
.where(MemberTrendScore.member_id == bioguide_id)
|
|
.order_by(desc(MemberTrendScore.score_date))
|
|
.limit(1)
|
|
)
|
|
)
|
|
trend = latest_trend.scalar_one_or_none()
|
|
if trend:
|
|
result_schema.latest_trend = MemberTrendScoreSchema.model_validate(trend)
|
|
return result_schema
|
|
|
|
|
|
@router.get("/{bioguide_id}/trend", response_model=list[MemberTrendScoreSchema])
|
|
async def get_member_trend(
|
|
bioguide_id: str,
|
|
days: int = Query(30, ge=7, le=365),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
from datetime import date, timedelta
|
|
cutoff = date.today() - timedelta(days=days)
|
|
result = await db.execute(
|
|
select(MemberTrendScore)
|
|
.where(MemberTrendScore.member_id == bioguide_id, MemberTrendScore.score_date >= cutoff)
|
|
.order_by(MemberTrendScore.score_date)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{bioguide_id}/news", response_model=list[MemberNewsArticleSchema])
|
|
async def get_member_news(bioguide_id: str, db: AsyncSession = Depends(get_db)):
|
|
result = await db.execute(
|
|
select(MemberNewsArticle)
|
|
.where(MemberNewsArticle.member_id == bioguide_id)
|
|
.order_by(desc(MemberNewsArticle.published_at))
|
|
.limit(20)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/{bioguide_id}/bills", response_model=PaginatedResponse[BillSchema])
|
|
async def get_member_bills(
|
|
bioguide_id: str,
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(20, ge=1, le=100),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
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
|
|
query = query.order_by(desc(Bill.introduced_date)).offset((page - 1) * per_page).limit(per_page)
|
|
|
|
result = await db.execute(query)
|
|
bills = result.scalars().all()
|
|
|
|
items = []
|
|
for bill in bills:
|
|
b = BillSchema.model_validate(bill)
|
|
if bill.briefs:
|
|
b.latest_brief = bill.briefs[0]
|
|
items.append(b)
|
|
|
|
return PaginatedResponse(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
per_page=per_page,
|
|
pages=max(1, (total + per_page - 1) // per_page),
|
|
)
|