@router.get("/list") async def list_campaigns(status: str = Query(""), search: str = Query(""), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)): q = select(Campaign).where(Campaign.user_id == user.id) if status: q = q.where(Campaign.status == status) if search: q = q.where(Campaign.name.ilike(f"%{search}%")) total = (await db.execute(select(func.count()).select_from(q.subquery()))).scalar() or 0 rows = (await db.execute(q.order_by(Campaign.id.desc()) .offset((page - 1) * page_size).limit(page_size))).scalars().all() return {"campaigns": [_ser(c) for c in rows], "total": total, "page": page, "page_size": page_size} @router.post("/create") async def create_campaign(payload: CampaignIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Create a new campaign (wizard step 12 / Save). Validates all references.""" errors = await _validate_refs(db, user, payload) if errors: raise HTTPException(status_code=400, detail={"message": "Validation failed", "errors": errors}) c = Campaign(user_id=user.id, **payload.model_dump()) db.add(c) await db.commit() await log_audit(db, user.id, "campaign.create", "campaign", c.id, {"name": c.name}) return _ser(c) @router.get("/{cid}") async def get_campaign(cid: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)): return _ser(await _get_own(db, user, cid)) @router.post("/{cid}/patch") async def patch_campaign(cid: int, payload: CampaignIn, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Full update (wizard edit). Only allowed while draft/paused/queued.""" c = await _get_own(db, user, cid) if c.status not in ("draft", "paused", "queued", "failed"): raise HTTPException(status_code=409, detail=f"Cannot edit a {c.status} campaign") errors = await _validate_refs(db, user, payload) if errors: raise HTTPException(status_code=400, detail={"message": "Validation failed", "errors": errors}) data = payload.model_dump() for k, v in data.items(): setattr(c, k, v) await db.commit() await log_audit(db, user.id, "campaign.patch", "campaign", c.id, {"fields": list(data.keys())}) return _ser(c) @router.post("/{cid}/delete") async def delete_campaign(cid: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): c = await _get_own(db, user, cid) if c.status == "running": raise HTTPException(status_code=409, detail="Pause or cancel the campaign first") await db.delete(c) await db.commit() await log_audit(db, user.id, "campaign.delete", "campaign", cid, {"name": c.name}) return {"deleted": cid} @router.post("/clear-all") async def clear_all_campaigns(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), _: None = Depends(require_csrf)): """Delete ALL campaigns (not running ones). Requires explicit UI confirmation.""" rows = (await db.execute(select(Campaign).where(Campaign.user_id == user.id))).scalars().all() deleted, skipped = 0, 0 for c in rows: if c.status == "running": skipped += 1 continue await db.delete(c) deleted += 1 await db.commit() await log_audit(db, user.id, "campaign.clear_all", "campaign", None, {"deleted": deleted, "skipped": skipped}) return {"deleted": deleted, "skipped": skipped} # --------------------------------------------------------------------------- # Preview: materialize the campaign plan and render REAL messages (no sending) # --------------------------------------------------------------------------- @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")