Comfoa's picture
feat: Vercel-ready OpenCouncil with 3 cities connected (Paris, Sulphur Springs, Frisco)
6ce841a
Raw History Blame Contribute Delete
11.2 kB
"""
Verification endpoints for OpenCouncil.
All endpoints require JWT authentication via Supabase Auth.
Volunteers can view pending summaries, start verification sessions,
approve or reject summaries, and track their hours.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from api.auth import get_current_volunteer
from models.schemas import Summary, Volunteer
from social.pipeline import run_social_pipeline
from storage import (
complete_verification_session,
create_verification_session,
get_minutes,
get_summary,
get_volunteer_sessions,
list_pending_summaries,
update_summary_status,
update_summary_social,
update_volunteer_hours,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/verify", tags=["verify"])
# --- Request/Response Models ---
class StartSessionResponse(BaseModel):
session_id: str
class ApproveRequest(BaseModel):
session_id: str
edited_summary: Optional[str] = None
notes: Optional[str] = None
class RejectRequest(BaseModel):
session_id: str
reason: str
class SuccessResponse(BaseModel):
success: bool
summary_id: str
class HoursResponse(BaseModel):
total_hours: float
sessions: list
# --- Endpoints ---
@router.get("/pending")
async def get_pending_summaries(volunteer: Volunteer = Depends(get_current_volunteer)):
"""List all summaries with status='pending'.
Includes the related minute's title, meeting_date, and raw OCR text.
Requires a valid Bearer token.
"""
try:
pending = list_pending_summaries()
return {"summaries": pending, "count": len(pending)}
except Exception as e:
logger.error(f"Failed to list pending summaries: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list pending summaries: {str(e)}",
)
@router.get("/{summary_id}")
async def get_summary_detail(
summary_id: str,
volunteer: Volunteer = Depends(get_current_volunteer),
):
"""Get full detail of a single summary, including raw OCR text.
Requires a valid Bearer token.
Returns 404 if the summary is not found.
"""
summary = get_summary(summary_id)
if not summary:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Summary {summary_id} not found",
)
return {
"id": summary.id,
"minutes_id": summary.minutes_id,
"summary": summary.summary,
"key_decisions": summary.key_decisions,
"budget_items": summary.budget_items,
"public_comment_opportunities": summary.public_comment_opportunities,
"items": summary.items,
"big_picture": summary.big_picture,
"what_you_can_do": summary.what_you_can_do,
"category": summary.category,
"neighborhood_impact": summary.neighborhood_impact,
"status": summary.status,
"created_at": summary.created_at.isoformat() if summary.created_at else None,
}
@router.post("/{summary_id}/start", response_model=StartSessionResponse)
async def start_verification_session(
summary_id: str,
volunteer: Volunteer = Depends(get_current_volunteer),
):
"""Start a new verification session for a summary.
Creates a new verification_sessions row with started_at = NOW().
Requires a valid Bearer token.
Returns 404 if the summary is not found.
Returns 409 if the summary is not in 'pending' status.
"""
summary = get_summary(summary_id)
if not summary:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Summary {summary_id} not found",
)
if summary.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Summary is already {summary.status}. Only pending summaries can be verified.",
)
try:
session_id = create_verification_session(
volunteer_id=volunteer.user_id,
summary_id=summary_id,
)
logger.info(
f"Verification session started: volunteer={volunteer.user_id}, "
f"summary={summary_id}, session={session_id}"
)
return StartSessionResponse(session_id=str(session_id))
except Exception as e:
logger.error(f"Failed to create verification session: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to start verification session: {str(e)}",
)
@router.post("/{summary_id}/approve", response_model=SuccessResponse)
async def approve_summary(
summary_id: str,
request: ApproveRequest,
volunteer: Volunteer = Depends(get_current_volunteer),
):
"""Approve a summary after verification.
Updates the verification session with ended_at, duration_seconds,
and action='approved'. Updates the summary with status='verified',
verified_by, and verified_at. If edited_summary is provided, updates
the summary text. Triggers the social media pipeline as a background
task (card generation, storage upload, Facebook/Instagram posting).
Requires a valid Bearer token.
Returns 404 if the summary or session is not found.
"""
summary = get_summary(summary_id)
if not summary:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Summary {summary_id} not found",
)
if summary.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Summary is already {summary.status}. Only pending summaries can be approved.",
)
try:
# Complete the verification session
complete_verification_session(
session_id=request.session_id,
action="approved",
notes=request.notes,
)
# Update the summary status
now = datetime.now(timezone.utc)
update_summary_status(
summary_id=summary_id,
status="verified",
verified_by=volunteer.user_id,
verified_at=now,
edited_summary=request.edited_summary,
)
# Add volunteer hours (e.g., 0.5 hours per verification)
update_volunteer_hours(volunteer.user_id, 0.5)
# Fire social pipeline as a background task (non-blocking)
async def _post_to_social():
try:
# Fetch minutes to get city and meeting_date
minutes = get_minutes(summary.minutes_id)
city = f"{minutes.city}, {minutes.state}" if minutes else "Paris, Texas"
meeting_date = minutes.meeting_date.isoformat() if minutes and minutes.meeting_date else ""
result = await run_social_pipeline(
summary_id=str(summary_id),
headline=summary.neighborhood_impact or "",
theme=summary.category or "City Council",
summary_text=(summary.summary or "")[:200],
city=city,
meeting_date=meeting_date,
)
if result["success"]:
update_summary_social(summary_id, result["image_url"])
logger.info(
f"Social pipeline completed for summary {summary_id}: "
f"fb={result['facebook_post_id']}, ig={result['instagram_media_id']}"
)
else:
logger.warning(f"Social pipeline completed with partial/failure for summary {summary_id}")
except Exception as e:
logger.error(f"Social pipeline failed for summary {summary_id}: {e}")
asyncio.create_task(_post_to_social())
logger.info(
f"Summary approved: summary={summary_id}, "
f"volunteer={volunteer.user_id}, session={request.session_id}"
)
return SuccessResponse(success=True, summary_id=summary_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to approve summary: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to approve summary: {str(e)}",
)
@router.post("/{summary_id}/reject", response_model=SuccessResponse)
async def reject_summary(
summary_id: str,
request: RejectRequest,
volunteer: Volunteer = Depends(get_current_volunteer),
):
"""Reject a summary after verification.
Updates the verification session with ended_at, duration_seconds,
and action='rejected'. Updates the summary with status='rejected'
and rejection_reason.
Requires a valid Bearer token.
Returns 404 if the summary or session is not found.
"""
summary = get_summary(summary_id)
if not summary:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Summary {summary_id} not found",
)
if summary.status != "pending":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Summary is already {summary.status}. Only pending summaries can be rejected.",
)
try:
# Complete the verification session
complete_verification_session(
session_id=request.session_id,
action="rejected",
notes=request.reason,
)
# Update the summary status
update_summary_status(
summary_id=summary_id,
status="rejected",
verified_by=volunteer.user_id,
rejection_reason=request.reason,
)
logger.info(
f"Summary rejected: summary={summary_id}, "
f"volunteer={volunteer.user_id}, session={request.session_id}, "
f"reason={request.reason}"
)
return SuccessResponse(success=True, summary_id=summary_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to reject summary: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reject summary: {str(e)}",
)
@router.get("/hours", response_model=HoursResponse)
async def get_volunteer_hours(volunteer: Volunteer = Depends(get_current_volunteer)):
"""Get the current volunteer's total hours and session history.
Requires a valid Bearer token.
Returns total_hours and a list of past verification sessions.
"""
try:
sessions = get_volunteer_sessions(volunteer.user_id)
return HoursResponse(
total_hours=volunteer.hours_earned,
sessions=sessions,
)
except Exception as e:
logger.error(f"Failed to get volunteer hours: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get volunteer hours: {str(e)}",
)