"""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, AgencyResponse, 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, ) from app.services.storage_service import storage_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, 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 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.""" 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() # 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]