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 && (