2147 lines
90 KiB
Python
2147 lines
90 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy import and_, exists, func, or_
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel, Field
|
|
|
|
from govoplan_campaign.backend.schemas import (
|
|
BuildCampaignRequest,
|
|
CampaignCreateRequest,
|
|
CampaignUpdateRequest,
|
|
CampaignCreateResponse,
|
|
CampaignCreateMinimalRequest,
|
|
CampaignShareItem,
|
|
CampaignShareListResponse,
|
|
CampaignShareTargetItem,
|
|
CampaignShareTargetsResponse,
|
|
CampaignShareUpsertRequest,
|
|
CampaignOwnerUpdateRequest,
|
|
RecipientImportMappingProfileListResponse,
|
|
RecipientImportMappingProfilePayload,
|
|
RecipientImportMappingProfileResponse,
|
|
CampaignJobsResponse,
|
|
CampaignJobDetailResponse,
|
|
CampaignRetryJobsRequest,
|
|
CampaignSendUnattemptedRequest,
|
|
CampaignResolveOutcomeRequest,
|
|
CampaignListResponse,
|
|
CampaignResponse,
|
|
CampaignVersionDetailResponse,
|
|
CampaignVersionResponse,
|
|
CampaignWorkspaceResponse,
|
|
CampaignVersionSetStepRequest,
|
|
CampaignReviewStateRequest,
|
|
CampaignVersionUpdateRequest,
|
|
CampaignPartialValidationRequest,
|
|
CampaignPartialValidationResponse,
|
|
ValidateCampaignRequest,
|
|
ReportEmailRequest,
|
|
ReportEmailResponse,
|
|
)
|
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope
|
|
from govoplan_core.audit.logging import audit_from_principal
|
|
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
|
from govoplan_core.core.runtime import get_registry
|
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
|
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
|
from govoplan_campaign.backend.persistence.campaigns import (
|
|
CampaignPersistenceError,
|
|
build_campaign_version,
|
|
create_campaign_version_from_json,
|
|
load_campaign_config_from_json,
|
|
validate_campaign_version,
|
|
)
|
|
from govoplan_campaign.backend.integrations import files_integration
|
|
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json
|
|
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
|
from govoplan_core.security.time import utc_now
|
|
from govoplan_campaign.backend.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,
|
|
lock_campaign_version_temporarily,
|
|
permanently_lock_campaign_version,
|
|
publish_campaign_version,
|
|
unlock_user_locked_campaign_version,
|
|
unlock_validated_campaign_version,
|
|
update_campaign_version,
|
|
update_campaign_review_state,
|
|
validate_campaign_partial,
|
|
)
|
|
|
|
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
|
|
|
|
|
def _access_directory() -> AccessDirectory:
|
|
registry = get_registry()
|
|
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is not configured")
|
|
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
|
|
if not isinstance(capability, AccessDirectory):
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is invalid")
|
|
return capability
|
|
|
|
|
|
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 _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]:
|
|
del session
|
|
return {group.id for group in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)}
|
|
|
|
|
|
|
|
|
|
def _campaign_acl_filter(session: Session, principal: ApiPrincipal):
|
|
if has_scope(principal, "tenant:*"):
|
|
return None
|
|
group_ids = _principal_group_ids(session, principal)
|
|
clauses = [Campaign.owner_user_id == principal.user.id]
|
|
if group_ids:
|
|
clauses.append(Campaign.owner_group_id.in_(group_ids))
|
|
share_clauses = [
|
|
and_(
|
|
CampaignShare.tenant_id == Campaign.tenant_id,
|
|
CampaignShare.campaign_id == Campaign.id,
|
|
CampaignShare.revoked_at.is_(None),
|
|
CampaignShare.target_type == "user",
|
|
CampaignShare.target_id == principal.user.id,
|
|
)
|
|
]
|
|
if group_ids:
|
|
share_clauses.append(
|
|
and_(
|
|
CampaignShare.tenant_id == Campaign.tenant_id,
|
|
CampaignShare.campaign_id == Campaign.id,
|
|
CampaignShare.revoked_at.is_(None),
|
|
CampaignShare.target_type == "group",
|
|
CampaignShare.target_id.in_(group_ids),
|
|
)
|
|
)
|
|
clauses.append(exists().where(or_(*share_clauses)))
|
|
return or_(*clauses)
|
|
|
|
|
|
def _campaign_acl_allows(session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False) -> bool:
|
|
if has_scope(principal, "tenant:*"):
|
|
return True
|
|
if campaign.owner_user_id == principal.user.id:
|
|
return True
|
|
group_ids = _principal_group_ids(session, principal)
|
|
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
|
|
return True
|
|
target_ids = [principal.user.id, *group_ids]
|
|
if not target_ids:
|
|
return False
|
|
query = session.query(CampaignShare).filter(
|
|
CampaignShare.tenant_id == campaign.tenant_id,
|
|
CampaignShare.campaign_id == campaign.id,
|
|
CampaignShare.revoked_at.is_(None),
|
|
or_(
|
|
CampaignShare.target_type == "user",
|
|
CampaignShare.target_type == "group",
|
|
),
|
|
CampaignShare.target_id.in_(target_ids),
|
|
)
|
|
shares = query.all()
|
|
if not shares:
|
|
return False
|
|
if not write:
|
|
return True
|
|
return any(item.permission == "write" for item in shares)
|
|
|
|
|
|
def _require_campaign_acl(session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False) -> None:
|
|
if not _campaign_acl_allows(session, campaign, principal, write=write):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Campaign is not shared with this principal")
|
|
|
|
|
|
def _get_campaign_for_principal(session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False) -> Campaign:
|
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
|
_require_campaign_acl(session, campaign, principal, write=write)
|
|
return campaign
|
|
|
|
|
|
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
|
|
if not has_scope(principal, scope):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
|
|
|
|
|
def _get_recipient_import_profile_for_principal(session: Session, profile_id: str, principal: ApiPrincipal) -> RecipientImportMappingProfile:
|
|
profile = session.get(RecipientImportMappingProfile, profile_id)
|
|
if not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipient import mapping profile not found")
|
|
return profile
|
|
|
|
|
|
def _apply_recipient_import_profile_payload(profile: RecipientImportMappingProfile, payload: RecipientImportMappingProfilePayload) -> None:
|
|
profile.name = payload.name.strip()
|
|
profile.column_count = payload.column_count
|
|
profile.headers = list(payload.headers)
|
|
profile.normalized_headers = list(payload.normalized_headers)
|
|
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
|
|
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
|
|
profile.delimiter = payload.delimiter
|
|
profile.header_rows = payload.header_rows
|
|
profile.quoted = payload.quoted
|
|
profile.value_separators = payload.value_separators
|
|
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
|
|
|
|
|
|
def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool:
|
|
if proposed is None:
|
|
return False
|
|
current = current or {}
|
|
return any(current.get(key) != proposed.get(key) for key in ("recipients", "entries"))
|
|
|
|
|
|
|
|
|
|
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
|
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
|
value = server.get("mail_profile_id") if isinstance(server, dict) else None
|
|
return str(value) if value else None
|
|
|
|
|
|
def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None:
|
|
if _campaign_mail_profile_id(raw_json) and not has_scope(principal, "mail:profile:use"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:use")
|
|
|
|
|
|
def _require_campaign_profile_use_if_needed(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
campaign_id: str,
|
|
version_id: str | None = None,
|
|
) -> None:
|
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
|
target_version_id = version_id or campaign.current_version_id
|
|
if not target_version_id:
|
|
return
|
|
version = _get_version_for_tenant(session, target_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")
|
|
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
|
|
|
|
|
def _get_version_for_principal(
|
|
session: Session,
|
|
version_id: str,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
write: bool = False,
|
|
) -> CampaignVersion:
|
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
|
campaign = _get_campaign_for_tenant(session, version.campaign_id, principal.tenant_id)
|
|
_require_campaign_acl(session, campaign, principal, write=write)
|
|
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("campaigns:campaign:create")),
|
|
):
|
|
try:
|
|
if payload.config.get("entries") or payload.config.get("recipients"):
|
|
_require_permission(principal, "campaigns:recipient:write")
|
|
_require_mail_profile_use_if_needed(principal, payload.config)
|
|
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 HTTPException:
|
|
raise
|
|
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("campaigns:campaign:create")),
|
|
):
|
|
"""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("campaigns:campaign:read")),
|
|
):
|
|
query = session.query(Campaign).filter(Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted")
|
|
acl_filter = _campaign_acl_filter(session, principal)
|
|
if acl_filter is not None:
|
|
query = query.filter(acl_filter)
|
|
campaigns = query.order_by(Campaign.updated_at.desc()).all()
|
|
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
|
|
|
|
|
|
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
|
|
def list_recipient_import_mapping_profiles(
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
|
|
):
|
|
profiles = (
|
|
session.query(RecipientImportMappingProfile)
|
|
.filter(
|
|
RecipientImportMappingProfile.tenant_id == principal.tenant_id,
|
|
RecipientImportMappingProfile.owner_user_id == principal.user.id,
|
|
)
|
|
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc())
|
|
.all()
|
|
)
|
|
return RecipientImportMappingProfileListResponse(
|
|
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/recipient-import/mapping-profiles",
|
|
response_model=RecipientImportMappingProfileResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_recipient_import_mapping_profile(
|
|
payload: RecipientImportMappingProfilePayload,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
|
|
):
|
|
profile = RecipientImportMappingProfile(
|
|
tenant_id=principal.tenant_id,
|
|
owner_user_id=principal.user.id,
|
|
name=payload.name.strip(),
|
|
column_count=payload.column_count,
|
|
headers=list(payload.headers),
|
|
normalized_headers=list(payload.normalized_headers),
|
|
ordered_header_fingerprint=payload.ordered_header_fingerprint,
|
|
unordered_header_fingerprint=payload.unordered_header_fingerprint,
|
|
delimiter=payload.delimiter,
|
|
header_rows=payload.header_rows,
|
|
quoted=payload.quoted,
|
|
value_separators=payload.value_separators,
|
|
mappings=[mapping.model_dump(mode="json") for mapping in payload.mappings],
|
|
)
|
|
session.add(profile)
|
|
session.flush()
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.recipient_import_mapping_profile_created",
|
|
object_type="recipient_import_mapping_profile",
|
|
object_id=profile.id,
|
|
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
|
|
commit=True,
|
|
)
|
|
session.refresh(profile)
|
|
return RecipientImportMappingProfileResponse.model_validate(profile)
|
|
|
|
|
|
@router.put("/recipient-import/mapping-profiles/{profile_id}", response_model=RecipientImportMappingProfileResponse)
|
|
def update_recipient_import_mapping_profile(
|
|
profile_id: str,
|
|
payload: RecipientImportMappingProfilePayload,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
|
|
):
|
|
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
|
|
_apply_recipient_import_profile_payload(profile, payload)
|
|
session.add(profile)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.recipient_import_mapping_profile_updated",
|
|
object_type="recipient_import_mapping_profile",
|
|
object_id=profile.id,
|
|
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
|
|
commit=True,
|
|
)
|
|
session.refresh(profile)
|
|
return RecipientImportMappingProfileResponse.model_validate(profile)
|
|
|
|
|
|
@router.delete("/recipient-import/mapping-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_recipient_import_mapping_profile(
|
|
profile_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
|
|
):
|
|
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
|
|
details = {"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint}
|
|
session.delete(profile)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.recipient_import_mapping_profile_deleted",
|
|
object_type="recipient_import_mapping_profile",
|
|
object_id=profile_id,
|
|
details=details,
|
|
commit=True,
|
|
)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.get("/{campaign_id}", response_model=CampaignResponse)
|
|
def get_campaign(
|
|
campaign_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
|
):
|
|
return CampaignResponse.model_validate(_get_campaign_for_principal(session, campaign_id, principal))
|
|
|
|
|
|
@router.get("/{campaign_id}/workspace", response_model=CampaignWorkspaceResponse)
|
|
def get_campaign_workspace(
|
|
campaign_id: str,
|
|
version_id: str | None = None,
|
|
include_current_version: bool = True,
|
|
include_summary: bool = False,
|
|
include_versions: bool = True,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
|
|
|
versions: list[CampaignVersion] = []
|
|
if include_versions or include_current_version:
|
|
versions = (
|
|
session.query(CampaignVersion)
|
|
.filter(CampaignVersion.campaign_id == campaign.id)
|
|
.order_by(CampaignVersion.version_number.desc())
|
|
.all()
|
|
)
|
|
|
|
selected_version_id = version_id or campaign.current_version_id or (versions[0].id if versions else None)
|
|
current_version: CampaignVersion | None = None
|
|
if include_current_version and selected_version_id:
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
current_version = (
|
|
session.query(CampaignVersion)
|
|
.filter(
|
|
CampaignVersion.id == selected_version_id,
|
|
CampaignVersion.campaign_id == campaign.id,
|
|
)
|
|
.one_or_none()
|
|
)
|
|
|
|
summary_payload: dict[str, object] | None = None
|
|
if include_summary:
|
|
try:
|
|
summary_payload = generate_campaign_report(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=selected_version_id,
|
|
include_jobs=False,
|
|
)
|
|
except CampaignReportError:
|
|
summary_payload = None
|
|
|
|
return CampaignWorkspaceResponse(
|
|
campaign=CampaignResponse.model_validate(campaign),
|
|
versions=[CampaignVersionResponse.model_validate(item) for item in versions] if include_versions else [],
|
|
current_version=CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None,
|
|
summary=summary_payload,
|
|
selected_version_id=selected_version_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("campaigns:campaign:update")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
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.post("/{campaign_id}/archive", response_model=CampaignResponse)
|
|
def archive_campaign(
|
|
campaign_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
if campaign.status in {"queued", "sending", "outcome_unknown"}:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Active or uncertain delivery must be resolved before archiving.")
|
|
campaign.status = "archived"
|
|
session.add(campaign)
|
|
audit_from_principal(
|
|
session, principal, action="campaign.archived", object_type="campaign", object_id=campaign.id, details={}, commit=True
|
|
)
|
|
session.refresh(campaign)
|
|
return CampaignResponse.model_validate(campaign)
|
|
|
|
|
|
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_draft_campaign(
|
|
campaign_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
if campaign.status != "draft":
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Only untouched draft campaigns can be deleted.")
|
|
if session.query(CampaignJob.id).filter(CampaignJob.campaign_id == campaign.id).first() is not None:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaigns with built or delivery jobs must be archived instead of deleted.")
|
|
protected_version = (
|
|
session.query(CampaignVersion.id)
|
|
.filter(
|
|
CampaignVersion.campaign_id == campaign.id,
|
|
or_(
|
|
CampaignVersion.locked_at.is_not(None),
|
|
CampaignVersion.user_lock_state.is_not(None),
|
|
CampaignVersion.published_at.is_not(None),
|
|
CampaignVersion.execution_snapshot_at.is_not(None),
|
|
),
|
|
)
|
|
.first()
|
|
)
|
|
if protected_version is not None:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Audit-relevant campaign versions must be archived instead of deleted.")
|
|
campaign.status = "deleted"
|
|
session.add(campaign)
|
|
audit_from_principal(
|
|
session, principal, action="campaign.deleted", object_type="campaign", object_id=campaign.id, details={"mode": "soft"}, commit=True
|
|
)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@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("campaigns:campaign:read")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
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("campaigns:campaign:read")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:recipient: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("campaigns:campaign:copy")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
"""Create the campaign's next and only editable working version.
|
|
|
|
A new working copy may be created only after the current version is
|
|
permanently user-locked or delivery-final. Validation and temporary user
|
|
locks must be removed in place instead of creating parallel drafts.
|
|
"""
|
|
|
|
payload = payload or CampaignVersionUpdateRequest()
|
|
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
|
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 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}/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("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
"""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.post("/{campaign_id}/versions/{version_id}/lock-temporarily", response_model=CampaignVersionDetailResponse)
|
|
def lock_version_temporarily(
|
|
campaign_id: str,
|
|
version_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
try:
|
|
version = lock_campaign_version_temporarily(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
user_id=principal.user.id,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.version_user_locked_temporarily",
|
|
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.post("/{campaign_id}/versions/{version_id}/unlock-user-lock", response_model=CampaignVersionDetailResponse)
|
|
def unlock_version_user_lock(
|
|
campaign_id: str,
|
|
version_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
try:
|
|
version = unlock_user_locked_campaign_version(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.version_user_lock_removed",
|
|
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.post("/{campaign_id}/versions/{version_id}/lock-permanently", response_model=CampaignVersionDetailResponse)
|
|
def lock_version_permanently(
|
|
campaign_id: str,
|
|
version_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
try:
|
|
version = permanently_lock_campaign_version(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
user_id=principal.user.id,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.version_user_locked_permanently",
|
|
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("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
|
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
|
_require_permission(principal, "campaigns:recipient:write")
|
|
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
|
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("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
|
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
|
_require_permission(principal, "campaigns:recipient:write")
|
|
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
|
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("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
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}/review-state", response_model=CampaignVersionDetailResponse)
|
|
def set_version_review_state(
|
|
campaign_id: str,
|
|
version_id: str,
|
|
payload: CampaignReviewStateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
try:
|
|
version = update_campaign_review_state(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
inspection_complete=payload.inspection_complete,
|
|
reviewed_message_keys=payload.reviewed_message_keys,
|
|
user_id=principal.user.id,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.message_review_updated",
|
|
object_type="campaign_version",
|
|
object_id=version.id,
|
|
details={
|
|
"campaign_id": campaign_id,
|
|
"inspection_complete": payload.inspection_complete,
|
|
"reviewed_message_count": len(payload.reviewed_message_keys),
|
|
},
|
|
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_422_UNPROCESSABLE_ENTITY, 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("campaigns:campaign:validate")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
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("campaigns:campaign:update")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
try:
|
|
version = publish_campaign_version(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
user_id=principal.user.id,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.version_user_locked_permanently",
|
|
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.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("campaigns:campaign:validate")),
|
|
):
|
|
_get_version_for_principal(session, version_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
try:
|
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
|
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
|
if is_user_locked_version(version) or is_version_final_locked(version):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.",
|
|
)
|
|
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 HTTPException:
|
|
raise
|
|
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("campaigns:campaign:build")),
|
|
):
|
|
version = _get_version_for_principal(session, version_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
|
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
|
|
|
|
|
|
def _job_review_key(job: CampaignJob) -> str:
|
|
return str(job.entry_id or job.entry_index)
|
|
|
|
|
|
def _job_summary_payload(
|
|
job: CampaignJob,
|
|
*,
|
|
reviewed_keys: set[str] | None = None,
|
|
) -> dict[str, object]:
|
|
review_key = _job_review_key(job)
|
|
return {
|
|
"id": job.id,
|
|
"campaign_version_id": job.campaign_version_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_size_bytes": job.eml_size_bytes,
|
|
"eml_sha256": job.eml_sha256,
|
|
"attempt_count": job.attempt_count,
|
|
"last_error": job.last_error,
|
|
"queued_at": job.queued_at,
|
|
"claimed_at": job.claimed_at,
|
|
"smtp_started_at": job.smtp_started_at,
|
|
"outcome_unknown_at": job.outcome_unknown_at,
|
|
"sent_at": job.sent_at,
|
|
"issues_count": len(job.issues_snapshot or []),
|
|
"attachment_count": len(job.resolved_attachments or []),
|
|
"review_key": review_key,
|
|
"reviewed": review_key in reviewed_keys if reviewed_keys is not None else False,
|
|
"matched_file_count": sum(
|
|
len(item.get("matches") or [])
|
|
for item in (job.resolved_attachments or [])
|
|
if isinstance(item, dict)
|
|
),
|
|
}
|
|
|
|
|
|
def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
|
|
return {
|
|
**_job_summary_payload(job),
|
|
"message_id_header": job.message_id_header,
|
|
"eml_local_path": job.eml_local_path,
|
|
"eml_storage_key": job.eml_storage_key,
|
|
"issues": job.issues_snapshot or [],
|
|
"attachments": job.resolved_attachments or [],
|
|
"resolved_recipients": job.resolved_recipients or {},
|
|
}
|
|
|
|
|
|
def _review_metadata(
|
|
session: Session,
|
|
version: CampaignVersion | None,
|
|
base_filters: list[object],
|
|
) -> tuple[dict[str, object], set[str]]:
|
|
if version is None:
|
|
return {
|
|
"inspection_complete": False,
|
|
"blocking_count": 0,
|
|
"required_count": 0,
|
|
"reviewed_required_count": 0,
|
|
"bulk_acceptable_count": 0,
|
|
}, set()
|
|
|
|
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
|
|
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "")
|
|
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
|
review_state = editor_state.get("review_send") if isinstance(editor_state.get("review_send"), dict) else {}
|
|
state_token = str(review_state.get("build_token") or "")
|
|
state_is_current = bool(build_token and state_token == build_token)
|
|
reviewed_keys = {
|
|
str(value)
|
|
for value in (review_state.get("reviewed_message_keys") or [])
|
|
if state_is_current and str(value).strip()
|
|
}
|
|
|
|
review_rows = (
|
|
session.query(
|
|
CampaignJob.entry_id,
|
|
CampaignJob.entry_index,
|
|
CampaignJob.build_status,
|
|
CampaignJob.validation_status,
|
|
)
|
|
.filter(*base_filters)
|
|
.all()
|
|
)
|
|
blocking_count = 0
|
|
required_count = 0
|
|
reviewed_required_count = 0
|
|
bulk_acceptable_count = 0
|
|
for entry_id, entry_index, build_status, validation_status in review_rows:
|
|
key = str(entry_id or entry_index)
|
|
if build_status != "built" or validation_status == "blocked":
|
|
blocking_count += 1
|
|
if validation_status == "needs_review":
|
|
required_count += 1
|
|
if key in reviewed_keys:
|
|
reviewed_required_count += 1
|
|
elif validation_status in {"warning", "excluded"}:
|
|
bulk_acceptable_count += 1
|
|
|
|
return {
|
|
"inspection_complete": bool(state_is_current and review_state.get("inspection_complete") is True),
|
|
"blocking_count": blocking_count,
|
|
"required_count": required_count,
|
|
"reviewed_required_count": reviewed_required_count,
|
|
"bulk_acceptable_count": bulk_acceptable_count,
|
|
}, reviewed_keys
|
|
|
|
|
|
def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]:
|
|
result: dict[str, dict[str, int]] = {}
|
|
for field_name in ("build_status", "validation_status", "queue_status", "send_status", "imap_status"):
|
|
column = getattr(CampaignJob, field_name)
|
|
rows = session.query(column, func.count(CampaignJob.id)).filter(*filters).group_by(column).all()
|
|
result[field_name.removesuffix("_status")] = {str(value or "unknown"): int(count) for value, count in rows}
|
|
return result
|
|
|
|
|
|
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
|
def list_jobs(
|
|
campaign_id: str,
|
|
version_id: str | None = None,
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=50, ge=1, le=200),
|
|
send_status: list[str] | None = Query(default=None),
|
|
validation_status: list[str] | None = Query(default=None),
|
|
imap_status: list[str] | None = Query(default=None),
|
|
query_text: str | None = Query(default=None, alias="q", max_length=200),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
"""Return a lightweight, paginated job list with server-side filters.
|
|
|
|
Complete recipients, attachment metadata, issues and attempt history are
|
|
available from the separate job-detail endpoint.
|
|
"""
|
|
|
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
|
base_filters: list[object] = [CampaignJob.campaign_id == campaign.id, CampaignJob.tenant_id == principal.tenant_id]
|
|
selected_version: CampaignVersion | None = None
|
|
if version_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")
|
|
selected_version = version
|
|
base_filters.append(CampaignJob.campaign_version_id == version.id)
|
|
|
|
review_metadata, reviewed_keys = _review_metadata(session, selected_version, base_filters)
|
|
filtered = list(base_filters)
|
|
if send_status:
|
|
filtered.append(CampaignJob.send_status.in_(send_status))
|
|
if validation_status:
|
|
filtered.append(CampaignJob.validation_status.in_(validation_status))
|
|
if imap_status:
|
|
filtered.append(CampaignJob.imap_status.in_(imap_status))
|
|
if query_text and query_text.strip():
|
|
pattern = f"%{query_text.strip()}%"
|
|
filtered.append(or_(
|
|
CampaignJob.recipient_email.ilike(pattern),
|
|
CampaignJob.subject.ilike(pattern),
|
|
CampaignJob.entry_id.ilike(pattern),
|
|
))
|
|
|
|
total_unfiltered = int(session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0)
|
|
total = int(session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0)
|
|
pages = (total + page_size - 1) // page_size if total else 0
|
|
jobs = (
|
|
session.query(CampaignJob)
|
|
.filter(*filtered)
|
|
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
return CampaignJobsResponse(
|
|
jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs],
|
|
page=page,
|
|
page_size=page_size,
|
|
total=total,
|
|
total_unfiltered=total_unfiltered,
|
|
pages=pages,
|
|
counts=_status_counts(session, base_filters),
|
|
filtered_counts=_status_counts(session, filtered),
|
|
review=review_metadata,
|
|
)
|
|
|
|
|
|
@router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse)
|
|
def get_job_detail(
|
|
campaign_id: str,
|
|
job_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
|
job = session.get(CampaignJob, job_id)
|
|
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
|
|
send_attempts = (
|
|
session.query(SendAttempt)
|
|
.filter(SendAttempt.job_id == job.id)
|
|
.order_by(SendAttempt.attempt_number.asc())
|
|
.all()
|
|
)
|
|
imap_attempts = (
|
|
session.query(ImapAppendAttempt)
|
|
.filter(ImapAppendAttempt.job_id == job.id)
|
|
.order_by(ImapAppendAttempt.attempt_number.asc())
|
|
.all()
|
|
)
|
|
return CampaignJobDetailResponse(
|
|
job=_job_detail_payload(job),
|
|
attempts={
|
|
"smtp": [
|
|
{
|
|
"id": attempt.id,
|
|
"attempt_number": attempt.attempt_number,
|
|
"status": attempt.status,
|
|
"claim_token": attempt.claim_token,
|
|
"smtp_status_code": attempt.smtp_status_code,
|
|
"smtp_response": attempt.smtp_response,
|
|
"error_type": attempt.error_type,
|
|
"error_message": attempt.error_message,
|
|
"started_at": attempt.started_at,
|
|
"finished_at": attempt.finished_at,
|
|
}
|
|
for attempt in send_attempts
|
|
],
|
|
"imap": [
|
|
{
|
|
"id": attempt.id,
|
|
"attempt_number": attempt.attempt_number,
|
|
"status": attempt.status,
|
|
"folder": attempt.folder,
|
|
"error_message": attempt.error_message,
|
|
"created_at": attempt.created_at,
|
|
"updated_at": attempt.updated_at,
|
|
}
|
|
for attempt in imap_attempts
|
|
],
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/{campaign_id}/summary")
|
|
def campaign_summary(
|
|
campaign_id: str,
|
|
version_id: str | None = None,
|
|
include_jobs: bool = False,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
if include_jobs:
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
"""Return dashboard-friendly campaign status counters and summaries."""
|
|
|
|
try:
|
|
return generate_campaign_report(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_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,
|
|
version_id: str | None = None,
|
|
include_jobs: bool = False,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
if include_jobs:
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
"""Return the full JSON report for one campaign."""
|
|
|
|
try:
|
|
return generate_campaign_report(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_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,
|
|
version_id: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:recipient:export")
|
|
"""Export per-job campaign status as CSV."""
|
|
|
|
try:
|
|
csv_text = generate_jobs_csv(
|
|
session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_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("campaigns:report:send")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
if payload.include_jobs:
|
|
_require_permission(principal, "campaigns:recipient:export")
|
|
"""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,
|
|
version_id=payload.version_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
|
|
|
|
|
|
|
|
|
|
@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse)
|
|
def list_campaign_share_targets(
|
|
campaign_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
directory = _access_directory()
|
|
users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"]
|
|
groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"]
|
|
return CampaignShareTargetsResponse(
|
|
users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users],
|
|
groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups],
|
|
)
|
|
|
|
|
|
@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse)
|
|
def list_campaign_shares(
|
|
campaign_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
shares = (
|
|
session.query(CampaignShare)
|
|
.filter(CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None))
|
|
.order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc())
|
|
.all()
|
|
)
|
|
return CampaignShareListResponse(shares=[CampaignShareItem.model_validate(item) for item in shares])
|
|
|
|
|
|
@router.put("/{campaign_id}/owner", response_model=CampaignResponse)
|
|
def update_campaign_owner(
|
|
campaign_id: str,
|
|
payload: CampaignOwnerUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
if payload.owner_user_id and payload.owner_group_id:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Choose either a user owner or a group owner, not both")
|
|
directory = _access_directory()
|
|
if payload.owner_user_id:
|
|
owner = directory.get_user(payload.owner_user_id)
|
|
if owner is not None and (owner.tenant_id != principal.tenant_id or owner.status != "active"):
|
|
owner = None
|
|
if owner is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found")
|
|
if payload.owner_group_id:
|
|
group = directory.get_group(payload.owner_group_id)
|
|
if group is not None and (group.tenant_id != principal.tenant_id or group.status != "active"):
|
|
group = None
|
|
if group is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found")
|
|
campaign.owner_user_id = payload.owner_user_id
|
|
campaign.owner_group_id = payload.owner_group_id
|
|
session.add(campaign)
|
|
audit_from_principal(session, principal, action="campaign.owner_updated", object_type="campaign", object_id=campaign.id, details=payload.model_dump(), commit=True)
|
|
return CampaignResponse.model_validate(campaign)
|
|
|
|
|
|
@router.post("/{campaign_id}/shares", response_model=CampaignShareItem, status_code=status.HTTP_201_CREATED)
|
|
def upsert_campaign_share(
|
|
campaign_id: str,
|
|
payload: CampaignShareUpsertRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
directory = _access_directory()
|
|
if payload.target_type == "user":
|
|
target = directory.get_user(payload.target_id)
|
|
if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"):
|
|
target = None
|
|
else:
|
|
target = directory.get_group(payload.target_id)
|
|
if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"):
|
|
target = None
|
|
if target is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found")
|
|
share = (
|
|
session.query(CampaignShare)
|
|
.filter(CampaignShare.campaign_id == campaign.id, CampaignShare.target_type == payload.target_type, CampaignShare.target_id == payload.target_id)
|
|
.one_or_none()
|
|
)
|
|
if share is None:
|
|
share = CampaignShare(
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign.id,
|
|
target_type=payload.target_type,
|
|
target_id=payload.target_id,
|
|
permission=payload.permission,
|
|
created_by_user_id=principal.user.id,
|
|
)
|
|
else:
|
|
share.permission = payload.permission
|
|
share.revoked_at = None
|
|
session.add(share)
|
|
audit_from_principal(session, principal, action="campaign.share_upserted", object_type="campaign", object_id=campaign.id, details=payload.model_dump(), commit=True)
|
|
return CampaignShareItem.model_validate(share)
|
|
|
|
|
|
@router.delete("/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def revoke_campaign_share(
|
|
campaign_id: str,
|
|
share_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
|
):
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
share = session.query(CampaignShare).filter(CampaignShare.id == share_id, CampaignShare.campaign_id == campaign.id, CampaignShare.tenant_id == principal.tenant_id).one_or_none()
|
|
if share is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found")
|
|
share.revoked_at = utc_now()
|
|
session.add(share)
|
|
audit_from_principal(session, principal, action="campaign.share_revoked", object_type="campaign", object_id=campaign.id, details={"share_id": share_id}, commit=True)
|
|
return None
|
|
|
|
# Queue / delivery control -------------------------------------------------
|
|
from govoplan_campaign.backend.schemas import (
|
|
AppendSentRequest,
|
|
CampaignActionResponse,
|
|
QueueCampaignRequest,
|
|
QueueCampaignResponse,
|
|
SendCampaignNowRequest,
|
|
SendCampaignNowResponse,
|
|
MockCampaignSendRequest,
|
|
MockCampaignSendResponse,
|
|
)
|
|
from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send
|
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError
|
|
from govoplan_campaign.backend.sending.jobs import (
|
|
QueueingError,
|
|
cancel_campaign_jobs,
|
|
enqueue_pending_imap_appends,
|
|
pause_campaign_jobs,
|
|
queue_campaign_jobs,
|
|
queue_failed_jobs_for_retry,
|
|
queue_unattempted_jobs,
|
|
reconcile_job_outcome,
|
|
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("campaigns:campaign:queue")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
payload = payload or QueueCampaignRequest()
|
|
_require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id)
|
|
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}/jobs/retry", response_model=CampaignActionResponse)
|
|
def retry_campaign_jobs(
|
|
campaign_id: str,
|
|
payload: CampaignRetryJobsRequest | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:retry")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
payload = payload or CampaignRetryJobsRequest()
|
|
_require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id)
|
|
try:
|
|
result = queue_failed_jobs_for_retry(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=payload.version_id,
|
|
job_ids=payload.job_ids or None,
|
|
include_permanent=payload.include_permanent,
|
|
force_max_attempts=payload.force_max_attempts,
|
|
enqueue_celery=payload.enqueue_celery,
|
|
dry_run=payload.dry_run,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.jobs_retry_queued" if not payload.dry_run else "campaign.jobs_retry_dry_run",
|
|
object_type="campaign",
|
|
object_id=campaign_id,
|
|
details=result,
|
|
commit=True,
|
|
)
|
|
return CampaignActionResponse(result=result)
|
|
except (QueueingError, ExecutionSnapshotError) as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse)
|
|
def send_unattempted_campaign_jobs(
|
|
campaign_id: str,
|
|
payload: CampaignSendUnattemptedRequest | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
payload = payload or CampaignSendUnattemptedRequest()
|
|
_require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id)
|
|
try:
|
|
result = queue_unattempted_jobs(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=payload.version_id,
|
|
job_ids=payload.job_ids or None,
|
|
enqueue_celery=payload.enqueue_celery,
|
|
dry_run=payload.dry_run,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.unattempted_jobs_queued" if not payload.dry_run else "campaign.unattempted_jobs_dry_run",
|
|
object_type="campaign",
|
|
object_id=campaign_id,
|
|
details=result,
|
|
commit=True,
|
|
)
|
|
return CampaignActionResponse(result=result)
|
|
except (QueueingError, ExecutionSnapshotError) as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/{campaign_id}/jobs/{job_id}/resolve-outcome", response_model=CampaignActionResponse)
|
|
def resolve_campaign_job_outcome(
|
|
campaign_id: str,
|
|
job_id: str,
|
|
payload: CampaignResolveOutcomeRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
try:
|
|
result = reconcile_job_outcome(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
job_id=job_id,
|
|
decision=payload.decision,
|
|
note=payload.note,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.job_outcome_reconciled",
|
|
object_type="campaign_job",
|
|
object_id=job_id,
|
|
details=result,
|
|
commit=True,
|
|
)
|
|
return CampaignActionResponse(result=result)
|
|
except (QueueingError, ExecutionSnapshotError) 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("campaigns:campaign:send_test")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
"""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()
|
|
_require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id)
|
|
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("campaigns:campaign:send")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
_require_permission(principal, "campaigns:recipient:read")
|
|
"""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)
|
|
_require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {})
|
|
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("campaigns:campaign:control")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
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("campaigns:campaign:control")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
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("campaigns:campaign:control")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
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("campaigns:campaign:send")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
|
payload = payload or AppendSentRequest()
|
|
_require_campaign_profile_use_if_needed(session, principal, campaign_id)
|
|
try:
|
|
result = enqueue_pending_imap_appends(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
enqueue_celery=payload.enqueue_celery,
|
|
run_inline=payload.run_inline,
|
|
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
|
|
campaign_json: dict[str, object] | None = None
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _file_preview(session: Session, asset) -> dict[str, object]:
|
|
version, blob = files_integration().current_version_and_blob(session, asset)
|
|
return {
|
|
"id": asset.id,
|
|
"version_id": version.id,
|
|
"blob_id": blob.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,
|
|
"checksum_sha256": blob.checksum_sha256,
|
|
"size_bytes": blob.size_bytes,
|
|
"content_type": blob.content_type,
|
|
}
|
|
|
|
|
|
@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("files:file:read")),
|
|
):
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:recipient: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")
|
|
|
|
payload = payload or CampaignAttachmentPreviewRequest()
|
|
raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json
|
|
raw = raw if isinstance(raw, dict) else {}
|
|
_require_mail_profile_use_if_needed(principal, raw)
|
|
|
|
files = files_integration()
|
|
with files.prepared_campaign_snapshot(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign.id,
|
|
raw_json=raw,
|
|
include_bytes=False,
|
|
prefix="multimailer-managed-preview-",
|
|
) as prepared:
|
|
prepared_raw = load_campaign_json(prepared.path)
|
|
config = load_campaign_config_from_json(session, tenant_id=principal.tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
|
|
report = resolve_campaign_attachments(config, campaign_file=prepared.path)
|
|
rules: list[dict[str, object]] = []
|
|
matched_asset_ids: set[str] = set()
|
|
|
|
for entry in report.entries:
|
|
for attachment in entry.attachments:
|
|
managed_matches = files.managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path)
|
|
matched_asset_ids.update(str(item["asset_id"]) for item in managed_matches)
|
|
matches: list[dict[str, object]] = [
|
|
{
|
|
"id": item["asset_id"],
|
|
"version_id": item["version_id"],
|
|
"blob_id": item["blob_id"],
|
|
"display_path": item["display_path"],
|
|
"filename": item["filename"],
|
|
"owner_type": item["owner_type"],
|
|
"owner_id": item["owner_id"],
|
|
"checksum_sha256": item["checksum_sha256"],
|
|
"size_bytes": item["size_bytes"],
|
|
"content_type": item["content_type"],
|
|
}
|
|
for item in managed_matches
|
|
]
|
|
if not matches:
|
|
matches = [
|
|
{
|
|
"id": "",
|
|
"display_path": match,
|
|
"filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1],
|
|
"owner_type": "legacy",
|
|
"owner_id": "",
|
|
}
|
|
for match in attachment.matches
|
|
]
|
|
rules.append({
|
|
"source": attachment.scope.value,
|
|
"entry_index": entry.entry_index,
|
|
"entry_id": entry.entry_id,
|
|
"index": attachment.index,
|
|
"attachment_id": attachment.attachment_id,
|
|
"label": attachment.label,
|
|
"required": attachment.required,
|
|
"pattern": attachment.file_filter,
|
|
"base_path_name": attachment.base_path_name,
|
|
"base_path": attachment.base_path,
|
|
"status": attachment.status.value,
|
|
"behavior": attachment.behavior.value if attachment.behavior else None,
|
|
"zip_included": attachment.zip_enabled,
|
|
"zip_mode": attachment.zip_mode.value,
|
|
"zip_archive_id": attachment.zip_archive_id,
|
|
"zip_filename": attachment.zip_filename,
|
|
"matches": matches,
|
|
"match_count": len(matches),
|
|
"issues": [issue.model_dump(mode="json") for issue in attachment.issues],
|
|
})
|
|
|
|
unused = [asset for asset in prepared.shared_assets if asset.id not in matched_asset_ids]
|
|
return CampaignAttachmentPreviewResponse(
|
|
campaign_id=campaign.id,
|
|
version_id=version.id,
|
|
shared_file_count=len(prepared.shared_assets),
|
|
rules=rules,
|
|
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
|
|
)
|