from __future__ import annotations import dataclasses from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_campaign.backend.schemas import ( CampaignDeltaResponse, CampaignCreateRequest, CampaignUpdateRequest, CampaignCreateResponse, CampaignCreateMinimalRequest, CampaignAddressLookupCandidate, CampaignAddressLookupResponse, CampaignPostboxCatalogResponse, CampaignRecipientAddressSource, CampaignRecipientAddressSourcesResponse, CampaignRecipientAddressSourceSnapshotRequest, CampaignRecipientAddressSourceSnapshotResponse, CampaignRecipientSnapshotItem, 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.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, CampaignJob, CampaignVersion, RecipientImportMappingProfile, ) from govoplan_campaign.backend.campaign.postbox_targets import ( delivery_catalog_payload, ) from govoplan_campaign.backend.integrations import ( PostboxDeliveryUnavailable, postbox_integration, ) from govoplan_core.db.session import get_session 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, ) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" @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.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 ) 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", []) ] 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, provenance=snapshot_payload.get("provenance") if isinstance(snapshot_payload.get("provenance"), dict) else {}, ) @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.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)