1094 lines
44 KiB
Python
1094 lines
44 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import re
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.api.v1.schemas import (
|
|
BuildCampaignRequest,
|
|
CampaignCreateRequest,
|
|
CampaignUpdateRequest,
|
|
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.storage.services import list_assets_for_user, resolve_patterns
|
|
from app.mailer.persistence.versions import (
|
|
LockedCampaignVersionError,
|
|
create_minimal_campaign,
|
|
fork_campaign_version_for_edit,
|
|
is_version_final_locked,
|
|
is_user_locked_version,
|
|
is_version_locked,
|
|
get_campaign_version_for_tenant,
|
|
publish_campaign_version,
|
|
unlock_validated_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
|
|
|
|
|
|
|
|
|
|
def _sync_campaign_metadata_to_current_version(session: Session, campaign: Campaign) -> None:
|
|
"""Keep editable version JSON aligned with version-independent campaign metadata.
|
|
|
|
Campaign metadata can be edited from the overview while individual campaign
|
|
sections save the current version JSON later. Without this sync, a later
|
|
version save can re-apply stale `campaign.name` / `campaign.id` values from
|
|
raw_json and make the old overview metadata appear to come back. Audit-safe
|
|
or validation-locked versions are left untouched.
|
|
"""
|
|
|
|
if not campaign.current_version_id:
|
|
return
|
|
|
|
version = session.get(CampaignVersion, campaign.current_version_id)
|
|
if not version or version.campaign_id != campaign.id or is_version_locked(version):
|
|
return
|
|
|
|
raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {})
|
|
campaign_section = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
|
raw_json["campaign"] = {
|
|
**campaign_section,
|
|
"id": campaign.external_id,
|
|
"name": campaign.name,
|
|
"description": campaign.description or "",
|
|
}
|
|
version.raw_json = raw_json
|
|
session.add(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.put("/{campaign_id}", response_model=CampaignResponse)
|
|
def update_campaign_metadata_endpoint(
|
|
campaign_id: str,
|
|
payload: CampaignUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
|
):
|
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
|
if payload.external_id is not None:
|
|
value = payload.external_id.strip()
|
|
if not value:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign ID cannot be empty")
|
|
duplicate = (
|
|
session.query(Campaign)
|
|
.filter(Campaign.tenant_id == principal.tenant_id, Campaign.external_id == value, Campaign.id != campaign.id)
|
|
.one_or_none()
|
|
)
|
|
if duplicate:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaign ID already exists for this tenant")
|
|
campaign.external_id = value
|
|
if payload.name is not None:
|
|
value = payload.name.strip()
|
|
if not value:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign name cannot be empty")
|
|
campaign.name = value
|
|
if payload.status is not None:
|
|
campaign.status = payload.status
|
|
if payload.description is not None:
|
|
campaign.description = payload.description
|
|
|
|
_sync_campaign_metadata_to_current_version(session, campaign)
|
|
session.add(campaign)
|
|
session.commit()
|
|
session.refresh(campaign)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.metadata_updated",
|
|
object_type="campaign",
|
|
object_id=campaign.id,
|
|
details={"external_id": campaign.external_id, "name": campaign.name},
|
|
commit=True,
|
|
)
|
|
return CampaignResponse.model_validate(campaign)
|
|
|
|
|
|
@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.post("/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse)
|
|
def fork_version_for_edit(
|
|
campaign_id: str,
|
|
version_id: str,
|
|
payload: CampaignVersionUpdateRequest | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
|
):
|
|
"""Create a new editable campaign version from a locked/validated/sent version.
|
|
|
|
Versions that were validated, built, queued or sent are immutable audit
|
|
snapshots. This endpoint makes an explicit editable copy and makes that
|
|
new copy the campaign's current version.
|
|
"""
|
|
|
|
payload = payload or CampaignVersionUpdateRequest()
|
|
try:
|
|
version = fork_campaign_version_for_edit(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
raw_json=payload.campaign_json,
|
|
current_flow=payload.current_flow or "manual",
|
|
current_step=payload.current_step,
|
|
editor_state=payload.editor_state,
|
|
source_filename=payload.source_filename,
|
|
source_base_path=payload.source_base_path,
|
|
autosave=True,
|
|
)
|
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.version_forked_for_edit",
|
|
object_type="campaign_version",
|
|
object_id=version.id,
|
|
details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number},
|
|
commit=True,
|
|
)
|
|
return CampaignCreateResponse(
|
|
campaign=CampaignResponse.model_validate(campaign),
|
|
version=CampaignVersionResponse.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}/unlock-validation", response_model=CampaignVersionDetailResponse)
|
|
def unlock_version_validation(
|
|
campaign_id: str,
|
|
version_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
|
):
|
|
"""Unlock a successfully validated version before delivery starts.
|
|
|
|
Unlocking invalidates validation/build state and removes generated jobs for
|
|
that version. Sent/final versions cannot be unlocked and must be copied.
|
|
"""
|
|
|
|
try:
|
|
version = unlock_validated_campaign_version(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.version_validation_unlocked",
|
|
object_type="campaign_version",
|
|
object_id=version.id,
|
|
details={"campaign_id": campaign_id},
|
|
commit=True,
|
|
)
|
|
return CampaignVersionDetailResponse.model_validate(version)
|
|
except LockedCampaignVersionError as exc:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
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 LockedCampaignVersionError as exc:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
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 LockedCampaignVersionError as exc:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
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 LockedCampaignVersionError as exc:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
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:
|
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
|
if is_user_locked_version(version) or is_version_final_locked(version):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="This version is audit-safe/final and cannot be validated again. Create an editable copy instead.",
|
|
)
|
|
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,
|
|
MockCampaignSendRequest,
|
|
MockCampaignSendResponse,
|
|
)
|
|
from app.mailer.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
|
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}/mock-send", response_model=MockCampaignSendResponse)
|
|
def mock_send_campaign(
|
|
campaign_id: str,
|
|
payload: MockCampaignSendRequest | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaign:send")),
|
|
):
|
|
"""Run a fully visible mock delivery flow without mutating campaign state.
|
|
|
|
The route validates and builds the selected version, then optionally records
|
|
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
|
|
real SMTP/IMAP servers and it does not mark the version sent/final.
|
|
"""
|
|
|
|
payload = payload or MockCampaignSendRequest()
|
|
try:
|
|
result = run_mock_campaign_send(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=payload.version_id,
|
|
send=payload.send,
|
|
include_warnings=payload.include_warnings,
|
|
include_needs_review=payload.include_needs_review,
|
|
append_sent=payload.append_sent,
|
|
clear_mailbox=payload.clear_mailbox,
|
|
check_files=payload.check_files,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.mock_send" if payload.send else "campaign.mock_send_review",
|
|
object_type="campaign",
|
|
object_id=campaign_id,
|
|
details={
|
|
"version_id": result.get("version_id"),
|
|
"send_requested": payload.send,
|
|
"sent_count": result.get("send", {}).get("sent_count"),
|
|
"failed_count": result.get("send", {}).get("failed_count"),
|
|
},
|
|
commit=True,
|
|
)
|
|
return MockCampaignSendResponse(result=result)
|
|
except MockCampaignSendError 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}/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")
|
|
|
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
|
validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None
|
|
build_result: dict[str, object] | None = version.build_summary if isinstance(version.build_summary, dict) else None
|
|
if is_user_locked_version(version):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
|
|
)
|
|
if not version.locked_at or not validation_result or validation_result.get("ok") is not True:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Campaign version must be validated and locked before dry-run or sending.",
|
|
)
|
|
if not build_result:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Campaign version must be built before dry-run or sending.",
|
|
)
|
|
|
|
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
|
|
|
|
|
|
class CampaignAttachmentPreviewRequest(BaseModel):
|
|
include_unmatched: bool = True
|
|
|
|
|
|
class CampaignAttachmentPreviewResponse(BaseModel):
|
|
campaign_id: str
|
|
version_id: str
|
|
shared_file_count: int
|
|
rules: list[dict[str, object]] = Field(default_factory=list)
|
|
unused_shared_files: list[dict[str, object]] = Field(default_factory=list)
|
|
|
|
|
|
_BRACE_PLACEHOLDER_RE = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
|
_DOLLAR_PLACEHOLDER_RE = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
|
|
|
|
|
def _preview_render_template(template: str, *, global_values: dict[str, object], entry_fields: dict[str, object]) -> str:
|
|
def value_for(raw_key: str) -> str:
|
|
key = raw_key.strip()
|
|
if key.startswith("local:"):
|
|
value = entry_fields.get(key.removeprefix("local:"), "")
|
|
elif key.startswith("local."):
|
|
value = entry_fields.get(key.removeprefix("local."), "")
|
|
elif key.startswith("global:"):
|
|
value = global_values.get(key.removeprefix("global:"), "")
|
|
elif key.startswith("global."):
|
|
value = global_values.get(key.removeprefix("global."), "")
|
|
else:
|
|
value = entry_fields.get(key, global_values.get(key, ""))
|
|
return "" if value is None else str(value)
|
|
|
|
rendered = _BRACE_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), template)
|
|
return _DOLLAR_PLACEHOLDER_RE.sub(lambda match: value_for(match.group(1)), rendered)
|
|
|
|
|
|
def _rule_pattern(rule: dict[str, object], base_path_names: set[str], *, global_values: dict[str, object], entry_fields: dict[str, object]) -> str:
|
|
base_dir = _preview_render_template(str(rule.get("base_dir") or "."), global_values=global_values, entry_fields=entry_fields).strip().strip("/")
|
|
file_filter = _preview_render_template(str(rule.get("file_filter") or "*"), global_values=global_values, entry_fields=entry_fields).strip() or "*"
|
|
if not base_dir or base_dir == "." or base_dir in base_path_names:
|
|
return file_filter
|
|
return f"{base_dir}/{file_filter}"
|
|
|
|
|
|
def _file_preview(asset) -> dict[str, object]:
|
|
return {
|
|
"id": asset.id,
|
|
"display_path": asset.display_path,
|
|
"filename": asset.filename,
|
|
"owner_type": asset.owner_type,
|
|
"owner_id": asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id,
|
|
}
|
|
|
|
|
|
@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse)
|
|
def preview_campaign_attachments(
|
|
campaign_id: str,
|
|
version_id: str,
|
|
payload: CampaignAttachmentPreviewRequest | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("attachments:read")),
|
|
):
|
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
|
if version.campaign_id != campaign.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found")
|
|
|
|
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
|
attachments = raw.get("attachments") if isinstance(raw.get("attachments"), dict) else {}
|
|
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
|
|
base_path_names = {str(item.get("name")) for item in base_paths if isinstance(item, dict) and item.get("name")}
|
|
global_values = raw.get("global_values") if isinstance(raw.get("global_values"), dict) else {}
|
|
rules: list[dict[str, object]] = []
|
|
|
|
global_rules = attachments.get("global") if isinstance(attachments.get("global"), list) else []
|
|
for index, rule in enumerate(global_rules):
|
|
if not isinstance(rule, dict):
|
|
continue
|
|
rules.append({
|
|
"source": "global",
|
|
"index": index,
|
|
"label": rule.get("label"),
|
|
"required": bool(rule.get("required", True)),
|
|
"pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields={}),
|
|
})
|
|
|
|
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
|
|
inline_entries = entries.get("inline") if isinstance(entries.get("inline"), list) else []
|
|
for entry_index, entry in enumerate(inline_entries, start=1):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
entry_fields = entry.get("fields") if isinstance(entry.get("fields"), dict) else {}
|
|
entry_rules = entry.get("attachments") if isinstance(entry.get("attachments"), list) else []
|
|
for rule_index, rule in enumerate(entry_rules):
|
|
if not isinstance(rule, dict):
|
|
continue
|
|
rules.append({
|
|
"source": "entry",
|
|
"entry_index": entry_index,
|
|
"entry_id": entry.get("id"),
|
|
"index": rule_index,
|
|
"label": rule.get("label"),
|
|
"required": bool(rule.get("required", True)),
|
|
"pattern": _rule_pattern(rule, base_path_names, global_values=global_values, entry_fields=entry_fields),
|
|
})
|
|
|
|
shared_assets = list_assets_for_user(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
campaign_id=campaign.id,
|
|
is_admin=principal.user.is_tenant_admin or "*" in set(principal.scopes or []),
|
|
)
|
|
resolved, unmatched = resolve_patterns(shared_assets, [str(rule["pattern"]) for rule in rules])
|
|
for rule, result in zip(rules, resolved, strict=False):
|
|
rule["matches"] = [_file_preview(asset) for asset in result.matches]
|
|
rule["match_count"] = len(result.matches)
|
|
|
|
return CampaignAttachmentPreviewResponse(
|
|
campaign_id=campaign.id,
|
|
version_id=version.id,
|
|
shared_file_count=len(shared_assets),
|
|
rules=rules,
|
|
unused_shared_files=[_file_preview(asset) for asset in unmatched] if payload is None or payload.include_unmatched else [],
|
|
)
|