modcomms/backend/app/api/routes.py
michael 99af0164e6 Add PostgreSQL database support with Alembic migrations
Backend:
- Add PostgreSQL service to docker-compose with health checks
- Add SQLAlchemy async models for all entities (Agency, User, Campaign,
  Proof, ProofVersion, FlaggedItem, ResolvedItem, ErrorItem)
- Add Alembic migration framework with initial schema migration
- Add repository layer for CRUD operations
- Add REST API endpoints for campaigns, proofs, and audit items
- Add file storage service for proof uploads
- Update WebSocket handler to optionally persist analysis results

Frontend:
- Add apiService.ts for REST API communication
- Update geminiService.ts to support database persistence options

Deployment:
- Update deploy.sh to handle database migrations (6-step process)
- Update Dockerfile to include alembic configuration
- Add PostgreSQL environment variables to .env templates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:27:18 -06:00

488 lines
16 KiB
Python

"""REST API routes for campaigns, proofs, and audit items."""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas import (
CampaignCreate,
CampaignUpdate,
CampaignResponse,
ProofCreate,
ProofResponse,
ProofVersionResponse,
FlaggedItemCreate,
FlaggedItemResponse,
ResolvedItemCreate,
ResolvedItemResponse,
ErrorItemResponse,
AnalyticsResponse,
DropdownOptionsResponse,
UserResponse,
)
from app.dependencies.auth import get_current_user
from app.models.database import get_db
from app.repositories import (
CampaignRepository,
ProofRepository,
UserRepository,
AuditRepository,
)
router = APIRouter()
# Helper to get user from DB based on Azure claims
async def get_db_user(
session: AsyncSession,
user_claims: dict,
) -> Optional[uuid.UUID]:
"""Get or create user from Azure AD claims and return user ID."""
user_repo = UserRepository(session)
azure_oid = user_claims.get("oid") or user_claims.get("sub")
if not azure_oid:
return None
user = await user_repo.get_or_create_from_azure(
azure_ad_oid=azure_oid,
email=user_claims.get("email", user_claims.get("preferred_username", "")),
name=user_claims.get("name", "Unknown"),
)
return user.id
# Campaign endpoints
@router.get("/campaigns", response_model=list[CampaignResponse])
async def list_campaigns(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""List all campaigns."""
repo = CampaignRepository(db)
campaigns_with_counts = await repo.get_with_proof_counts()
return [
CampaignResponse(
id=item["campaign"].id,
name=item["campaign"].name,
workfront_id=item["campaign"].workfront_id,
client_lead=item["campaign"].client_lead,
agency_lead=item["campaign"].agency_lead,
brand_guidelines=item["campaign"].brand_guidelines,
status=item["campaign"].status,
agency=item["campaign"].agency.name if item["campaign"].agency else None,
created_at=item["campaign"].created_at,
updated_at=item["campaign"].updated_at,
proofs=item["proof_count"],
)
for item in campaigns_with_counts
]
@router.post("/campaigns", response_model=CampaignResponse, status_code=201)
async def create_campaign(
data: CampaignCreate,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Create a new campaign."""
user_id = await get_db_user(db, user)
repo = CampaignRepository(db)
# Check if campaign name already exists
existing = await repo.get_by_name(data.name)
if existing:
raise HTTPException(status_code=400, detail="Campaign with this name already exists")
campaign = await repo.create(
name=data.name,
workfront_id=data.workfront_id,
client_lead=data.client_lead,
agency_lead=data.agency_lead,
brand_guidelines=data.brand_guidelines,
created_by=user_id,
)
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workfront_id=campaign.workfront_id,
client_lead=campaign.client_lead,
agency_lead=campaign.agency_lead,
brand_guidelines=campaign.brand_guidelines,
status=campaign.status,
agency=None,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
proofs=0,
)
@router.get("/campaigns/{campaign_id}", response_model=CampaignResponse)
async def get_campaign(
campaign_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Get a campaign by ID."""
repo = CampaignRepository(db)
campaign = await repo.get_by_id(campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workfront_id=campaign.workfront_id,
client_lead=campaign.client_lead,
agency_lead=campaign.agency_lead,
brand_guidelines=campaign.brand_guidelines,
status=campaign.status,
agency=campaign.agency.name if campaign.agency else None,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
proofs=len(campaign.proofs),
)
@router.put("/campaigns/{campaign_id}", response_model=CampaignResponse)
async def update_campaign(
campaign_id: uuid.UUID,
data: CampaignUpdate,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Update a campaign."""
repo = CampaignRepository(db)
campaign = await repo.update(
campaign_id,
name=data.name,
workfront_id=data.workfront_id,
client_lead=data.client_lead,
agency_lead=data.agency_lead,
brand_guidelines=data.brand_guidelines,
status=data.status,
)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
return CampaignResponse(
id=campaign.id,
name=campaign.name,
workfront_id=campaign.workfront_id,
client_lead=campaign.client_lead,
agency_lead=campaign.agency_lead,
brand_guidelines=campaign.brand_guidelines,
status=campaign.status,
agency=campaign.agency.name if campaign.agency else None,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
proofs=len(campaign.proofs) if campaign.proofs else 0,
)
@router.delete("/campaigns/{campaign_id}", status_code=204)
async def delete_campaign(
campaign_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a campaign."""
repo = CampaignRepository(db)
success = await repo.delete(campaign_id)
if not success:
raise HTTPException(status_code=404, detail="Campaign not found")
# Proof endpoints
@router.get("/campaigns/{campaign_id}/proofs", response_model=list[ProofResponse])
async def list_proofs(
campaign_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""List all proofs for a campaign."""
repo = ProofRepository(db)
proofs = await repo.list_by_campaign(campaign_id)
return [
ProofResponse(
id=proof.id,
proof_name=proof.proof_name,
channel=proof.channel,
sub_channel=proof.sub_channel,
proof_type=proof.proof_type,
workfront_id=proof.workfront_id,
created_at=proof.created_at,
versions=[
ProofVersionResponse(
id=v.id,
version=v.version,
file_storage_key=v.file_storage_key,
thumbnail_url=v.thumbnail_url,
agent_review=v.agent_review,
overall_status=v.overall_status,
workfront_id=v.workfront_id,
created_at=v.created_at,
)
for v in proof.versions
],
)
for proof in proofs
]
@router.get("/proofs/{proof_id}", response_model=ProofResponse)
async def get_proof(
proof_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Get a proof by ID."""
repo = ProofRepository(db)
proof = await repo.get_by_id(proof_id)
if not proof:
raise HTTPException(status_code=404, detail="Proof not found")
return ProofResponse(
id=proof.id,
proof_name=proof.proof_name,
channel=proof.channel,
sub_channel=proof.sub_channel,
proof_type=proof.proof_type,
workfront_id=proof.workfront_id,
created_at=proof.created_at,
versions=[
ProofVersionResponse(
id=v.id,
version=v.version,
file_storage_key=v.file_storage_key,
thumbnail_url=v.thumbnail_url,
agent_review=v.agent_review,
overall_status=v.overall_status,
workfront_id=v.workfront_id,
created_at=v.created_at,
)
for v in proof.versions
],
)
@router.delete("/proofs/{proof_id}", status_code=204)
async def delete_proof(
proof_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a proof."""
repo = ProofRepository(db)
success = await repo.delete(proof_id)
if not success:
raise HTTPException(status_code=404, detail="Proof not found")
# Audit endpoints
@router.post("/proofs/{proof_id}/versions/{version}/flag", response_model=FlaggedItemResponse, status_code=201)
async def flag_proof_version(
proof_id: uuid.UUID,
version: int,
data: FlaggedItemCreate,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Flag an issue on a proof version."""
user_id = await get_db_user(db, user)
proof_repo = ProofRepository(db)
audit_repo = AuditRepository(db)
# Get the proof version
proof_version = await proof_repo.get_version(proof_id, version)
if not proof_version:
raise HTTPException(status_code=404, detail="Proof version not found")
flagged = await audit_repo.create_flagged_item(
proof_version_id=proof_version.id,
agent_flagged=data.agent_flagged,
comments=data.comments,
submitter_id=user_id,
)
# Get related data for response
proof = await proof_repo.get_by_id(proof_id)
return FlaggedItemResponse(
id=flagged.id,
proof_version_id=flagged.proof_version_id,
agent_flagged=flagged.agent_flagged,
comments=flagged.comments,
submitter_name=user.get("name"),
submitter_agency=None,
campaign_name=proof.campaign.name if proof and proof.campaign else None,
proof_name=proof.proof_name if proof else None,
version=version,
created_at=flagged.created_at,
)
@router.post("/proofs/{proof_id}/versions/{version}/resolve", response_model=ResolvedItemResponse, status_code=201)
async def resolve_proof_version(
proof_id: uuid.UUID,
version: int,
data: ResolvedItemCreate,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Resolve an issue on a proof version."""
user_id = await get_db_user(db, user)
proof_repo = ProofRepository(db)
audit_repo = AuditRepository(db)
# Get the proof version
proof_version = await proof_repo.get_version(proof_id, version)
if not proof_version:
raise HTTPException(status_code=404, detail="Proof version not found")
resolved = await audit_repo.create_resolved_item(
proof_version_id=proof_version.id,
agent=data.agent,
issue=data.issue,
resolution=data.resolution,
submitter_id=user_id,
)
# Get related data for response
proof = await proof_repo.get_by_id(proof_id)
return ResolvedItemResponse(
id=resolved.id,
proof_version_id=resolved.proof_version_id,
agent=resolved.agent,
issue=resolved.issue,
resolution=resolved.resolution,
submitter_name=user.get("name"),
submitter_agency=None,
campaign_name=proof.campaign.name if proof and proof.campaign else None,
proof_name=proof.proof_name if proof else None,
version=version,
created_at=resolved.created_at,
)
@router.get("/audit/flagged", response_model=list[FlaggedItemResponse])
async def list_flagged_items(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""List all flagged items."""
audit_repo = AuditRepository(db)
flagged_items = await audit_repo.get_flagged_items(limit=limit, offset=offset)
return [
FlaggedItemResponse(
id=item.id,
proof_version_id=item.proof_version_id,
agent_flagged=item.agent_flagged,
comments=item.comments,
submitter_name=item.submitter.name if item.submitter else None,
submitter_agency=item.submitter.agency.name if item.submitter and item.submitter.agency else None,
campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None,
proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None,
version=item.proof_version.version if item.proof_version else None,
created_at=item.created_at,
)
for item in flagged_items
]
@router.get("/audit/resolved", response_model=list[ResolvedItemResponse])
async def list_resolved_items(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""List all resolved items."""
audit_repo = AuditRepository(db)
resolved_items = await audit_repo.get_resolved_items(limit=limit, offset=offset)
return [
ResolvedItemResponse(
id=item.id,
proof_version_id=item.proof_version_id,
agent=item.agent,
issue=item.issue,
resolution=item.resolution,
submitter_name=item.submitter.name if item.submitter else None,
submitter_agency=item.submitter.agency.name if item.submitter and item.submitter.agency else None,
campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None,
proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None,
version=item.proof_version.version if item.proof_version else None,
created_at=item.created_at,
)
for item in resolved_items
]
@router.get("/audit/errors", response_model=list[ErrorItemResponse])
async def list_error_items(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""List all error items."""
audit_repo = AuditRepository(db)
error_items = await audit_repo.get_error_items(limit=limit, offset=offset)
return [
ErrorItemResponse(
id=item.id,
proof_version_id=item.proof_version_id,
error_summary=item.error_summary,
campaign_name=item.proof_version.proof.campaign.name if item.proof_version and item.proof_version.proof and item.proof_version.proof.campaign else None,
proof_name=item.proof_version.proof.proof_name if item.proof_version and item.proof_version.proof else None,
version=item.proof_version.version if item.proof_version else None,
created_at=item.created_at,
)
for item in error_items
]
# Analytics endpoint
@router.get("/analytics", response_model=AnalyticsResponse)
async def get_analytics(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Get analytics data."""
repo = CampaignRepository(db)
analytics = await repo.get_analytics()
return AnalyticsResponse(**analytics)
# Users endpoint (admin only)
@router.get("/users", response_model=list[UserResponse])
async def list_users(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""List all users (admin only)."""
user_repo = UserRepository(db)
users = await user_repo.list_all()
return [
UserResponse(
id=u.id,
email=u.email,
name=u.name,
role=u.role,
agency=u.agency.name if u.agency else None,
created_at=u.created_at,
)
for u in users
]