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 [], )