from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Response, status from sqlalchemy.orm import Session from app.api.v1.schemas import ( BuildCampaignRequest, CampaignCreateRequest, CampaignCreateResponse, CampaignCreateMinimalRequest, CampaignJobsResponse, CampaignListResponse, CampaignResponse, CampaignVersionDetailResponse, CampaignVersionResponse, CampaignVersionSetStepRequest, CampaignVersionUpdateRequest, CampaignPartialValidationRequest, CampaignPartialValidationResponse, ValidateCampaignRequest, ReportEmailRequest, ReportEmailResponse, ) from app.auth.dependencies import ApiPrincipal, require_scope from app.audit.logging import audit_from_principal from app.db.models import Campaign, CampaignJob, CampaignVersion from app.db.session import get_session from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from app.mailer.reports.emailing import CampaignReportEmailError, send_campaign_report_email from app.mailer.persistence.campaigns import ( CampaignPersistenceError, build_campaign_version, create_campaign_version_from_json, validate_campaign_version, ) from app.mailer.persistence.versions import ( create_minimal_campaign, get_campaign_version_for_tenant, publish_campaign_version, update_campaign_version, validate_campaign_partial, ) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign: campaign = session.get(Campaign, campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found") return campaign def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -> CampaignVersion: version = session.get(CampaignVersion, version_id) if not version: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") return version @router.post("", response_model=CampaignCreateResponse) def create_campaign( payload: CampaignCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:write")), ): try: campaign, version = create_campaign_version_from_json( session, tenant_id=principal.tenant_id, user_id=principal.user.id, raw_json=payload.config, source_filename=payload.source_filename, source_base_path=payload.source_base_path, ) audit_from_principal( session, principal, action="campaign.created", object_type="campaign", object_id=campaign.id, details={"version_id": version.id, "external_id": campaign.external_id}, commit=True, ) except Exception as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) @router.post("/new", response_model=CampaignCreateResponse) def create_minimal_campaign_endpoint( payload: CampaignCreateMinimalRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:write")), ): """Create a minimal editable campaign/version for the WebUI wizard. This is intentionally different from importing a complete campaign JSON. It returns a normal Campaign + CampaignVersion whose version is a working copy and can be autosaved while incomplete. """ try: campaign, version = create_minimal_campaign( session, tenant_id=principal.tenant_id, user_id=principal.user.id, external_id=payload.external_id, name=payload.name, description=payload.description, current_flow=payload.current_flow, current_step=payload.current_step, ) audit_from_principal( session, principal, action="campaign.created_minimal", object_type="campaign", object_id=campaign.id, details={"version_id": version.id, "external_id": campaign.external_id}, commit=True, ) return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.get("", response_model=CampaignListResponse) def list_campaigns( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): campaigns = ( session.query(Campaign) .filter(Campaign.tenant_id == principal.tenant_id) .order_by(Campaign.updated_at.desc()) .all() ) return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns]) @router.get("/{campaign_id}", response_model=CampaignResponse) def get_campaign( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id)) @router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) def list_versions( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) versions = ( session.query(CampaignVersion) .filter(CampaignVersion.campaign_id == campaign.id) .order_by(CampaignVersion.version_number.desc()) .all() ) return [CampaignVersionResponse.model_validate(item) for item in versions] @router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) def get_version_detail( campaign_id: str, version_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): try: version = get_campaign_version_for_tenant( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id ) return CampaignVersionDetailResponse.model_validate(version) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) def update_version_detail( campaign_id: str, version_id: str, payload: CampaignVersionUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:write")), ): try: version = update_campaign_version( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, raw_json=payload.campaign_json, current_flow=payload.current_flow, current_step=payload.current_step, workflow_state=payload.workflow_state, is_complete=payload.is_complete, editor_state=payload.editor_state, source_filename=payload.source_filename, source_base_path=payload.source_base_path, autosave=False, ) audit_from_principal( session, principal, action="campaign.version_updated", object_type="campaign_version", object_id=version.id, details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, commit=True, ) return CampaignVersionDetailResponse.model_validate(version) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse) def autosave_version( campaign_id: str, version_id: str, payload: CampaignVersionUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:write")), ): try: version = update_campaign_version( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, raw_json=payload.campaign_json, current_flow=payload.current_flow, current_step=payload.current_step, workflow_state=payload.workflow_state, is_complete=payload.is_complete, editor_state=payload.editor_state, source_filename=payload.source_filename, source_base_path=payload.source_base_path, autosave=True, ) audit_from_principal( session, principal, action="campaign.version_autosaved", object_type="campaign_version", object_id=version.id, details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, commit=True, ) return CampaignVersionDetailResponse.model_validate(version) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/{campaign_id}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse) def set_version_step( campaign_id: str, version_id: str, payload: CampaignVersionSetStepRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:write")), ): try: version = update_campaign_version( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, current_flow=payload.current_flow, current_step=payload.current_step, autosave=True, ) return CampaignVersionDetailResponse.model_validate(version) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse) def validate_version_partial( campaign_id: str, version_id: str, payload: CampaignPartialValidationRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:validate")), ): try: version = get_campaign_version_for_tenant( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id ) campaign_json = payload.campaign_json if payload and payload.campaign_json is not None else version.raw_json result = validate_campaign_partial(campaign_json, section=payload.section if payload else None) audit_from_principal( session, principal, action="campaign.version_partially_validated", object_type="campaign_version", object_id=version.id, details={"campaign_id": campaign_id, "section": result.get("section"), "ok": result.get("ok")}, commit=True, ) return CampaignPartialValidationResponse(**result) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.post("/{campaign_id}/versions/{version_id}/publish", response_model=CampaignVersionDetailResponse) def publish_version( campaign_id: str, version_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:write")), ): try: version = publish_campaign_version(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id) audit_from_principal( session, principal, action="campaign.version_published", object_type="campaign_version", object_id=version.id, details={"campaign_id": campaign_id}, commit=True, ) return CampaignVersionDetailResponse.model_validate(version) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.post("/versions/{version_id}/validate") def validate_version( version_id: str, payload: ValidateCampaignRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:validate")), ): try: result = validate_campaign_version( session, tenant_id=principal.tenant_id, version_id=version_id, check_files=payload.check_files if payload else False, user_id=principal.user.id, ) audit_from_principal( session, principal, action="campaign.validated", object_type="campaign_version", object_id=version_id, details={"check_files": payload.check_files if payload else False, "ok": result.get("ok")}, commit=True, ) return result except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/versions/{version_id}/build") def build_version( version_id: str, payload: BuildCampaignRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:build")), ): try: result = build_campaign_version( session, tenant_id=principal.tenant_id, version_id=version_id, write_eml=payload.write_eml if payload else True, ) audit_from_principal( session, principal, action="campaign.messages_built", object_type="campaign_version", object_id=version_id, details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")}, commit=True, ) return result except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) def list_jobs( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) jobs = ( session.query(CampaignJob) .filter(CampaignJob.campaign_id == campaign.id) .order_by(CampaignJob.entry_index.asc()) .all() ) return CampaignJobsResponse( jobs=[ { "id": job.id, "entry_index": job.entry_index, "entry_id": job.entry_id, "recipient_email": job.recipient_email, "subject": job.subject, "build_status": job.build_status, "validation_status": job.validation_status, "queue_status": job.queue_status, "send_status": job.send_status, "imap_status": job.imap_status, "eml_local_path": job.eml_local_path, "eml_size_bytes": job.eml_size_bytes, "attempt_count": job.attempt_count, "last_error": job.last_error, "queued_at": job.queued_at, "sent_at": job.sent_at, "issues": job.issues_snapshot, "attachments": job.resolved_attachments, } for job in jobs ] ) @router.get("/{campaign_id}/summary") def campaign_summary( campaign_id: str, include_jobs: bool = False, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): """Return dashboard-friendly campaign status counters and summaries.""" try: return generate_campaign_report( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, include_jobs=include_jobs, ) except CampaignReportError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.get("/{campaign_id}/report") def campaign_report( campaign_id: str, include_jobs: bool = True, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): """Return the full JSON report for one campaign.""" try: return generate_campaign_report( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, include_jobs=include_jobs, ) except CampaignReportError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.get("/{campaign_id}/report/jobs.csv") def campaign_jobs_csv( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:read")), ): """Export per-job campaign status as CSV.""" try: csv_text = generate_jobs_csv(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) except CampaignReportError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc return Response( content=csv_text, media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'}, ) @router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse) def email_campaign_report( campaign_id: str, payload: ReportEmailRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("reports:send")), ): """Generate a campaign report and send it to one or more email addresses.""" try: result = send_campaign_report_email( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, to=payload.to, include_jobs=payload.include_jobs, attach_jobs_csv=payload.attach_jobs_csv, attach_report_json=payload.attach_report_json, dry_run=payload.dry_run, ) audit_from_principal( session, principal, action="report.email_sent" if not payload.dry_run else "report.email_dry_run", object_type="campaign", object_id=campaign_id, details=result.as_dict(), commit=True, ) return ReportEmailResponse(result=result.as_dict()) except CampaignReportError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except (CampaignReportEmailError, Exception) as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc # Queue / delivery control ------------------------------------------------- from app.api.v1.schemas import ( AppendSentRequest, CampaignActionResponse, QueueCampaignRequest, QueueCampaignResponse, SendCampaignNowRequest, SendCampaignNowResponse, ) from app.mailer.sending.jobs import ( QueueingError, cancel_campaign_jobs, enqueue_pending_imap_appends, pause_campaign_jobs, queue_campaign_jobs, resume_campaign_jobs, send_campaign_now, ) @router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse) def queue_campaign( campaign_id: str, payload: QueueCampaignRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:queue")), ): payload = payload or QueueCampaignRequest() try: result = queue_campaign_jobs( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=payload.version_id, include_warnings=payload.include_warnings, enqueue_celery=payload.enqueue_celery, dry_run=payload.dry_run, ) audit_from_principal( session, principal, action="campaign.queued" if not payload.dry_run else "campaign.queue_dry_run", object_type="campaign", object_id=campaign_id, details=result.as_dict(), commit=True, ) return QueueCampaignResponse(**result.as_dict()) except QueueingError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse) def send_campaign_now_endpoint( campaign_id: str, payload: SendCampaignNowRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:send")), ): """Validate/build/queue and synchronously send a small campaign version. This endpoint is intentionally conservative and suitable for a first small test campaign. Larger campaigns should use the queue/Celery flow. """ payload = payload or SendCampaignNowRequest() try: campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) version_id = payload.version_id or campaign.current_version_id if not version_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version") validation_result: dict[str, object] | None = None build_result: dict[str, object] | None = None if payload.validate_before_send: validation_result = validate_campaign_version( session, tenant_id=principal.tenant_id, version_id=version_id, check_files=payload.check_files, user_id=principal.user.id, ) if not validation_result.get("ok"): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"message": "Campaign validation failed", "validation": validation_result}) if payload.build_before_send: build_result = build_campaign_version( session, tenant_id=principal.tenant_id, version_id=version_id, write_eml=True, ) result = send_campaign_now( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, include_warnings=payload.include_warnings, dry_run=payload.dry_run, use_rate_limit=payload.use_rate_limit, enqueue_imap_task=payload.enqueue_imap_task, ).as_dict() result["validation"] = validation_result result["build"] = build_result audit_from_principal( session, principal, action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run", object_type="campaign", object_id=campaign_id, details=result, commit=True, ) return SendCampaignNowResponse(result=result) except HTTPException: raise except (CampaignPersistenceError, QueueingError) as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/{campaign_id}/pause", response_model=CampaignActionResponse) def pause_campaign( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:queue")), ): try: result = pause_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) audit_from_principal(session, principal, action="campaign.paused", object_type="campaign", object_id=campaign_id, details=result, commit=True) return CampaignActionResponse(result=result) except QueueingError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/{campaign_id}/resume", response_model=CampaignActionResponse) def resume_campaign( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:queue")), ): try: result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True) return CampaignActionResponse(result=result) except QueueingError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse) def cancel_campaign( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:queue")), ): try: result = cancel_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) audit_from_principal(session, principal, action="campaign.cancelled", object_type="campaign", object_id=campaign_id, details=result, commit=True) return CampaignActionResponse(result=result) except QueueingError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc @router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse) def append_sent( campaign_id: str, payload: AppendSentRequest | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaign:send")), ): payload = payload or AppendSentRequest() try: result = enqueue_pending_imap_appends( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, enqueue_celery=payload.enqueue_celery, dry_run=payload.dry_run, ) audit_from_principal( session, principal, action="campaign.append_sent_enqueued" if not payload.dry_run else "campaign.append_sent_dry_run", object_type="campaign", object_id=campaign_id, details=result, commit=True, ) return CampaignActionResponse(result=result) except QueueingError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc