"sent_at": cr.sent_at.isoformat() if cr.sent_at else None, "last_error": (cr.last_error or "")[:200], }) return {"recipients": out, "total": total, "page": page, "page_size": page_size} @router.get("/{cid}/export") async def export_campaign(cid: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """CSV export of campaign results (one row per campaign recipient).""" c = await _get_own(db, user, cid) rows = (await db.execute( select(CampaignRecipient).where(CampaignRecipient.campaign_id == cid) .order_by(CampaignRecipient.seq))).scalars().all() def generate(): buf = io.StringIO() w = csv.writer(buf) w.writerow(["seq", "email", "status", "account", "subject", "template", "invoice_number", "amount", "shipping", "attempts", "message_id", "thread_id", "sent_at", "last_error"]) yield buf.getvalue() buf.seek(0); buf.truncate(0) for cr in rows: w.writerow([cr.seq, cr.email, cr.status, acc_map.get(cr.assigned_account_id, ""), rendered_map.get(cr.email) or subj_map.get(cr.generated_subject_id, ""), tpl_map.get(cr.generated_template_id, ""), cr.generated_invoice_number, cr.generated_amount or "", cr.generated_shipping or "", cr.attempt_count, cr.msg_id, cr.thread_id, cr.sent_at.isoformat() if cr.sent_at else "", (cr.last_error or "")[:300]]) yield buf.getvalue() buf.seek(0); buf.truncate(0) return StreamingResponse(generate(), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="{fname}.csv"'}) # --------------------------------------------------------------------------- # Wizard options: what lists/subjects/templates/accounts/phones are selectable # --------------------------------------------------------------------------- @router.get("/wizard/options") async def wizard_options(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): """All selectable entities for the 12-step wizard in one call.""" lists = (await db.execute(select(RecipientList).where(RecipientList.user_id == user.id) .order_by(RecipientList.id.desc()))).scalars().all()