diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 79ffef5..0047c69 100755 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -1,10 +1,13 @@ """REST API routes for campaigns, proofs, and audit items.""" import base64 +import csv +import io import uuid +from datetime import date from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form -from fastapi.responses import Response +from fastapi.responses import Response, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.api.schemas import ( @@ -950,6 +953,53 @@ async def get_file( ) +# --------------------------------------------------------------------------- +# Export endpoints +# --------------------------------------------------------------------------- + +@router.get("/export/campaigns-csv") +async def export_campaigns_csv( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_role("super_admin", "oversight_admin")), + agency_id: Optional[uuid.UUID] = Query(None, description="Filter by agency"), +): + """Export campaign data as a CSV file (super_admin and oversight_admin only).""" + effective_agency_id = _resolve_agency_filter(current_user, agency_id) + if effective_agency_id is _NO_AGENCY: + effective_agency_id = None + + repo = CampaignRepository(db) + rows = await repo.get_export_rows(agency_id=effective_agency_id) + + output = io.StringIO() + if rows: + writer = csv.DictWriter(output, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + else: + # Write empty CSV with headers only + headers = [ + "Campaign Name", "Campaign Workfront ID", "Client Lead", "Agency Lead", + "Brand Guidelines", "Campaign Status", "Agency", "Campaign Created", + "Proof Name", "Channel", "Sub-Channel", "Proof Type", "Proof Workfront ID", + "Proof Created By", "Proof Created By Email", "Proof Created", + "Version", "Overall Status", "Version Workfront ID", "Version Created", + "Legal RAG", "Brand RAG", "Channel Best Practices RAG", "Channel Tech Specs RAG", + "Lead Agent Summary", + ] + writer = csv.DictWriter(output, fieldnames=headers) + writer.writeheader() + + filename = f"campaigns_export_{date.today().isoformat()}.csv" + output.seek(0) + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + # --------------------------------------------------------------------------- # Support email endpoint (public - no auth required for login page access) # --------------------------------------------------------------------------- diff --git a/backend/app/repositories/campaign_repository.py b/backend/app/repositories/campaign_repository.py index 108e74d..bdf310c 100755 --- a/backend/app/repositories/campaign_repository.py +++ b/backend/app/repositories/campaign_repository.py @@ -5,7 +5,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models.models import Agency, Campaign, Proof, ProofVersion +from app.models.models import Agency, Campaign, Proof, ProofVersion, User class CampaignRepository: @@ -166,6 +166,77 @@ class CampaignRepository: "legal_review": row.legal_review, } + async def get_export_rows(self, agency_id: Optional[uuid.UUID] = None) -> list[dict]: + """Return flat rows for CSV export: Campaign → Proof → ProofVersion joined with Agency and User.""" + query = ( + select( + Campaign.name.label("campaign_name"), + Campaign.workfront_id.label("campaign_workfront_id"), + Campaign.client_lead, + Campaign.agency_lead, + Campaign.brand_guidelines, + Campaign.status.label("campaign_status"), + Campaign.created_at.label("campaign_created_at"), + Agency.name.label("agency_name"), + Proof.proof_name, + Proof.channel, + Proof.sub_channel, + Proof.proof_type, + Proof.workfront_id.label("proof_workfront_id"), + Proof.created_at.label("proof_created_at"), + User.name.label("proof_created_by_name"), + User.email.label("proof_created_by_email"), + ProofVersion.version, + ProofVersion.overall_status, + ProofVersion.workfront_id.label("version_workfront_id"), + ProofVersion.created_at.label("version_created_at"), + ProofVersion.agent_review, + ) + .select_from(Campaign) + .join(Agency, Campaign.agency_id == Agency.id, isouter=True) + .join(Proof, Proof.campaign_id == Campaign.id, isouter=True) + .join(User, Proof.created_by == User.id, isouter=True) + .join(ProofVersion, ProofVersion.proof_id == Proof.id, isouter=True) + ) + if agency_id: + query = query.where(Campaign.agency_id == agency_id) + query = query.order_by(Campaign.name, Proof.proof_name, ProofVersion.version) + + result = await self.session.execute(query) + rows = result.all() + + export_rows = [] + for row in rows: + agent_review = row.agent_review or {} + export_rows.append({ + "Campaign Name": row.campaign_name or "", + "Campaign Workfront ID": row.campaign_workfront_id or "", + "Client Lead": row.client_lead or "", + "Agency Lead": row.agency_lead or "", + "Brand Guidelines": row.brand_guidelines or "", + "Campaign Status": row.campaign_status or "", + "Agency": row.agency_name or "", + "Campaign Created": row.campaign_created_at.strftime("%Y-%m-%d %H:%M:%S") if row.campaign_created_at else "", + "Proof Name": row.proof_name or "", + "Channel": row.channel or "", + "Sub-Channel": row.sub_channel or "", + "Proof Type": row.proof_type or "", + "Proof Workfront ID": row.proof_workfront_id or "", + "Proof Created By": row.proof_created_by_name or "", + "Proof Created By Email": row.proof_created_by_email or "", + "Proof Created": row.proof_created_at.strftime("%Y-%m-%d %H:%M:%S") if row.proof_created_at else "", + "Version": str(row.version) if row.version is not None else "", + "Overall Status": row.overall_status or "", + "Version Workfront ID": row.version_workfront_id or "", + "Version Created": row.version_created_at.strftime("%Y-%m-%d %H:%M:%S") if row.version_created_at else "", + "Legal RAG": (agent_review.get("legalAgent") or {}).get("ragStatus") or "", + "Brand RAG": (agent_review.get("brandAgent") or {}).get("ragStatus") or "", + "Channel Best Practices RAG": (agent_review.get("channelAgent") or {}).get("bestPractices", {}).get("ragStatus") if agent_review.get("channelAgent") else "", + "Channel Tech Specs RAG": (agent_review.get("channelAgent") or {}).get("techSpecs", {}).get("ragStatus") if agent_review.get("channelAgent") else "", + "Lead Agent Summary": (agent_review.get("leadAgent") or {}).get("summary") or "", + }) + return export_rows + async def get_analytics_by_agency(self) -> list[dict]: """Get analytics data grouped by agency.""" query = ( diff --git a/frontend/App.tsx b/frontend/App.tsx index 2eb7c8f..a3e03ab 100755 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -906,6 +906,7 @@ const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => { flaggedItems={flaggedItems} resolvedItems={resolvedItems} readOnly={readOnly || isUnassigned} + selectedAgencyId={selectedAgencyId} />; case 'WIP Reviewer': return ; diff --git a/frontend/components/Campaigns.tsx b/frontend/components/Campaigns.tsx index 577136d..d3bb65d 100755 --- a/frontend/components/Campaigns.tsx +++ b/frontend/components/Campaigns.tsx @@ -282,14 +282,27 @@ const CampaignList: React.FC<{ onCampaignStatusChange: (campaignName: string, newStatus: 'In Progress' | 'Completed') => void; onDeleteCampaign: (campaign: typeof initialCampaigns[0]) => void; readOnly?: boolean; -}> = ({ onSelectCampaign, campaigns, onOpenModal, onCampaignStatusChange, onDeleteCampaign, readOnly = false }) => { - const { user } = useUser(); + selectedAgencyId?: string | null; +}> = ({ onSelectCampaign, campaigns, onOpenModal, onCampaignStatusChange, onDeleteCampaign, readOnly = false, selectedAgencyId }) => { + const { user, isSuperAdmin, isOversightAdmin } = useUser(); const [showCompleted, setShowCompleted] = useState(true); const [showMyCampaignsOnly, setShowMyCampaignsOnly] = useState(false); const [selectedCampaigns, setSelectedCampaigns] = useState>(new Set()); const [campaignToDelete, setCampaignToDelete] = useState(null); const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [isExportingCsv, setIsExportingCsv] = useState(false); + + const handleExportCsv = async () => { + setIsExportingCsv(true); + try { + await apiService.downloadCampaignsCsv(selectedAgencyId || undefined); + } catch (err) { + console.error('CSV export failed:', err); + } finally { + setIsExportingCsv(false); + } + }; const [sortKey, setSortKey] = useState('lastModified'); const [sortDirection, setSortDirection] = useState('desc'); const [columnFilters, setColumnFilters] = useState>({ @@ -480,6 +493,20 @@ const CampaignList: React.FC<{ + {(isSuperAdmin || isOversightAdmin) && ( + + )} {!readOnly && (