Add CSV export of campaign data for super_admin and oversight_admin

Adds a server-side CSV export covering all campaign, proof, and version
data including agent RAG statuses. The export respects the active agency
filter so oversight admins can scope the download to a single agency.

- backend: `CampaignRepository.get_export_rows()` — flat join across
  Campaign → Proof → ProofVersion with Agency and User, extracts agent
  RAG statuses from the `agent_review` JSONB column
- backend: `GET /api/export/campaigns-csv` endpoint gated to
  super_admin / oversight_admin, streams a dated CSV file
- frontend: `apiService.downloadCampaignsCsv(agencyId?)` — fetches blob
  and triggers browser download
- frontend: threads `selectedAgencyId` prop from App → Campaigns →
  CampaignList so the export uses the active filter
- frontend: Export CSV button in CampaignList header, visible only to
  super_admin / oversight_admin, with spinner while downloading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-19 11:35:24 +00:00
parent 4e6545e5f2
commit 447c4b2a95
5 changed files with 178 additions and 4 deletions

View file

@ -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)
# ---------------------------------------------------------------------------

View file

@ -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 = (

View file

@ -906,6 +906,7 @@ const AppContent: React.FC<{ msalInstance: any }> = ({ msalInstance }) => {
flaggedItems={flaggedItems}
resolvedItems={resolvedItems}
readOnly={readOnly || isUnassigned}
selectedAgencyId={selectedAgencyId}
/>;
case 'WIP Reviewer':
return <WIPReviewer dropdownOptions={dropdownOptions} msalInstance={msalInstance} />;

View file

@ -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<Set<string>>(new Set());
const [campaignToDelete, setCampaignToDelete] = useState<typeof initialCampaigns[0] | null>(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<SortKey>('lastModified');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const [columnFilters, setColumnFilters] = useState<Record<SortKey, string>>({
@ -480,6 +493,20 @@ const CampaignList: React.FC<{
<label htmlFor="my-campaigns" className="text-sm font-medium text-oliver-black whitespace-nowrap">My Campaigns Only</label>
<ToggleSwitch enabled={showMyCampaignsOnly} onChange={setShowMyCampaignsOnly} />
</div>
{(isSuperAdmin || isOversightAdmin) && (
<button
onClick={handleExportCsv}
disabled={isExportingCsv}
className="flex items-center gap-2 bg-white text-oliver-azure font-semibold py-2.5 px-6 rounded-full border border-oliver-azure hover:bg-oliver-grey transition-colors duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExportingCsv ? (
<SpinnerIcon className="h-5 w-5 animate-spin" />
) : (
<ExportIcon className="h-5 w-5" />
)}
Export CSV
</button>
)}
{!readOnly && (
<button
onClick={onOpenModal}
@ -1854,6 +1881,7 @@ interface CampaignsProps {
flaggedItems: FlaggedItem[];
resolvedItems: ResolvedItem[];
readOnly?: boolean;
selectedAgencyId?: string | null;
}
export const Campaigns: React.FC<CampaignsProps> = ({
@ -1877,6 +1905,7 @@ export const Campaigns: React.FC<CampaignsProps> = ({
flaggedItems,
resolvedItems,
readOnly = false,
selectedAgencyId,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
@ -1942,6 +1971,7 @@ export const Campaigns: React.FC<CampaignsProps> = ({
onCampaignStatusChange={readOnly ? () => {} : onCampaignStatusChange}
onDeleteCampaign={readOnly ? () => {} : (campaign) => onDeleteCampaign(campaign.name)}
readOnly={readOnly}
selectedAgencyId={selectedAgencyId}
/>
</>
);

View file

@ -456,6 +456,28 @@ class ApiService {
});
}
// Export endpoints
async downloadCampaignsCsv(agencyId?: string): Promise<void> {
const headers = await this.getHeaders();
const params = agencyId ? `?agency_id=${agencyId}` : '';
const response = await fetch(`${API_URL}/api/export/campaigns-csv${params}`, { headers });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}: ${response.statusText}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const disposition = response.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="([^"]+)"/);
a.download = match ? match[1] : 'campaigns_export.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Knowledge Base endpoints
async getKnowledgeBases(): Promise<KnowledgeBaseListItem[]> {
return this.fetch<KnowledgeBaseListItem[]>('/knowledge-base');