- 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
150 lines
5.5 KiB
Python
150 lines
5.5 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.dependencies import get_current_admin
|
|
from app.database import get_db
|
|
from app.models import Bill, BillBrief, BillDocument, Follow
|
|
from app.models.user import User
|
|
from app.schemas.schemas import UserResponse
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── User Management ───────────────────────────────────────────────────────────
|
|
|
|
class UserWithStats(UserResponse):
|
|
follow_count: int
|
|
|
|
|
|
@router.get("/users", response_model=list[UserWithStats])
|
|
async def list_users(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin),
|
|
):
|
|
"""List all users with their follow counts."""
|
|
users_result = await db.execute(select(User).order_by(User.created_at))
|
|
users = users_result.scalars().all()
|
|
|
|
counts_result = await db.execute(
|
|
select(Follow.user_id, func.count(Follow.id).label("cnt"))
|
|
.group_by(Follow.user_id)
|
|
)
|
|
counts = {row.user_id: row.cnt for row in counts_result}
|
|
|
|
return [
|
|
UserWithStats(
|
|
id=u.id,
|
|
email=u.email,
|
|
is_admin=u.is_admin,
|
|
notification_prefs=u.notification_prefs or {},
|
|
created_at=u.created_at,
|
|
follow_count=counts.get(u.id, 0),
|
|
)
|
|
for u in users
|
|
]
|
|
|
|
|
|
@router.delete("/users/{user_id}", status_code=204)
|
|
async def delete_user(
|
|
user_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin),
|
|
):
|
|
"""Delete a user account (cascades to their follows). Cannot delete yourself."""
|
|
if user_id == current_user.id:
|
|
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
|
user = await db.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
await db.delete(user)
|
|
await db.commit()
|
|
|
|
|
|
@router.patch("/users/{user_id}/toggle-admin", response_model=UserResponse)
|
|
async def toggle_admin(
|
|
user_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin),
|
|
):
|
|
"""Promote or demote a user's admin status."""
|
|
if user_id == current_user.id:
|
|
raise HTTPException(status_code=400, detail="Cannot change your own admin status")
|
|
user = await db.get(User, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
user.is_admin = not user.is_admin
|
|
await db.commit()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
# ── Analysis Stats ────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/stats")
|
|
async def get_stats(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_admin),
|
|
):
|
|
"""Return analysis pipeline progress counters."""
|
|
total_bills = (await db.execute(select(func.count()).select_from(Bill))).scalar()
|
|
docs_fetched = (await db.execute(
|
|
select(func.count()).select_from(BillDocument).where(BillDocument.raw_text.isnot(None))
|
|
)).scalar()
|
|
total_briefs = (await db.execute(select(func.count()).select_from(BillBrief))).scalar()
|
|
full_briefs = (await db.execute(
|
|
select(func.count()).select_from(BillBrief).where(BillBrief.brief_type == "full")
|
|
)).scalar()
|
|
amendment_briefs = (await db.execute(
|
|
select(func.count()).select_from(BillBrief).where(BillBrief.brief_type == "amendment")
|
|
)).scalar()
|
|
return {
|
|
"total_bills": total_bills,
|
|
"docs_fetched": docs_fetched,
|
|
"briefs_generated": total_briefs,
|
|
"full_briefs": full_briefs,
|
|
"amendment_briefs": amendment_briefs,
|
|
"remaining": total_bills - total_briefs,
|
|
}
|
|
|
|
|
|
# ── 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")
|
|
async def trigger_poll(current_user: User = Depends(get_current_admin)):
|
|
from app.workers.congress_poller import poll_congress_bills
|
|
task = poll_congress_bills.delay()
|
|
return {"task_id": task.id, "status": "queued"}
|
|
|
|
|
|
@router.post("/trigger-member-sync")
|
|
async def trigger_member_sync(current_user: User = Depends(get_current_admin)):
|
|
from app.workers.congress_poller import sync_members
|
|
task = sync_members.delay()
|
|
return {"task_id": task.id, "status": "queued"}
|
|
|
|
|
|
@router.post("/trigger-trend-scores")
|
|
async def trigger_trend_scores(current_user: User = Depends(get_current_admin)):
|
|
from app.workers.trend_scorer import calculate_all_trend_scores
|
|
task = calculate_all_trend_scores.delay()
|
|
return {"task_id": task.id, "status": "queued"}
|
|
|
|
|
|
@router.get("/task-status/{task_id}")
|
|
async def get_task_status(task_id: str, current_user: User = Depends(get_current_admin)):
|
|
from app.workers.celery_app import celery_app
|
|
result = celery_app.AsyncResult(task_id)
|
|
return {
|
|
"task_id": task_id,
|
|
"status": result.status,
|
|
"result": result.result if result.ready() else None,
|
|
}
|