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:
parent
4e6545e5f2
commit
447c4b2a95
5 changed files with 178 additions and 4 deletions
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue