modcomms/backend/app/api/routes.py
michael c07c66a583 Connect frontend to PostgreSQL database via API
- Replace all localStorage-based state management with API calls
- Load campaigns, proofs, and audit items from database
- Persist proof analysis results to database via WebSocket
- Add dropdown options CRUD API endpoints (channels, sub-channels, proof types)
- Create DropdownRepository for managing dropdown options
- Update Analytics component to fetch data from API
- Remove demo data and localStorage persistence code

Frontend changes:
- App.tsx: Initialize apiService with MSAL, use API for all CRUD operations
- apiService.ts: Add dropdown options API methods
- Analytics.tsx: Fetch stats from /api/analytics

Backend changes:
- New dropdown_repository.py for dropdown CRUD
- routes.py: Add 7 dropdown endpoints

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

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

592 lines
20 KiB
Python
Executable file

"""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,
DropdownRepository,
)
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
]
# Dropdown options endpoints
@router.get("/dropdown-options", response_model=DropdownOptionsResponse)
async def get_dropdown_options(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Get all dropdown options as hierarchical structure."""
repo = DropdownRepository(db)
options = await repo.get_all_hierarchical()
return DropdownOptionsResponse(**options)
@router.post("/dropdown-options/channels", status_code=201)
async def add_channel(
name: str = Query(..., description="Channel name"),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Add a new channel."""
repo = DropdownRepository(db)
await repo.add_channel(name)
await db.commit()
return {"message": f"Channel '{name}' added successfully"}
@router.post("/dropdown-options/channels/{channel}/sub-channels", status_code=201)
async def add_sub_channel(
channel: str,
name: str = Query(..., description="Sub-channel name"),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Add a sub-channel under a channel."""
repo = DropdownRepository(db)
result = await repo.add_sub_channel(channel, name)
if not result:
raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found")
await db.commit()
return {"message": f"Sub-channel '{name}' added to '{channel}'"}
@router.post("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types", status_code=201)
async def add_proof_type(
channel: str,
sub_channel: str,
name: str = Query(..., description="Proof type name"),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Add a proof type under a sub-channel."""
repo = DropdownRepository(db)
result = await repo.add_proof_type(channel, sub_channel, name)
if not result:
raise HTTPException(status_code=404, detail=f"Channel '{channel}' or sub-channel '{sub_channel}' not found")
await db.commit()
return {"message": f"Proof type '{name}' added to '{channel}/{sub_channel}'"}
@router.delete("/dropdown-options/channels/{channel}", status_code=204)
async def delete_channel(
channel: str,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a channel and all its sub-channels and proof types."""
repo = DropdownRepository(db)
success = await repo.remove_channel(channel)
if not success:
raise HTTPException(status_code=404, detail=f"Channel '{channel}' not found")
await db.commit()
@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}", status_code=204)
async def delete_sub_channel(
channel: str,
sub_channel: str,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a sub-channel and all its proof types."""
repo = DropdownRepository(db)
success = await repo.remove_sub_channel(channel, sub_channel)
if not success:
raise HTTPException(status_code=404, detail=f"Sub-channel '{sub_channel}' not found in channel '{channel}'")
await db.commit()
@router.delete("/dropdown-options/channels/{channel}/sub-channels/{sub_channel}/proof-types/{proof_type}", status_code=204)
async def delete_proof_type(
channel: str,
sub_channel: str,
proof_type: str,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Delete a proof type."""
repo = DropdownRepository(db)
success = await repo.remove_proof_type(channel, sub_channel, proof_type)
if not success:
raise HTTPException(status_code=404, detail=f"Proof type '{proof_type}' not found")
await db.commit()