@router.post("/{cid}/preview") async def preview_campaign(cid: int, sample_size: int = Query(0), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Preview: generate the campaign plan for a sample of recipients and render the actual messages (subject + HTML + substituted variables). NOTHING is sent.""" c = await _get_own(db, user, cid) if c.status not in ("draft", "paused", "queued", "failed"): raise HTTPException(status_code=409, detail="Cannot preview a running campaign") size = sample_size or c.preview_sample_size or 5 size = max(1, min(size, 20)) if not c.recipient_list_id: raise HTTPException(status_code=400, detail="Campaign has no recipient list") rl = await db.get(RecipientList, c.recipient_list_id) if not rl or rl.user_id != user.id: raise HTTPException(status_code=400, detail="Recipient list not found") recipient_rows = (await db.execute( select(Recipient).where(Recipient.list_id == rl.id, Recipient.is_valid == True) # noqa: E712 .order_by(Recipient.id).limit(size))).scalars().all() if not recipient_rows: raise HTTPException(status_code=400, detail="Recipient list has no valid recipients") # materialize plan rows WITHOUT persisting CampaignRecipient entities result = await materialize_campaign(db, c, recipient_rows, preview=True, preview_size=size) plan = result.get("plan", []) # render actual message content per sample engine = VariableEngine(c.variable_config or {}) out = [] for item in plan: rec = next((r for r in recipient_rows if r.id == item["recipient_id"]), None) if not rec: continue tpl = await db.get(EmailTemplate, item["template_id"]) if item.get("template_id") else None subj = await db.get(SubjectLine, item["subject_id"]) if item.get("subject_id") else None g = GeneratedValues() g.invoice_number = item.get("invoice_number", "") g.amount = item.get("amount") g.shipping = item.get("shipping") g.invoice_date = item.get("invoice_date") g.due_date = item.get("due_date")