from __future__ import annotations import copy import dataclasses import re from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session from govoplan_campaign.backend.schemas import ( CampaignDeltaResponse, CampaignCreateRequest, CampaignUpdateRequest, CampaignCreateResponse, CampaignCreateMinimalRequest, CampaignCopyRequest, CampaignContentLibrarySaveRequest, CampaignLifecycleMutationRequest, CampaignLifecyclePolicyResponse, CampaignAddressLookupCandidate, CampaignAddressLookupResponse, CampaignCalendarCatalogResponse, CampaignPostboxCatalogResponse, CampaignRecipientAddressSource, CampaignRecipientAddressSourcesResponse, CampaignRecipientAddressSourceSnapshotRequest, CampaignRecipientAddressSourceSnapshotResponse, CampaignRecipientSnapshotExcludedItem, CampaignRecipientSnapshotItem, CampaignDistributionListExpansionRequest, CampaignDistributionListExpansionResponse, CampaignDistributionListSource, CampaignDistributionListSourcesResponse, RecipientImportMappingProfileListResponse, RecipientImportMappingProfilePayload, RecipientImportMappingProfileResponse, CampaignListResponse, CampaignResponse, CampaignVersionDetailResponse, CampaignVersionResponse, CampaignWorkspaceDeltaResponse, CampaignWorkspaceResponse, ) from govoplan_core.auth import ApiPrincipal, has_scope, require_scope from govoplan_core.audit.logging import audit_from_principal from govoplan_core.core.templates import ( TemplateContentDraftRequest, TemplateFieldRequirement, TemplateRef, ) from govoplan_core.core.change_sequence import ( decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired, ) from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_campaign.backend.change_tracking import ( CAMPAIGNS_COLLECTION, CAMPAIGNS_MODULE_ID, CAMPAIGN_ATTEMPTS_COLLECTION, CAMPAIGN_ISSUES_COLLECTION, CAMPAIGN_JOBS_COLLECTION, CAMPAIGN_VERSIONS_COLLECTION, ) from govoplan_campaign.backend.db.models import ( Campaign, CampaignShare, CampaignVersion, RecipientImportMappingProfile, ) from govoplan_campaign.backend.campaign.postbox_targets import ( delivery_catalog_payload, ) from govoplan_campaign.backend.campaign.lifecycle import ( assert_lifecycle_state_token, campaign_lifecycle_policy, ) from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration from govoplan_campaign.backend.integrations import ( calendar_integration, PostboxDeliveryUnavailable, postbox_integration, templates_integration, ) from govoplan_campaign.backend.template_rendering import find_unresolved_placeholders from govoplan_core.db.session import get_session from govoplan_core.core.distribution_lists import ( CAPABILITY_DISTRIBUTION_LIST_EXPAND, CAPABILITY_DISTRIBUTION_LIST_SOURCE, DistributionExpansionRequest, ) from govoplan_campaign.backend.reports.campaigns import ( CampaignReportError, generate_campaign_report, ) from govoplan_campaign.backend.report_privacy_policy import ( CampaignReportPrivacyPolicyError, ) from govoplan_campaign.backend.reports.aggregate import ( AggregateCampaignReport, AggregateCampaignReportError, AggregateReportCampaignList, aggregate_report_campaign_item, generate_aggregate_campaign_report, ) from govoplan_campaign.backend.persistence.campaigns import ( CampaignPersistenceError, create_campaign_version_from_json, ) from govoplan_campaign.backend.persistence.versions import ( create_minimal_campaign, ) from govoplan_campaign.backend.route_support import ( _apply_recipient_import_profile_payload, _campaign_query_for_principal, _campaign_response_context, _capability_payload, _get_campaign_for_principal, _get_recipient_import_profile_for_principal, _principal_group_ids, _registry_capability, _require_mail_profile_use_if_needed, _require_permission, _sync_campaign_metadata_to_current_version, _write_current_version_snapshot_if_available, bounded_query_rows as _bounded_query_rows, ) from govoplan_campaign.backend.archive_encryption import ( effective_archive_encryption_policy, ) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) @router.get("/{campaign_id}/archive-encryption-policy") def campaign_archive_encryption_policy( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): campaign = _get_campaign_for_principal(session, campaign_id, principal) return effective_archive_encryption_policy(session, campaign).to_dict() CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" def _lifecycle_policy_for_mutation( session: Session, *, campaign_id: str, principal: ApiPrincipal, expected_state_token: str, action: str, version_id: str | None = None, ) -> tuple[Campaign, dict[str, object]]: _get_campaign_for_principal(session, campaign_id, principal, write=True) campaign = ( session.query(Campaign) .filter( Campaign.id == campaign_id, Campaign.tenant_id == principal.tenant_id, ) .with_for_update() .one() ) policy = campaign_lifecycle_policy( session, campaign=campaign, principal=principal, version_id=version_id, ) try: assert_lifecycle_state_token( str(policy["state_token"]), expected_state_token, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc decision = policy["actions"][action] # type: ignore[index] if not decision["allowed"]: # type: ignore[index] raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=decision["reason"], # type: ignore[index] ) return campaign, policy def _campaign_copy_external_id( session: Session, *, tenant_id: str, source_external_id: str, requested: str | None, ) -> str: if requested is not None: candidate = requested.strip() exists = ( session.query(Campaign.id) .filter( Campaign.tenant_id == tenant_id, Campaign.external_id == candidate, ) .first() ) if exists is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Campaign ID already exists for this tenant", ) return candidate stem = f"{source_external_id[:240]}-copy" for suffix in ("", *(f"-{number}" for number in range(2, 10_000))): candidate = f"{stem[:255 - len(suffix)]}{suffix}" exists = ( session.query(Campaign.id) .filter( Campaign.tenant_id == tenant_id, Campaign.external_id == candidate, ) .first() ) if exists is None: return candidate raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="No available campaign copy identifier could be generated.", ) def _campaign_copy_configuration( source: dict[str, object], payload: CampaignCopyRequest, ) -> dict[str, object]: return campaign_copy_configuration(source, payload.model_dump()) @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, commit=False, ) 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, ) _write_current_version_snapshot_if_available(version) except HTTPException: session.rollback() raise except Exception as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc return CampaignCreateResponse( campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate( version, context=_campaign_response_context(principal), ), ) @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, commit=False, ) 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, ) _write_current_version_snapshot_if_available(version) return CampaignCreateResponse( campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate( version, context=_campaign_response_context(principal), ), ) except CampaignPersistenceError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc except Exception: session.rollback() raise @router.get("", response_model=CampaignListResponse) def list_campaigns( limit: int = 200, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): campaigns = ( _campaign_query_for_principal(session, principal) .order_by(Campaign.updated_at.desc()) .limit(max(1, min(limit, 500))) .all() ) return CampaignListResponse( campaigns=[CampaignResponse.model_validate(item) for item in campaigns] ) _CAMPAIGN_LIST_DELTA_COLLECTIONS = (CAMPAIGNS_COLLECTION,) _CAMPAIGN_FULL_CURSOR_PREFIX = "full:campaigns:" _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS = ( CAMPAIGNS_COLLECTION, CAMPAIGN_VERSIONS_COLLECTION, CAMPAIGN_JOBS_COLLECTION, CAMPAIGN_ISSUES_COLLECTION, CAMPAIGN_ATTEMPTS_COLLECTION, ) @dataclasses.dataclass(frozen=True, slots=True) class _WorkspaceDeltaState: entries: list[object] relevant_entries: list[object] has_more: bool changed_campaign: bool changed_version_ids: set[str] selected_version_id: str | None changed_current_version: bool summary_invalidated: bool def _campaign_delta_watermark( session: Session, tenant_id: str, collections: tuple[str, ...] ) -> str: return encode_sequence_watermark( max_sequence_id( session, tenant_id=tenant_id, module_id=CAMPAIGNS_MODULE_ID, collections=collections, ) ) def _campaign_entry_matches_principal( session: Session, principal: ApiPrincipal, payload: dict[str, object] ) -> bool: if has_scope(principal, "tenant:*"): return True user_ids = { value for value in ( payload.get("owner_user_id"), payload.get("previous_owner_user_id"), ) if isinstance(value, str) } if principal.user.id in user_ids: return True group_ids = _principal_group_ids(session, principal) owner_group_ids = { value for value in ( payload.get("owner_group_id"), payload.get("previous_owner_group_id"), ) if isinstance(value, str) } if owner_group_ids.intersection(group_ids): return True share_target_type = payload.get("share_target_type") share_target_id = payload.get("share_target_id") if share_target_type == "user" and share_target_id == principal.user.id: return True if ( share_target_type == "group" and isinstance(share_target_id, str) and share_target_id in group_ids ): return True return False def _campaign_full_cursor( *, page: int, snapshot_sequence: int, ) -> str: return f"{_CAMPAIGN_FULL_CURSOR_PREFIX}{int(page)}:{int(snapshot_sequence)}" def _decode_campaign_full_cursor( value: str | None, ) -> tuple[int, int] | None: if not value or not value.startswith(_CAMPAIGN_FULL_CURSOR_PREFIX): return None parts = value[len(_CAMPAIGN_FULL_CURSOR_PREFIX) :].split(":", 1) if len(parts) != 2: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid campaign full snapshot cursor", ) try: page, snapshot_sequence = (int(item) for item in parts) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid campaign full snapshot cursor", ) from exc if page < 1 or snapshot_sequence < 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid campaign full snapshot cursor", ) return page, snapshot_sequence def _campaign_list_full_delta_response( session: Session, principal: ApiPrincipal, *, cursor: tuple[int, int] | None = None, limit: int = 500, ) -> CampaignDeltaResponse: page = cursor[0] if cursor is not None else 1 snapshot_sequence = ( cursor[1] if cursor is not None else decode_sequence_watermark( _campaign_delta_watermark( session, principal.tenant_id, _CAMPAIGN_LIST_DELTA_COLLECTIONS, ), ) ) query = _campaign_query_for_principal(session, principal) total = query.order_by(None).count() pages = max(1, (total + limit - 1) // limit) campaigns = ( query.order_by(Campaign.updated_at.desc(), Campaign.id.asc()) .offset((page - 1) * limit) .limit(limit) .all() ) has_more = page < pages return CampaignDeltaResponse( campaigns=[CampaignResponse.model_validate(item) for item in campaigns], deleted=[], watermark=( _campaign_full_cursor( page=page + 1, snapshot_sequence=snapshot_sequence, ) if has_more else encode_sequence_watermark(snapshot_sequence) ), has_more=has_more, full=True, total=total, page=page, page_size=limit, pages=pages, ) def _campaign_list_delta_response( session: Session, principal: ApiPrincipal, *, since: str, limit: int ) -> CampaignDeltaResponse: try: since_sequence = decode_sequence_watermark(since) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) ) from exc if sequence_watermark_is_expired( session, since=since_sequence, tenant_id=principal.tenant_id, module_id=CAMPAIGNS_MODULE_ID, collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS, ): return _campaign_list_full_delta_response( session, principal, limit=limit, ) entries_plus_one = sequence_entries_since( session, since=since_sequence, tenant_id=principal.tenant_id, module_id=CAMPAIGNS_MODULE_ID, collections=_CAMPAIGN_LIST_DELTA_COLLECTIONS, limit=limit + 1, ) has_more = len(entries_plus_one) > limit entries = entries_plus_one[:limit] changed_ids = list( dict.fromkeys( entry.resource_id for entry in entries if entry.resource_type == "campaign" ) ) visible_campaigns = { campaign.id: campaign for campaign in ( _campaign_query_for_principal(session, principal) .filter(Campaign.id.in_(changed_ids)) .order_by(Campaign.updated_at.desc()) .all() if changed_ids else [] ) } deleted: dict[tuple[str, str], DeltaDeletedItem] = {} for entry in entries: if entry.resource_type != "campaign" or entry.resource_id in visible_campaigns: continue payload = entry.payload or {} if not _campaign_entry_matches_principal(session, principal, payload): continue deleted[(entry.resource_type, entry.resource_id)] = DeltaDeletedItem( id=entry.resource_id, resource_type=entry.resource_type, revision=encode_sequence_watermark(entry.id), deleted_at=entry.created_at if entry.operation == "deleted" else None, ) watermark = ( encode_sequence_watermark(entries[-1].id) if has_more and entries else _campaign_delta_watermark( session, principal.tenant_id, _CAMPAIGN_LIST_DELTA_COLLECTIONS ) ) return CampaignDeltaResponse( campaigns=[ CampaignResponse.model_validate(item) for item in visible_campaigns.values() ], deleted=list(deleted.values()), watermark=watermark, has_more=has_more, full=False, total=len(visible_campaigns), page=1, page_size=limit, pages=1, ) @router.get("/delta", response_model=CampaignDeltaResponse) def list_campaigns_delta( since: str | None = None, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): full_cursor = _decode_campaign_full_cursor(since) if since is None or full_cursor is not None: return _campaign_list_full_delta_response( session, principal, cursor=full_cursor, limit=limit, ) return _campaign_list_delta_response(session, principal, since=since, limit=limit) @router.get( "/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse, ) def list_recipient_import_mapping_profiles( limit: int = Query(default=200, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")), ): profiles = _bounded_query_rows( 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(), ), limit=limit, label="Recipient import mapping profile list", ) 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("/aggregate-reports", response_model=AggregateReportCampaignList) def list_aggregate_campaign_reports( limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")), ): """List only the business metadata needed to select an aggregate report.""" campaigns = _bounded_query_rows( _campaign_query_for_principal(session, principal).order_by( Campaign.updated_at.desc(), Campaign.id.asc() ), limit=limit, label="Aggregate report campaign list", ) return AggregateReportCampaignList( campaigns=[aggregate_report_campaign_item(campaign) for campaign in campaigns] ) @router.get("/aggregate-reports/{campaign_id}", response_model=AggregateCampaignReport) def aggregate_campaign_report( campaign_id: str, version_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")), ): """Return the privacy-safe aggregate projection without recipient detail.""" _get_campaign_for_principal(session, campaign_id, principal) try: return generate_aggregate_campaign_report( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, ) except AggregateCampaignReportError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Campaign report not found", ) from exc except CampaignReportPrivacyPolicyError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Campaign report privacy policy is invalid", ) from exc @router.get( "/{campaign_id}/address-lookup", response_model=CampaignAddressLookupResponse ) def lookup_campaign_addresses( campaign_id: str, query: str = Query(default="", min_length=0), limit: int = Query(default=25, ge=1, le=100), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")), ): _get_campaign_for_principal(session, campaign_id, principal) capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP) if capability is None or not hasattr(capability, "lookup"): return CampaignAddressLookupResponse(available=False, candidates=[]) candidates = getattr(capability, "lookup")( session, principal, query=query, limit=limit ) return CampaignAddressLookupResponse( available=True, candidates=[ CampaignAddressLookupCandidate.model_validate( _capability_payload(candidate) ) for candidate in candidates ], ) @router.get( "/{campaign_id}/recipient-address-sources", response_model=CampaignRecipientAddressSourcesResponse, ) def list_campaign_recipient_address_sources( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")), ): _get_campaign_for_principal(session, campaign_id, principal) capability = _registry_capability(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE) if capability is None or not hasattr(capability, "list_sources"): return CampaignRecipientAddressSourcesResponse(available=False, sources=[]) sources = getattr(capability, "list_sources")(session, principal) return CampaignRecipientAddressSourcesResponse( available=True, sources=[ CampaignRecipientAddressSource.model_validate(_capability_payload(source)) for source in sources ], ) @router.get( "/{campaign_id}/postbox-catalog", response_model=CampaignPostboxCatalogResponse, ) def campaign_postbox_catalog( campaign_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:write")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) integration = postbox_integration() if not integration.available: return CampaignPostboxCatalogResponse(available=False) try: payload = delivery_catalog_payload( integration.delivery_catalog( session, tenant_id=principal.tenant_id, ) ) except PostboxDeliveryUnavailable: return CampaignPostboxCatalogResponse(available=False) return CampaignPostboxCatalogResponse( available=True, postboxes=payload.get("postboxes", []), templates=payload.get("templates", []), organization_units=payload.get("organization_units", []), ) @router.get( "/{campaign_id}/calendar-invitation-catalog", response_model=CampaignCalendarCatalogResponse, ) def campaign_calendar_invitation_catalog( campaign_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) integration = calendar_integration() if not integration.available: return CampaignCalendarCatalogResponse( reason="The Calendar invitation capability is not active." ) if not any( principal.has(scope) for scope in ( "calendar:calendar:read", "calendar:calendar:write", "calendar:calendar:admin", ) ): return CampaignCalendarCatalogResponse( reason="You are not permitted to select a Calendar collection." ) calendars = integration.list_calendars( session, tenant_id=principal.tenant_id, user_id=str(getattr(principal.user, "id", "")) or None, group_ids=tuple(principal.group_ids), can_admin=principal.has("calendar:calendar:admin"), ) return CampaignCalendarCatalogResponse( available=True, calendars=[ { "id": item.id, "name": item.name, "color": item.color, "timezone": item.timezone, "source_kind": item.source_kind, "writable": item.writable, } for item in calendars ], ) @router.get("/{campaign_id}/print-templates") def campaign_print_templates( campaign_id: str, query: str = Query(default="", min_length=0, max_length=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): _get_campaign_for_principal(session, campaign_id, principal) integration = templates_integration() if not integration.available: return {"available": False, "templates": []} templates = integration.list_templates( session, principal, query=query, limit=250, ) return { "available": True, "templates": [dataclasses.asdict(template) for template in templates], } def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]: revision = template.revision revision_metadata = dict(revision.metadata) if revision else {} raw_targets = revision_metadata.get("campaign_targets") targets = ( [str(value) for value in raw_targets if str(value) in {"subject", "text", "html"}] if isinstance(raw_targets, (list, tuple)) else [] ) if not targets and revision is not None: if template.template_type == "email": targets = ["subject", "text", "html"] else: if revision.content_text: targets.append("text") if revision.content_html: targets.append("html") kind = str(revision_metadata.get("campaign_kind") or "").strip() if kind not in {"fragment", "campaign_part"}: kind = "fragment" if template.template_type == "content_fragment" else "campaign_part" return { "id": template.id, "name": template.name, "description": template.description, "template_type": template.template_type, "kind": kind, "status": template.status, "scope_type": template.scope_type, "scope_id": template.scope_id, "read_only": template.read_only, "current_revision": template.current_revision, "revision": revision.revision if revision else template.current_revision, "revision_id": revision.id if revision else template.current_revision_id, "locale": revision.locale if revision else None, "published": bool(template.published_revision_id), "targets": list(dict.fromkeys(targets)), "subject": revision_metadata.get("campaign_subject"), "text": revision.content_text if revision else None, "html": revision.content_html if revision else None, "body_mode": revision_metadata.get("campaign_body_mode") or "both", "required_fields": [ dataclasses.asdict(field) for field in revision.required_fields ] if revision else [], } def _campaign_content_required_fields( payload: CampaignContentLibrarySaveRequest, ) -> tuple[TemplateFieldRequirement, ...]: if payload.kind == "fragment": values = ( payload.subject if payload.target == "subject" else payload.html if payload.target == "html" else payload.text, ) else: values = ( payload.subject, payload.text if payload.body_mode != "html" else None, payload.html if payload.body_mode != "text" else None, ) paths = { path for value in values for key in find_unresolved_placeholders(value) if (path := _campaign_content_requirement_path(key)) is not None } return tuple( TemplateFieldRequirement(path=path, label=path) for path in sorted(paths) ) def _campaign_content_requirement_path(key: str) -> str | None: path = key.replace("local::", "local.", 1).replace("global::", "global.", 1) path = re.sub(r"\[([1-9][0-9]*)\]", r".\1", path) return path if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", path) else None @router.get("/{campaign_id}/content-library") def campaign_content_library( campaign_id: str, query: str = Query(default="", min_length=0, max_length=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): _get_campaign_for_principal(session, campaign_id, principal) integration = templates_integration() if not integration.content_available: return { "available": False, "writable": False, "reason": "The Templates content library is not active.", "items": [], } try: templates = integration.list_content_templates( session, principal, query=query, limit=250, ) except PermissionError as exc: return { "available": True, "writable": False, "reason": str(exc), "items": [], } return { "available": True, "writable": integration.content_writable, "items": [_campaign_content_library_item(template) for template in templates], } @router.post("/{campaign_id}/content-library", status_code=status.HTTP_201_CREATED) def save_campaign_content_library_item( campaign_id: str, payload: CampaignContentLibrarySaveRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): campaign = _get_campaign_for_principal( session, campaign_id, principal, write=True, ) integration = templates_integration() if not integration.content_writable: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="The Templates content-library capability is not active.", ) target = payload.target if payload.kind == "fragment" else None content_text = ( payload.subject if target == "subject" else payload.text if target == "text" else None ) content_html = payload.html if target == "html" else None if payload.kind == "campaign_part": content_text = payload.text content_html = payload.html metadata: dict[str, object] = { "campaign_kind": payload.kind, "campaign_targets": ( [target] if target else [ field for field, value in ( ("subject", payload.subject), ("text", payload.text), ("html", payload.html), ) if value and value.strip() ] ), "campaign_body_mode": payload.body_mode, "source_module": "campaigns", "source_campaign_id": campaign.id, } if payload.kind == "campaign_part" and payload.subject: metadata["campaign_subject"] = payload.subject try: template = integration.create_content_draft( session, principal, request=TemplateContentDraftRequest( name=payload.name, description=payload.description, template_type=( "content_fragment" if payload.kind == "fragment" else "email" ), usages=("campaign.content",), content_text=content_text, content_html=content_html, locale=payload.locale, required_fields=_campaign_content_required_fields(payload), scope_type=("user" if payload.visibility == "personal" else "tenant"), scope_id=( principal.account_id if payload.visibility == "personal" else None ), metadata=metadata, ), ) audit_from_principal( session, principal, action="campaign.content_library_saved", object_type="campaign", object_id=campaign.id, details={ "template_id": template.id, "template_revision_id": ( template.revision.id if template.revision else None ), "kind": payload.kind, "target": payload.target, "visibility": payload.visibility, }, commit=False, ) session.commit() except PermissionError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(exc), ) from exc except ValueError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc return {"template": _campaign_content_library_item(template)} @router.post( "/{campaign_id}/recipient-address-sources/snapshot", response_model=CampaignRecipientAddressSourceSnapshotResponse, ) def snapshot_campaign_recipient_address_source( campaign_id: str, payload: CampaignRecipientAddressSourceSnapshotRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) capability = _registry_capability(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE) if capability is None or not hasattr(capability, "snapshot"): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Address recipient-source capability is not available", ) try: snapshot = getattr(capability, "snapshot")( session, principal, source_id=payload.source_id, purpose=payload.purpose, requested_channels=("email",), ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) ) from exc snapshot_payload = _capability_payload(snapshot) recipients = [ CampaignRecipientSnapshotItem.model_validate(_capability_payload(item)) for item in snapshot_payload.get("recipients", []) ] excluded = [ CampaignRecipientSnapshotExcludedItem.model_validate(_capability_payload(item)) for item in snapshot_payload.get("excluded", []) ] provenance = ( snapshot_payload.get("provenance") if isinstance(snapshot_payload.get("provenance"), dict) else {} ) return CampaignRecipientAddressSourceSnapshotResponse( source_id=str(snapshot_payload.get("source_id") or ""), source_label=str(snapshot_payload.get("source_label") or ""), source_kind=str(snapshot_payload.get("source_kind") or ""), source_revision=str(snapshot_payload.get("source_revision") or ""), generated_at=str(snapshot_payload.get("generated_at") or ""), recipients=recipients, excluded=excluded, included_count=len(recipients), excluded_count=len(excluded), purpose=str(snapshot_payload.get("purpose") or payload.purpose), provenance={ **provenance, "campaign_id": campaign_id, "purpose": payload.purpose, "requested_channels": ["email"], }, ) @router.get( "/{campaign_id}/recipient-distribution-lists", response_model=CampaignDistributionListSourcesResponse, ) def list_campaign_recipient_distribution_lists( campaign_id: str, query: str = Query(default="", min_length=0, max_length=200), limit: int = Query(default=100, ge=1, le=250), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")), ): _get_campaign_for_principal(session, campaign_id, principal) source_capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_SOURCE) expand_capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_EXPAND) if source_capability is None or not hasattr(source_capability, "list_sources"): return CampaignDistributionListSourcesResponse(available=False) sources = getattr(source_capability, "list_sources")( session, principal, query=query, limit=limit, ) return CampaignDistributionListSourcesResponse( available=True, expand_available=bool( expand_capability is not None and hasattr(expand_capability, "expand") ), sources=[ CampaignDistributionListSource.model_validate( _capability_payload(source) ) for source in sources ], ) @router.post( "/{campaign_id}/recipient-distribution-lists/preview", response_model=CampaignDistributionListExpansionResponse, ) def preview_campaign_recipient_distribution_list( campaign_id: str, payload: CampaignDistributionListExpansionRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")), ): _get_campaign_for_principal(session, campaign_id, principal) return _expand_campaign_distribution_list( session, principal, payload=payload, freeze=False, ) @router.post( "/{campaign_id}/recipient-distribution-lists/snapshot", response_model=CampaignDistributionListExpansionResponse, ) def snapshot_campaign_recipient_distribution_list( campaign_id: str, payload: CampaignDistributionListExpansionRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) if not payload.idempotency_key: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Freezing a distribution list requires an idempotency key", ) result = _expand_campaign_distribution_list( session, principal, payload=payload, freeze=True, ) audit_from_principal( session, principal, action="campaign.distribution_list_snapshot_frozen", object_type="campaign", object_id=campaign_id, details={ "list_id": result.source.id, "list_revision": result.source.revision, "snapshot_id": result.snapshot_id, "expansion_hash": result.expansion_hash, "recipient_count": len(result.recipients), "excluded_count": len(result.excluded), "stale": result.stale, "truncated": result.truncated, }, commit=True, ) return result def _expand_campaign_distribution_list( session: Session, principal: ApiPrincipal, *, payload: CampaignDistributionListExpansionRequest, freeze: bool, ) -> CampaignDistributionListExpansionResponse: capability = _registry_capability(CAPABILITY_DISTRIBUTION_LIST_EXPAND) if capability is None or not hasattr(capability, "expand"): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Distribution-list expansion capability is not available", ) request = DistributionExpansionRequest( list_id=payload.list_id, revision=payload.revision, effective_at=payload.effective_at, purpose=payload.purpose, requested_channels=tuple(payload.requested_channels), parameters=payload.parameters, preview=not freeze, freeze=freeze, idempotency_key=payload.idempotency_key if freeze else None, ) try: result = getattr(capability, "expand")( session, principal, request=request, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc return CampaignDistributionListExpansionResponse.model_validate( _capability_payload(result) ) @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) ) def _campaign_workspace_response( session: Session, principal: ApiPrincipal, *, campaign_id: str, version_id: str | None, include_current_version: bool, include_summary: bool, include_versions: bool, ) -> CampaignWorkspaceResponse: campaign = _get_campaign_for_principal(session, campaign_id, principal) versions: list[CampaignVersion] = [] if include_versions or include_current_version: versions = _bounded_query_rows( session.query(CampaignVersion) .filter(CampaignVersion.campaign_id == campaign.id) .order_by(CampaignVersion.version_number.desc()), limit=1000, label="Campaign version history", ) 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, include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), ) except CampaignReportError: summary_payload = None return CampaignWorkspaceResponse( campaign=CampaignResponse.model_validate(campaign), versions=[ CampaignVersionResponse.model_validate( item, context=_campaign_response_context(principal), ) for item in versions ] if include_versions else [], current_version=( CampaignVersionDetailResponse.model_validate( current_version, context=_campaign_response_context(principal), ) if current_version is not None else None ), summary=summary_payload, selected_version_id=selected_version_id, ) @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")), ): return _campaign_workspace_response( session, principal, campaign_id=campaign_id, version_id=version_id, include_current_version=include_current_version, include_summary=include_summary, include_versions=include_versions, ) def _workspace_delta_deleted_item(entry) -> DeltaDeletedItem: return DeltaDeletedItem( id=entry.resource_id, resource_type=entry.resource_type, revision=encode_sequence_watermark(entry.id), deleted_at=entry.created_at if entry.operation == "deleted" else None, ) def _entry_belongs_to_campaign(entry, campaign_id: str) -> bool: payload = entry.payload or {} if payload.get("campaign_id") == campaign_id: return True return entry.resource_type == "campaign" and entry.resource_id == campaign_id def _campaign_workspace_full_delta_response( session: Session, principal: ApiPrincipal, *, campaign_id: str, version_id: str | None, include_current_version: bool, include_summary: bool, include_versions: bool, ) -> CampaignWorkspaceDeltaResponse: payload = _campaign_workspace_response( session, principal, campaign_id=campaign_id, version_id=version_id, include_current_version=include_current_version, include_summary=include_summary, include_versions=include_versions, ) return CampaignWorkspaceDeltaResponse( **payload.model_dump(), deleted=[], watermark=_campaign_delta_watermark( session, principal.tenant_id, _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS ), has_more=False, full=True, ) def _campaign_workspace_delta_response( session: Session, principal: ApiPrincipal, *, campaign_id: str, version_id: str | None, include_current_version: bool, include_summary: bool, include_versions: bool, since: str, limit: int, ) -> CampaignWorkspaceDeltaResponse: campaign = _get_campaign_for_principal(session, campaign_id, principal) try: since_sequence = decode_sequence_watermark(since) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) ) from exc if sequence_watermark_is_expired( session, since=since_sequence, tenant_id=principal.tenant_id, module_id=CAMPAIGNS_MODULE_ID, collections=_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS, ): return _campaign_workspace_full_delta_response( session, principal, campaign_id=campaign_id, version_id=version_id, include_current_version=include_current_version, include_summary=include_summary, include_versions=include_versions, ) delta = _campaign_workspace_delta_state( session, principal, campaign=campaign, version_id=version_id, since_sequence=since_sequence, limit=limit, ) return CampaignWorkspaceDeltaResponse( campaign=CampaignResponse.model_validate(campaign) if delta.changed_campaign else None, versions=_campaign_workspace_delta_versions( session, principal, campaign, delta, include_versions=include_versions, ), current_version=_campaign_workspace_delta_current_version( session, principal, campaign=campaign, delta=delta, include_current_version=include_current_version, ), summary=_campaign_workspace_delta_summary( session, principal, campaign_id=campaign_id, delta=delta, include_summary=include_summary, ), selected_version_id=delta.selected_version_id, deleted=_campaign_workspace_delta_deleted(delta.relevant_entries), watermark=_campaign_workspace_delta_watermark(session, principal, delta), has_more=delta.has_more, full=False, ) def _campaign_workspace_delta_state( session: Session, principal: ApiPrincipal, *, campaign: Campaign, version_id: str | None, since_sequence: int, limit: int, ) -> _WorkspaceDeltaState: entries_plus_one = sequence_entries_since( session, since=since_sequence, tenant_id=principal.tenant_id, module_id=CAMPAIGNS_MODULE_ID, collections=_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS, limit=limit + 1, ) entries = entries_plus_one[:limit] relevant_entries = [ entry for entry in entries if _entry_belongs_to_campaign(entry, campaign.id) ] changed_version_ids = { entry.resource_id for entry in relevant_entries if entry.resource_type == "campaign_version" and entry.operation != "deleted" } selected_version_id = version_id or campaign.current_version_id return _WorkspaceDeltaState( entries=entries, relevant_entries=relevant_entries, has_more=len(entries_plus_one) > limit, changed_campaign=any( entry.resource_type == "campaign" for entry in relevant_entries ), changed_version_ids=changed_version_ids, selected_version_id=selected_version_id, changed_current_version=bool( selected_version_id and selected_version_id in changed_version_ids ), summary_invalidated=any( entry.collection in { CAMPAIGN_JOBS_COLLECTION, CAMPAIGN_ISSUES_COLLECTION, CAMPAIGN_ATTEMPTS_COLLECTION, } for entry in relevant_entries ), ) def _campaign_workspace_delta_deleted(entries: list[object]) -> list[dict[str, object]]: return [ _workspace_delta_deleted_item(entry) for entry in entries if entry.operation == "deleted" and entry.resource_type != "campaign" ] def _campaign_workspace_delta_versions( session: Session, principal: ApiPrincipal, campaign: Campaign, delta: _WorkspaceDeltaState, *, include_versions: bool, ) -> list[CampaignVersionResponse]: if not include_versions or not delta.changed_version_ids: return [] versions = ( session.query(CampaignVersion) .filter( CampaignVersion.campaign_id == campaign.id, CampaignVersion.id.in_(delta.changed_version_ids), ) .order_by(CampaignVersion.version_number.desc()) .all() ) return [ CampaignVersionResponse.model_validate( item, context=_campaign_response_context(principal), ) for item in versions ] def _campaign_workspace_delta_current_version( session: Session, principal: ApiPrincipal, *, campaign: Campaign, delta: _WorkspaceDeltaState, include_current_version: bool, ) -> CampaignVersionDetailResponse | None: if not include_current_version or not delta.selected_version_id: return None if not (delta.changed_campaign or delta.changed_current_version): return None _require_permission(principal, "campaigns:recipient:read") current_version = ( session.query(CampaignVersion) .filter( CampaignVersion.id == delta.selected_version_id, CampaignVersion.campaign_id == campaign.id, ) .one_or_none() ) return ( CampaignVersionDetailResponse.model_validate( current_version, context=_campaign_response_context(principal), ) if current_version is not None else None ) def _campaign_workspace_delta_summary( session: Session, principal: ApiPrincipal, *, campaign_id: str, delta: _WorkspaceDeltaState, include_summary: bool, ) -> dict[str, object] | None: if not include_summary: return None if not ( delta.changed_campaign or delta.changed_version_ids or delta.summary_invalidated ): return None try: return generate_campaign_report( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=delta.selected_version_id, include_jobs=False, include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), ) except CampaignReportError: return None def _campaign_workspace_delta_watermark( session: Session, principal: ApiPrincipal, delta: _WorkspaceDeltaState, ) -> str: if delta.has_more and delta.entries: return encode_sequence_watermark(delta.entries[-1].id) return _campaign_delta_watermark( session, principal.tenant_id, _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS ) @router.get( "/{campaign_id}/workspace/delta", response_model=CampaignWorkspaceDeltaResponse ) def get_campaign_workspace_delta( campaign_id: str, version_id: str | None = None, include_current_version: bool = True, include_summary: bool = False, include_versions: bool = True, since: str | None = None, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): if since is None: return _campaign_workspace_full_delta_response( session, principal, campaign_id=campaign_id, version_id=version_id, include_current_version=include_current_version, include_summary=include_summary, include_versions=include_versions, ) return _campaign_workspace_delta_response( session, principal, campaign_id=campaign_id, version_id=version_id, include_current_version=include_current_version, include_summary=include_summary, include_versions=include_versions, since=since, limit=limit, ) @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_CONTENT, 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_CONTENT, 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.flush() try: 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, ) except Exception: session.rollback() raise session.refresh(campaign) if campaign.current_version_id: current_version = session.get(CampaignVersion, campaign.current_version_id) if current_version is not None: _write_current_version_snapshot_if_available(current_version) return CampaignResponse.model_validate(campaign) @router.get( "/{campaign_id}/lifecycle-policy", response_model=CampaignLifecyclePolicyResponse, ) def get_campaign_lifecycle_policy( campaign_id: str, version_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): campaign = _get_campaign_for_principal(session, campaign_id, principal) return campaign_lifecycle_policy( session, campaign=campaign, principal=principal, version_id=version_id, ) @router.post("/{campaign_id}/copies", response_model=CampaignCreateResponse) def copy_campaign( campaign_id: str, payload: CampaignCopyRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")), ): source_campaign, _policy = _lifecycle_policy_for_mutation( session, campaign_id=campaign_id, principal=principal, expected_state_token=payload.expected_state_token, action="copy_campaign", version_id=payload.source_version_id, ) if payload.include_recipients: _require_permission(principal, "campaigns:recipient:read") if payload.include_shares: _require_permission(principal, "campaigns:campaign:share") source_version = ( session.query(CampaignVersion) .filter( CampaignVersion.id == payload.source_version_id, CampaignVersion.campaign_id == source_campaign.id, ) .one_or_none() ) if source_version is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found", ) external_id = _campaign_copy_external_id( session, tenant_id=principal.tenant_id, source_external_id=source_campaign.external_id, requested=payload.external_id, ) name = (payload.name or f"{source_campaign.name} (copy)").strip() raw_json = _campaign_copy_configuration(source_version.raw_json, payload) campaign_metadata = raw_json.get("campaign") if not isinstance(campaign_metadata, dict): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="The selected source version has no valid campaign metadata.", ) campaign_metadata["id"] = external_id campaign_metadata["name"] = name campaign_metadata["mode"] = "draft" _require_mail_profile_use_if_needed(principal, raw_json) try: campaign, version = create_campaign_version_from_json( session, tenant_id=principal.tenant_id, user_id=principal.user.id, raw_json=raw_json, source_filename=None, source_base_path=source_version.source_base_path, commit=False, ) if payload.include_policies: campaign.settings = copy.deepcopy(source_campaign.settings or {}) if payload.include_mail_profile: campaign.mail_profile_policy = copy.deepcopy( source_campaign.mail_profile_policy or {} ) copied_share_count = 0 if payload.include_shares: source_shares = ( session.query(CampaignShare) .filter( CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == source_campaign.id, CampaignShare.revoked_at.is_(None), ) .order_by(CampaignShare.id.asc()) .all() ) for source_share in source_shares: session.add( CampaignShare( tenant_id=principal.tenant_id, campaign_id=campaign.id, target_type=source_share.target_type, target_id=source_share.target_id, permission=source_share.permission, created_by_user_id=principal.user.id, ) ) copied_share_count = len(source_shares) audit_from_principal( session, principal, action="campaign.copied", object_type="campaign", object_id=campaign.id, details={ "source_campaign_id": source_campaign.id, "source_version_id": source_version.id, "destination_version_id": version.id, "copied_evidence": False, "copy_options": { "recipients": payload.include_recipients, "files": payload.include_files, "shares": payload.include_shares, "policies": payload.include_policies, "mail_profile": payload.include_mail_profile, }, "copied_share_count": copied_share_count, }, commit=True, ) except HTTPException: session.rollback() raise except Exception: session.rollback() raise session.refresh(campaign) session.refresh(version) return CampaignCreateResponse( campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate( version, context=_campaign_response_context(principal), ), ) @router.post( "/{campaign_id}/versions/{version_id}/archive", response_model=CampaignVersionResponse, ) def archive_campaign_version( campaign_id: str, version_id: str, payload: CampaignLifecycleMutationRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")), ): campaign, _policy = _lifecycle_policy_for_mutation( session, campaign_id=campaign_id, principal=principal, expected_state_token=payload.expected_state_token, action="archive_version", version_id=version_id, ) version = ( session.query(CampaignVersion) .filter( CampaignVersion.id == version_id, CampaignVersion.campaign_id == campaign.id, ) .with_for_update() .one() ) version.archived_at = datetime.now(UTC) version.archived_by_user_id = principal.user.id session.add(version) audit_from_principal( session, principal, action="campaign.version_archived", object_type="campaign_version", object_id=version.id, details={ "campaign_id": campaign.id, "version_number": version.version_number, "retained_evidence": True, }, commit=True, ) session.refresh(version) return CampaignVersionResponse.model_validate( version, context=_campaign_response_context(principal), ) @router.post("/{campaign_id}/archive", response_model=CampaignResponse) def archive_campaign( campaign_id: str, payload: CampaignLifecycleMutationRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")), ): campaign, _policy = _lifecycle_policy_for_mutation( session, campaign_id=campaign_id, principal=principal, expected_state_token=payload.expected_state_token, action="archive_campaign", ) 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, payload: CampaignLifecycleMutationRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")), ): campaign, _policy = _lifecycle_policy_for_mutation( session, campaign_id=campaign_id, principal=principal, expected_state_token=payload.expected_state_token, action="delete_campaign", ) 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)