modcomms/backend/app/api/routes.py
michael 2f547dc494 Detect identical file uploads via MD5 hashing
- Add file_hash and is_identical_file columns to proof_versions table
- Compute MD5 hash on file upload and compare with previous version
- Display warning banner when uploading identical file as revision
- Return is_identical_file in WebSocket response and API endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:15:48 -06:00

729 lines
24 KiB
Python
Executable file

"""REST API routes for campaigns, proofs, and audit items."""
import base64
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas import (
CampaignCreate,
CampaignUpdate,
CampaignResponse,
ProofCreate,
ProofResponse,
ProofVersionResponse,
FlaggedItemCreate,
FlaggedItemResponse,
ResolvedItemCreate,
ResolvedItemResponse,
ErrorItemResponse,
AnalyticsResponse,
DropdownOptionsResponse,
AgencyResponse,
UserResponse,
SupportEmailRequest,
)
from app.dependencies.auth import get_current_user
from app.models.database import get_db
from app.repositories import (
CampaignRepository,
ProofRepository,
UserRepository,
AuditRepository,
DropdownRepository,
)
from app.services.storage_service import storage_service
from app.services.email_service import email_service
from app.services.pdf_service import pdf_service
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 and all associated files."""
repo = CampaignRepository(db)
# Get campaign with proofs and versions to extract file keys
campaign = await repo.get_by_id(campaign_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Delete files from storage for all proofs
for proof in campaign.proofs:
for version in proof.versions:
if version.file_storage_key:
await storage_service.delete_file(version.file_storage_key)
# Delete database records (cascades to proofs and versions)
await repo.delete(campaign_id)
# 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,
is_identical_file=v.is_identical_file,
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,
is_identical_file=v.is_identical_file,
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 and its associated files."""
repo = ProofRepository(db)
# Get proof with versions to extract file keys
proof = await repo.get_by_id(proof_id)
if not proof:
raise HTTPException(status_code=404, detail="Proof not found")
# Delete files from storage
for version in proof.versions:
if version.file_storage_key:
await storage_service.delete_file(version.file_storage_key)
# Delete database records
await repo.delete(proof_id)
# 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."""
import logging
logger = logging.getLogger(__name__)
repo = DropdownRepository(db)
options = await repo.get_all_hierarchical()
# Debug logging
channels = options.get("channels", {})
social = channels.get("Social", {})
meta_proof_types = social.get("Meta", [])
logger.info(f"[DEBUG API] Returning dropdown options - Social.Meta has {len(meta_proof_types)} proof types: {meta_proof_types}")
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()
# Agency endpoints
@router.get("/agencies", response_model=list[AgencyResponse])
async def list_agencies(
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""List all agencies."""
from sqlalchemy import select
from app.models.models import Agency
stmt = select(Agency).order_by(Agency.name)
result = await db.execute(stmt)
agencies = result.scalars().all()
return [AgencyResponse(id=a.id, name=a.name) for a in agencies]
# PDF pages endpoint (must be defined BEFORE the base file endpoint for correct routing)
@router.get("/files/{storage_key:path}/pages")
async def get_pdf_pages(
storage_key: str,
max_pages: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Rasterize a stored PDF and return pages as data URLs."""
if not storage_key.lower().endswith('.pdf'):
raise HTTPException(status_code=400, detail="File is not a PDF")
file_data = await storage_service.get_file(storage_key)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
try:
pages = pdf_service.rasterize(file_data, max_pages=max_pages)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {
"pages": [
{
"page": i + 1,
"data_url": f"data:image/png;base64,{base64.b64encode(png_data).decode('utf-8')}",
"width": width,
"height": height,
}
for i, (png_data, width, height) in enumerate(pages)
]
}
# File download endpoint
@router.get("/files/{storage_key:path}")
async def get_file(
storage_key: str,
db: AsyncSession = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""Retrieve a stored file by its storage key."""
file_data = await storage_service.get_file(storage_key)
if file_data is None:
raise HTTPException(status_code=404, detail="File not found")
# Determine content type from extension
extension = storage_key.split('.')[-1].lower() if '.' in storage_key else ''
content_types = {
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'webp': 'image/webp',
'svg': 'image/svg+xml',
'pdf': 'application/pdf',
}
return Response(
content=file_data,
media_type=content_types.get(extension, 'application/octet-stream'),
)
# Support email endpoint (public - no auth required for login page access)
@router.post("/support/email")
async def send_support_email(
data: SupportEmailRequest,
):
"""Send support email - no auth required (for login page)."""
success = await email_service.send_support_email(
message=data.message,
subject=data.subject,
user_name=data.user_name,
user_email=data.user_email,
)
if not success:
raise HTTPException(status_code=500, detail="Failed to send email")
return {"success": True, "message": "Email sent successfully"}