From 2199187e8bb1abcd39afd530eb09c0463653ee4a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 29 Jul 2026 20:07:11 +0200 Subject: [PATCH] refactor(api): split campaign workflow routers --- .../backend/route_support.py | 601 +++ src/govoplan_campaign/backend/router.py | 4412 +---------------- .../backend/routes/__init__.py | 1 + .../backend/routes/attachments.py | 398 ++ .../backend/routes/campaigns.py | 1362 +++++ .../backend/routes/delivery.py | 639 +++ src/govoplan_campaign/backend/routes/jobs.py | 436 ++ .../backend/routes/reports.py | 198 + .../backend/routes/sharing.py | 272 + .../backend/routes/versions.py | 667 +++ .../backend/services/job_queries.py | 884 ++++ tests/test_aggregate_report_routes.py | 29 +- tests/test_campaign_audit_atomicity.py | 36 +- tests/test_imap_append_idempotency.py | 10 +- tests/test_job_list_query.py | 2 +- tests/test_mail_profile_boundary.py | 9 +- tests/test_partial_validation.py | 2 +- tests/test_report_email_security.py | 2 +- tests/test_response_security.py | 2 +- tests/test_route_registration.py | 63 + tests/test_send_now_response_security.py | 2 +- tests/test_synchronous_delivery_policy.py | 5 +- 22 files changed, 5595 insertions(+), 4437 deletions(-) create mode 100644 src/govoplan_campaign/backend/route_support.py create mode 100644 src/govoplan_campaign/backend/routes/__init__.py create mode 100644 src/govoplan_campaign/backend/routes/attachments.py create mode 100644 src/govoplan_campaign/backend/routes/campaigns.py create mode 100644 src/govoplan_campaign/backend/routes/delivery.py create mode 100644 src/govoplan_campaign/backend/routes/jobs.py create mode 100644 src/govoplan_campaign/backend/routes/reports.py create mode 100644 src/govoplan_campaign/backend/routes/sharing.py create mode 100644 src/govoplan_campaign/backend/routes/versions.py create mode 100644 src/govoplan_campaign/backend/services/job_queries.py create mode 100644 tests/test_route_registration.py diff --git a/src/govoplan_campaign/backend/route_support.py b/src/govoplan_campaign/backend/route_support.py new file mode 100644 index 0000000..b61048e --- /dev/null +++ b/src/govoplan_campaign/backend/route_support.py @@ -0,0 +1,601 @@ +from __future__ import annotations + +import copy +import dataclasses +from collections.abc import Callable +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import and_, exists, or_ +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.campaign.mail_profile_boundary import ( + CAMPAIGN_MAIL_SERVER_KEYS, + campaign_mail_profile_id, +) +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignShare, + CampaignStatus, + CampaignVersion, + CampaignVersionWorkflowState, + RecipientImportMappingProfile, +) +from govoplan_campaign.backend.path_security import CampaignPathSecurityError +from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError +from govoplan_campaign.backend.persistence.versions import ( + LockedCampaignVersionError, + is_user_locked_version, + is_version_final_locked, + is_version_locked, + update_campaign_version, +) +from govoplan_campaign.backend.schemas import ( + CampaignVersionDetailResponse, + CampaignVersionUpdateRequest, + RecipientImportMappingProfilePayload, +) +from govoplan_campaign.backend.sending.execution import ( + clear_execution_snapshot, +) +from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.auth import ApiPrincipal, has_scope +from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory +from govoplan_core.core.runtime import get_registry + + +def _capability_payload(value: object) -> dict[str, Any]: + if dataclasses.is_dataclass(value): + return dataclasses.asdict(value) + if isinstance(value, dict): + return dict(value) + payload: dict[str, Any] = {} + for key in ( + "contact_id", + "address_book_id", + "display_name", + "email", + "email_label", + "organization", + "role_title", + "tags", + "source_kind", + "source_ref", + "source_revision", + "source_id", + "source_label", + "recipient_count", + "generated_at", + "recipients", + "fields", + "provenance", + ): + if hasattr(value, key): + payload[key] = getattr(value, key) + return payload + + +def _registry_capability(name: str) -> object | None: + registry = get_registry() + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(name) + ): + return None + return registry.capability(name) + + +def _access_directory() -> AccessDirectory: + registry = get_registry() + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY) + ): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Access directory capability is not configured", + ) + capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY) + if not isinstance(capability, AccessDirectory): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Access directory capability is invalid", + ) + return capability + + +def _get_campaign_for_tenant( + session: Session, campaign_id: str, tenant_id: str +) -> Campaign: + campaign = session.get(Campaign, campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found" + ) + return campaign + + +def _get_version_for_tenant( + session: Session, version_id: str, tenant_id: str +) -> CampaignVersion: + version = session.get(CampaignVersion, version_id) + if not version: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" + ) + campaign = session.get(Campaign, version.campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" + ) + return version + + +def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]: + del session + return { + group.id + for group in _access_directory().groups_for_user( + principal.user.id, tenant_id=principal.tenant_id + ) + } + + +def _campaign_acl_filter(session: Session, principal: ApiPrincipal): + if has_scope(principal, "tenant:*"): + return None + group_ids = _principal_group_ids(session, principal) + clauses = [Campaign.owner_user_id == principal.user.id] + if group_ids: + clauses.append(Campaign.owner_group_id.in_(group_ids)) + share_clauses = [ + and_( + CampaignShare.tenant_id == Campaign.tenant_id, + CampaignShare.campaign_id == Campaign.id, + CampaignShare.revoked_at.is_(None), + CampaignShare.target_type == "user", + CampaignShare.target_id == principal.user.id, + ) + ] + if group_ids: + share_clauses.append( + and_( + CampaignShare.tenant_id == Campaign.tenant_id, + CampaignShare.campaign_id == Campaign.id, + CampaignShare.revoked_at.is_(None), + CampaignShare.target_type == "group", + CampaignShare.target_id.in_(group_ids), + ) + ) + clauses.append(exists().where(or_(*share_clauses))) + return or_(*clauses) + + +def _campaign_acl_allows( + session: Session, + campaign: Campaign, + principal: ApiPrincipal, + *, + write: bool = False, +) -> bool: + if has_scope(principal, "tenant:*"): + return True + if campaign.owner_user_id == principal.user.id: + return True + group_ids = _principal_group_ids(session, principal) + if campaign.owner_group_id and campaign.owner_group_id in group_ids: + return True + target_ids = [principal.user.id, *group_ids] + if not target_ids: + return False + query = session.query(CampaignShare).filter( + CampaignShare.tenant_id == campaign.tenant_id, + CampaignShare.campaign_id == campaign.id, + CampaignShare.revoked_at.is_(None), + or_( + CampaignShare.target_type == "user", + CampaignShare.target_type == "group", + ), + CampaignShare.target_id.in_(target_ids), + ) + shares = query.all() + if not shares: + return False + if not write: + return True + return any(item.permission == "write" for item in shares) + + +def _require_campaign_acl( + session: Session, + campaign: Campaign, + principal: ApiPrincipal, + *, + write: bool = False, +) -> None: + if not _campaign_acl_allows(session, campaign, principal, write=write): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Campaign is not shared with this principal", + ) + + +def _get_campaign_for_principal( + session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False +) -> Campaign: + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + _require_campaign_acl(session, campaign, principal, write=write) + return campaign + + +def _require_permission(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}" + ) + + +def _campaign_query_for_principal(session: Session, principal: ApiPrincipal): + query = session.query(Campaign).filter( + Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted" + ) + acl_filter = _campaign_acl_filter(session, principal) + if acl_filter is not None: + query = query.filter(acl_filter) + return query + + +def _get_recipient_import_profile_for_principal( + session: Session, profile_id: str, principal: ApiPrincipal +) -> RecipientImportMappingProfile: + profile = session.get(RecipientImportMappingProfile, profile_id) + if ( + not profile + or profile.tenant_id != principal.tenant_id + or profile.owner_user_id != principal.user.id + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Recipient import mapping profile not found", + ) + return profile + + +def _apply_recipient_import_profile_payload( + profile: RecipientImportMappingProfile, + payload: RecipientImportMappingProfilePayload, +) -> None: + profile.name = payload.name.strip() + profile.column_count = payload.column_count + profile.headers = list(payload.headers) + profile.normalized_headers = list(payload.normalized_headers) + profile.ordered_header_fingerprint = payload.ordered_header_fingerprint + profile.unordered_header_fingerprint = payload.unordered_header_fingerprint + profile.delimiter = payload.delimiter + profile.header_rows = payload.header_rows + profile.quoted = payload.quoted + profile.value_separators = payload.value_separators + profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings] + + +def _recipient_sections_changed( + current: dict[str, object] | None, proposed: dict[str, object] | None +) -> bool: + if proposed is None: + return False + current = current or {} + return any( + current.get(key) != proposed.get(key) for key in ("recipients", "entries") + ) + + +def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None: + return campaign_mail_profile_id(raw_json) + + +def _require_mail_profile_use_if_needed( + principal: ApiPrincipal, raw_json: dict[str, object] | None +) -> None: + if _campaign_mail_profile_id(raw_json) and not has_scope( + principal, "mail:profile:use" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Missing scope: mail:profile:use", + ) + + +def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]: + return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")} + + +def _campaign_version_detail_response( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + mutation: Callable[[], CampaignVersion], + *, + audit_action: str, + details: dict[str, Any] | Callable[[CampaignVersion], dict[str, Any]] | None = None, + validation_error_status: int | None = None, +) -> CampaignVersionDetailResponse: + try: + version = mutation() + audit_details = ( + details(version) + if callable(details) + else dict(details or {"campaign_id": campaign_id}) + ) + audit_from_principal( + session, + principal, + action=audit_action, + object_type="campaign_version", + object_id=version.id, + details=audit_details, + commit=True, + ) + _write_current_version_snapshot_if_available(version) + return CampaignVersionDetailResponse.model_validate( + version, + context=_campaign_response_context(principal), + ) + except LockedCampaignVersionError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + except CampaignPathSecurityError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except CampaignPersistenceError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except Exception as exc: + session.rollback() + if validation_error_status is None: + raise + raise HTTPException( + status_code=validation_error_status, detail=str(exc) + ) from exc + + +def _update_campaign_version_detail_response( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + *, + autosave: bool, + audit_action: str, +) -> CampaignVersionDetailResponse: + _get_campaign_for_principal(session, campaign_id, principal, write=True) + current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): + _require_permission(principal, "campaigns:recipient:write") + _require_mail_profile_use_if_needed(principal, payload.campaign_json) + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow, + current_step=payload.current_step, + workflow_state=payload.workflow_state, + is_complete=payload.is_complete, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=autosave, + migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings, + commit=False, + ), + audit_action=audit_action, + details=lambda version: { + "campaign_id": campaign_id, + "current_flow": version.current_flow, + "current_step": version.current_step, + "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings, + }, + validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT, + ) + + +def _require_campaign_profile_use_if_needed( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + version_id: str | None = None, +) -> None: + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + target_version_id = version_id or campaign.current_version_id + if not target_version_id: + return + version = _get_version_for_tenant(session, target_version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" + ) + _require_mail_profile_use_if_needed( + principal, version.raw_json if isinstance(version.raw_json, dict) else {} + ) + + +def _require_campaign_versions_profile_use( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + version_ids: set[str], +) -> None: + """Authorize every historical version affected by a campaign-wide action.""" + + for version_id in sorted(version_ids): + _require_campaign_profile_use_if_needed( + session, + principal, + campaign_id, + version_id, + ) + + +def _get_version_for_principal( + session: Session, + version_id: str, + principal: ApiPrincipal, + *, + write: bool = False, +) -> CampaignVersion: + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + campaign = _get_campaign_for_tenant( + session, version.campaign_id, principal.tenant_id + ) + _require_campaign_acl(session, campaign, principal, write=write) + return version + + +def _sync_campaign_metadata_to_current_version( + session: Session, campaign: Campaign +) -> None: + """Keep editable version JSON aligned with version-independent campaign metadata. + + Campaign metadata can be edited from the overview while individual campaign + sections save the current version JSON later. Without this sync, a later + version save can re-apply stale `campaign.name` / `campaign.id` values from + raw_json and make the old overview metadata appear to come back. Audit-safe + or validation-locked versions are left untouched. + """ + + if not campaign.current_version_id: + return + + version = session.get(CampaignVersion, campaign.current_version_id) + if not version or version.campaign_id != campaign.id or is_version_locked(version): + return + + raw_json = copy.deepcopy( + version.raw_json if isinstance(version.raw_json, dict) else {} + ) + campaign_section = ( + raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + ) + raw_json["campaign"] = { + **campaign_section, + "id": campaign.external_id, + "name": campaign.name, + "description": campaign.description or "", + } + version.raw_json = raw_json + session.add(version) + + +def _clear_current_version_mail_profile_for_owner_transfer( + session: Session, campaign: Campaign +) -> bool: + """Force explicit profile reselection after campaign ownership changes. + + User/group-scoped reusable mail profiles are evaluated against the current + owner. Instead of trying to keep a stale selection across an ownership + transfer, clear the profile from the editable current version and invalidate + validation/build state so the operator has to reselect and revalidate. + """ + + if not campaign.current_version_id: + return False + + version = session.get(CampaignVersion, campaign.current_version_id) + if not version or version.campaign_id != campaign.id: + return False + + raw_json = copy.deepcopy( + version.raw_json if isinstance(version.raw_json, dict) else {} + ) + server = ( + raw_json.get("server") if isinstance(raw_json.get("server"), dict) else None + ) + if not isinstance(server, dict): + return False + + profile_id = _campaign_mail_profile_id(raw_json) + if not profile_id: + return False + + if is_version_final_locked(version) or is_user_locked_version(version): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Change owner only after creating an editable campaign version; the current version has a selected mail profile and is locked.", + ) + + next_server = dict(server) + for key in CAMPAIGN_MAIL_SERVER_KEYS: + next_server.pop(key, None) + next_server.pop("profile_id", None) + raw_json["server"] = next_server + + version.raw_json = raw_json + version.validation_summary = None + version.build_summary = None + clear_execution_snapshot(version) + version.locked_at = None + version.locked_by_user_id = None + version.workflow_state = CampaignVersionWorkflowState.EDITING.value + version.is_complete = False + + editor_state = copy.deepcopy(version.editor_state or {}) + editor_state.pop("review_send", None) + version.editor_state = editor_state + + session.query(CampaignIssue).filter( + CampaignIssue.campaign_version_id == version.id + ).delete(synchronize_session=False) + session.query(CampaignJob).filter( + CampaignJob.campaign_version_id == version.id + ).delete(synchronize_session=False) + campaign.status = CampaignStatus.DRAFT.value + session.add(version) + _write_current_version_snapshot_if_available(version) + return True + + +def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None: + try: + from govoplan_campaign.backend.persistence.campaigns import ( + _write_campaign_snapshot, + ) + + _write_campaign_snapshot(version) + except Exception: + # The database state is authoritative for the WebUI. Snapshot writing is + # best-effort here because ownership changes should not fail due to an + # unavailable local runtime directory. + return + + +def bounded_query_rows(query, *, limit: int, label: str): + rows = query.limit(limit + 1).all() + if len(rows) > limit: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=( + f"{label} exceeds the maximum response size of {limit} rows. " + "Narrow the request or use a paginated/delta endpoint." + ), + ) + return rows + + +def job_attempt_rows(query, *, label: str): + return bounded_query_rows(query, limit=1000, label=label) diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 37d2622..4603fee 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -1,4394 +1,24 @@ from __future__ import annotations -import copy -import dataclasses -import json -import logging -from collections.abc import Callable, Sequence -from typing import Any, Literal - -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy import and_, exists, func, or_ -from sqlalchemy.orm import Session -from pydantic import BaseModel, Field - -from govoplan_campaign.backend.schemas import ( - AppendSentRequest, - BuildCampaignRequest, - CampaignActionResponse, - CampaignDeltaResponse, - CampaignCreateRequest, - CampaignUpdateRequest, - CampaignCreateResponse, - CampaignCreateMinimalRequest, - CampaignShareItem, - CampaignShareListResponse, - CampaignShareTargetItem, - CampaignShareTargetsResponse, - CampaignShareUpsertRequest, - CampaignOwnerUpdateRequest, - CampaignAddressLookupCandidate, - CampaignAddressLookupResponse, - CampaignPostboxCatalogResponse, - CampaignRecipientAddressSource, - CampaignRecipientAddressSourcesResponse, - CampaignRecipientAddressSourceSnapshotRequest, - CampaignRecipientAddressSourceSnapshotResponse, - CampaignRecipientSnapshotItem, - RecipientImportMappingProfileListResponse, - RecipientImportMappingProfilePayload, - RecipientImportMappingProfileResponse, - CampaignJobsResponse, - CampaignJobsDeltaResponse, - CampaignJobDetailResponse, - CampaignJobDiagnosticsResponse, - CampaignRetryJobsRequest, - CampaignSendJobRequest, - CampaignSendUnattemptedRequest, - CampaignResolveOutcomeRequest, - CampaignDeliveryOptionsResponse, - CampaignListResponse, - CampaignResponse, - CampaignVersionDetailResponse, - CampaignVersionResponse, - CampaignWorkspaceDeltaResponse, - CampaignWorkspaceResponse, - CampaignVersionSetStepRequest, - CampaignReviewStateRequest, - CampaignVersionUpdateRequest, - CampaignPartialValidationRequest, - CampaignPartialValidationResponse, - ValidateCampaignRequest, - ReportEmailRequest, - ReportEmailResponse, - MockCampaignSendRequest, - MockCampaignSendResponse, - QueueCampaignRequest, - QueueCampaignResponse, - SendCampaignNowRequest, - SendCampaignNowResponse, -) -from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope -from govoplan_core.audit.logging import audit_from_principal -from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory -from govoplan_core.core.change_sequence import ( - decode_sequence_watermark, - encode_sequence_watermark, - max_sequence_id, - sequence_entries_since, - sequence_watermark_is_expired, -) -from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint -from govoplan_core.core.runtime import get_registry -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, - CampaignIssue, - CampaignJob, - CampaignShare, - CampaignStatus, - CampaignVersion, - CampaignVersionWorkflowState, - ImapAppendAttempt, - JobImapStatus, - JobPostboxStatus, - JobQueueStatus, - JobSendStatus, - JobValidationStatus, - RecipientImportMappingProfile, - PostboxDeliveryAttempt, - SendAttempt, -) -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, generate_jobs_csv -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.response_security import ( - public_campaign_payload, - public_delivery_result_message, - public_send_campaign_now_result, - send_campaign_now_audit_details, -) -from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email -from govoplan_campaign.backend.persistence.campaigns import ( - CampaignPersistenceError, - build_campaign_version, - create_campaign_version_from_json, - load_campaign_config_from_json, - validate_campaign_version, -) -from govoplan_campaign.backend.integrations import ( - MailProfileError, - SmtpConfigurationError, - SmtpSendError, - files_integration, -) -from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths -from govoplan_campaign.backend.campaign.loader import load_campaign_json -from govoplan_campaign.backend.campaign.mail_profile_boundary import ( - CAMPAIGN_MAIL_SERVER_KEYS, - campaign_mail_profile_id, -) -from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments -from govoplan_core.security.time import utc_now -from govoplan_campaign.backend.persistence.versions import ( - LockedCampaignVersionError, - create_minimal_campaign, - fork_campaign_version_for_edit, - is_version_final_locked, - is_user_locked_version, - is_version_locked, - get_campaign_version_for_tenant, - lock_campaign_version_temporarily, - permanently_lock_campaign_version, - publish_campaign_version, - unlock_user_locked_campaign_version, - unlock_validated_campaign_version, - update_campaign_version, - update_campaign_review_state, - validate_campaign_partial, -) - -from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send -from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot -from govoplan_campaign.backend.sending.jobs import ( - QueueingError, - SynchronousSendRejected, - cancel_campaign_jobs, - enqueue_pending_imap_appends, - pause_campaign_jobs, - queue_campaign_jobs, - queue_failed_jobs_for_retry, - queue_unattempted_jobs, - reconcile_job_outcome, - resume_campaign_jobs, - send_campaign_now, - send_single_campaign_job, - synchronous_send_options, -) - -router = APIRouter(prefix="/campaigns", tags=["campaigns"]) -logger = logging.getLogger(__name__) - -CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs" -CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" -CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" - - -def _capability_payload(value: object) -> dict[str, Any]: - if dataclasses.is_dataclass(value): - return dataclasses.asdict(value) - if isinstance(value, dict): - return dict(value) - payload: dict[str, Any] = {} - for key in ( - "contact_id", - "address_book_id", - "display_name", - "email", - "email_label", - "organization", - "role_title", - "tags", - "source_kind", - "source_ref", - "source_revision", - "source_id", - "source_label", - "recipient_count", - "generated_at", - "recipients", - "fields", - "provenance", - ): - if hasattr(value, key): - payload[key] = getattr(value, key) - return payload - - -def _registry_capability(name: str) -> object | None: - registry = get_registry() - if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name): - return None - return registry.capability(name) - - -def _access_directory() -> AccessDirectory: - registry = get_registry() - if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY): - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is not configured") - capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY) - if not isinstance(capability, AccessDirectory): - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Access directory capability is invalid") - return capability - - -def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign: - campaign = session.get(Campaign, campaign_id) - if not campaign or campaign.tenant_id != tenant_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found") - return campaign - - -def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -> CampaignVersion: - version = session.get(CampaignVersion, version_id) - if not version: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") - campaign = session.get(Campaign, version.campaign_id) - if not campaign or campaign.tenant_id != tenant_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") - return version - - - - -def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]: - del session - return {group.id for group in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)} - - - - -def _campaign_acl_filter(session: Session, principal: ApiPrincipal): - if has_scope(principal, "tenant:*"): - return None - group_ids = _principal_group_ids(session, principal) - clauses = [Campaign.owner_user_id == principal.user.id] - if group_ids: - clauses.append(Campaign.owner_group_id.in_(group_ids)) - share_clauses = [ - and_( - CampaignShare.tenant_id == Campaign.tenant_id, - CampaignShare.campaign_id == Campaign.id, - CampaignShare.revoked_at.is_(None), - CampaignShare.target_type == "user", - CampaignShare.target_id == principal.user.id, - ) - ] - if group_ids: - share_clauses.append( - and_( - CampaignShare.tenant_id == Campaign.tenant_id, - CampaignShare.campaign_id == Campaign.id, - CampaignShare.revoked_at.is_(None), - CampaignShare.target_type == "group", - CampaignShare.target_id.in_(group_ids), - ) - ) - clauses.append(exists().where(or_(*share_clauses))) - return or_(*clauses) - - -def _campaign_acl_allows(session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False) -> bool: - if has_scope(principal, "tenant:*"): - return True - if campaign.owner_user_id == principal.user.id: - return True - group_ids = _principal_group_ids(session, principal) - if campaign.owner_group_id and campaign.owner_group_id in group_ids: - return True - target_ids = [principal.user.id, *group_ids] - if not target_ids: - return False - query = session.query(CampaignShare).filter( - CampaignShare.tenant_id == campaign.tenant_id, - CampaignShare.campaign_id == campaign.id, - CampaignShare.revoked_at.is_(None), - or_( - CampaignShare.target_type == "user", - CampaignShare.target_type == "group", - ), - CampaignShare.target_id.in_(target_ids), - ) - shares = query.all() - if not shares: - return False - if not write: - return True - return any(item.permission == "write" for item in shares) - - -def _require_campaign_acl(session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False) -> None: - if not _campaign_acl_allows(session, campaign, principal, write=write): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Campaign is not shared with this principal") - - -def _get_campaign_for_principal(session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False) -> Campaign: - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - _require_campaign_acl(session, campaign, principal, write=write) - return campaign - - -def _require_permission(principal: ApiPrincipal, scope: str) -> None: - if not has_scope(principal, scope): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") - - -def _campaign_query_for_principal(session: Session, principal: ApiPrincipal): - query = session.query(Campaign).filter(Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted") - acl_filter = _campaign_acl_filter(session, principal) - if acl_filter is not None: - query = query.filter(acl_filter) - return query - - -def _get_recipient_import_profile_for_principal(session: Session, profile_id: str, principal: ApiPrincipal) -> RecipientImportMappingProfile: - profile = session.get(RecipientImportMappingProfile, profile_id) - if not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipient import mapping profile not found") - return profile - - -def _apply_recipient_import_profile_payload(profile: RecipientImportMappingProfile, payload: RecipientImportMappingProfilePayload) -> None: - profile.name = payload.name.strip() - profile.column_count = payload.column_count - profile.headers = list(payload.headers) - profile.normalized_headers = list(payload.normalized_headers) - profile.ordered_header_fingerprint = payload.ordered_header_fingerprint - profile.unordered_header_fingerprint = payload.unordered_header_fingerprint - profile.delimiter = payload.delimiter - profile.header_rows = payload.header_rows - profile.quoted = payload.quoted - profile.value_separators = payload.value_separators - profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings] - - -def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool: - if proposed is None: - return False - current = current or {} - return any(current.get(key) != proposed.get(key) for key in ("recipients", "entries")) - - - - -def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None: - return campaign_mail_profile_id(raw_json) - - -def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None: - if _campaign_mail_profile_id(raw_json) and not has_scope(principal, "mail:profile:use"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail:profile:use") - - -def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]: - return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")} - - -def _campaign_version_detail_response( - session: Session, - principal: ApiPrincipal, - campaign_id: str, - mutation: Callable[[], CampaignVersion], - *, - audit_action: str, - details: dict[str, Any] | Callable[[CampaignVersion], dict[str, Any]] | None = None, - validation_error_status: int | None = None, -) -> CampaignVersionDetailResponse: - try: - version = mutation() - audit_details = details(version) if callable(details) else dict(details or {"campaign_id": campaign_id}) - audit_from_principal( - session, - principal, - action=audit_action, - object_type="campaign_version", - object_id=version.id, - details=audit_details, - commit=True, - ) - _write_current_version_snapshot_if_available(version) - return CampaignVersionDetailResponse.model_validate( - version, - context=_campaign_response_context(principal), - ) - except LockedCampaignVersionError as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPathSecurityError as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - except Exception as exc: - session.rollback() - if validation_error_status is None: - raise - raise HTTPException(status_code=validation_error_status, detail=str(exc)) from exc - - -def _update_campaign_version_detail_response( - session: Session, - principal: ApiPrincipal, - campaign_id: str, - version_id: str, - payload: CampaignVersionUpdateRequest, - *, - autosave: bool, - audit_action: str, -) -> CampaignVersionDetailResponse: - _get_campaign_for_principal(session, campaign_id, principal, write=True) - current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) - if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): - _require_permission(principal, "campaigns:recipient:write") - _require_mail_profile_use_if_needed(principal, payload.campaign_json) - return _campaign_version_detail_response( - session, - principal, - campaign_id, - lambda: update_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - raw_json=payload.campaign_json, - current_flow=payload.current_flow, - current_step=payload.current_step, - workflow_state=payload.workflow_state, - is_complete=payload.is_complete, - editor_state=payload.editor_state, - source_filename=payload.source_filename, - source_base_path=payload.source_base_path, - autosave=autosave, - migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings, - commit=False, - ), - audit_action=audit_action, - details=lambda version: { - "campaign_id": campaign_id, - "current_flow": version.current_flow, - "current_step": version.current_step, - "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings, - }, - validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT, - ) - - -def _require_campaign_profile_use_if_needed( - session: Session, - principal: ApiPrincipal, - campaign_id: str, - version_id: str | None = None, -) -> None: - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - target_version_id = version_id or campaign.current_version_id - if not target_version_id: - return - version = _get_version_for_tenant(session, target_version_id, principal.tenant_id) - if version.campaign_id != campaign.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") - _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) - - -def _require_campaign_versions_profile_use( - session: Session, - principal: ApiPrincipal, - campaign_id: str, - version_ids: set[str], -) -> None: - """Authorize every historical version affected by a campaign-wide action.""" - - for version_id in sorted(version_ids): - _require_campaign_profile_use_if_needed( - session, - principal, - campaign_id, - version_id, - ) - - -def _get_version_for_principal( - session: Session, - version_id: str, - principal: ApiPrincipal, - *, - write: bool = False, -) -> CampaignVersion: - version = _get_version_for_tenant(session, version_id, principal.tenant_id) - campaign = _get_campaign_for_tenant(session, version.campaign_id, principal.tenant_id) - _require_campaign_acl(session, campaign, principal, write=write) - return version - - - -def _sync_campaign_metadata_to_current_version(session: Session, campaign: Campaign) -> None: - """Keep editable version JSON aligned with version-independent campaign metadata. - - Campaign metadata can be edited from the overview while individual campaign - sections save the current version JSON later. Without this sync, a later - version save can re-apply stale `campaign.name` / `campaign.id` values from - raw_json and make the old overview metadata appear to come back. Audit-safe - or validation-locked versions are left untouched. - """ - - if not campaign.current_version_id: - return - - version = session.get(CampaignVersion, campaign.current_version_id) - if not version or version.campaign_id != campaign.id or is_version_locked(version): - return - - raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {}) - campaign_section = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} - raw_json["campaign"] = { - **campaign_section, - "id": campaign.external_id, - "name": campaign.name, - "description": campaign.description or "", - } - version.raw_json = raw_json - session.add(version) - - -def _clear_current_version_mail_profile_for_owner_transfer(session: Session, campaign: Campaign) -> bool: - """Force explicit profile reselection after campaign ownership changes. - - User/group-scoped reusable mail profiles are evaluated against the current - owner. Instead of trying to keep a stale selection across an ownership - transfer, clear the profile from the editable current version and invalidate - validation/build state so the operator has to reselect and revalidate. - """ - - if not campaign.current_version_id: - return False - - version = session.get(CampaignVersion, campaign.current_version_id) - if not version or version.campaign_id != campaign.id: - return False - - raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {}) - server = raw_json.get("server") if isinstance(raw_json.get("server"), dict) else None - if not isinstance(server, dict): - return False - - profile_id = _campaign_mail_profile_id(raw_json) - if not profile_id: - return False - - if is_version_final_locked(version) or is_user_locked_version(version): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Change owner only after creating an editable campaign version; the current version has a selected mail profile and is locked.", - ) - - next_server = dict(server) - for key in CAMPAIGN_MAIL_SERVER_KEYS: - next_server.pop(key, None) - next_server.pop("profile_id", None) - raw_json["server"] = next_server - - version.raw_json = raw_json - version.validation_summary = None - version.build_summary = None - clear_execution_snapshot(version) - version.locked_at = None - version.locked_by_user_id = None - version.workflow_state = CampaignVersionWorkflowState.EDITING.value - version.is_complete = False - - editor_state = copy.deepcopy(version.editor_state or {}) - editor_state.pop("review_send", None) - version.editor_state = editor_state - - session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) - session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False) - campaign.status = CampaignStatus.DRAFT.value - session.add(version) - _write_current_version_snapshot_if_available(version) - return True - - -def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None: - try: - from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot - - _write_campaign_snapshot(version) - except Exception: - # The database state is authoritative for the WebUI. Snapshot writing is - # best-effort here because ownership changes should not fail due to an - # unavailable local runtime directory. - return - - -@router.post("", response_model=CampaignCreateResponse) -def create_campaign( - payload: CampaignCreateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:create")), +from fastapi import APIRouter + +from govoplan_campaign.backend.routes.attachments import router as attachments_router +from govoplan_campaign.backend.routes.campaigns import router as campaigns_router +from govoplan_campaign.backend.routes.delivery import router as delivery_router +from govoplan_campaign.backend.routes.jobs import router as jobs_router +from govoplan_campaign.backend.routes.reports import router as reports_router +from govoplan_campaign.backend.routes.sharing import router as sharing_router +from govoplan_campaign.backend.routes.versions import router as versions_router + + +router = APIRouter() +for workflow_router in ( + campaigns_router, + versions_router, + jobs_router, + reports_router, + sharing_router, + delivery_router, + attachments_router, ): - 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]) - - -def _bounded_query_rows(query, *, limit: int, label: str): - rows = query.limit(limit + 1).all() - if len(rows) > limit: - raise HTTPException( - status_code=status.HTTP_413_CONTENT_TOO_LARGE, - detail=( - f"{label} exceeds the maximum response size of {limit} rows. " - "Narrow the request or use a paginated/delta endpoint." - ), - ) - return rows - - -def _job_attempt_rows(query, *, label: str): - return _bounded_query_rows(query, limit=1000, label=label) - - -_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}" - f"{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) - - -@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) -def list_versions( - campaign_id: str, - limit: int = Query(default=500, ge=1, le=1000), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), -): - _get_campaign_for_principal(session, campaign_id, principal) - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - versions = _bounded_query_rows( - session.query(CampaignVersion) - .filter(CampaignVersion.campaign_id == campaign.id) - .order_by(CampaignVersion.version_number.desc()), - limit=limit, - label="Campaign version history", - ) - return [ - CampaignVersionResponse.model_validate( - item, - context=_campaign_response_context(principal), - ) - for item in versions - ] - - -@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) -def get_version_detail( - campaign_id: str, - version_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), -): - _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:read") - try: - version = get_campaign_version_for_tenant( - session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id - ) - return CampaignVersionDetailResponse.model_validate( - version, - context=_campaign_response_context(principal), - ) - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse) -def fork_version_for_edit( - campaign_id: str, - version_id: str, - payload: CampaignVersionUpdateRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")), -): - """Create the campaign's next and only editable working version. - - A new working copy may be created only after the current version is - permanently user-locked or delivery-final. Validation and temporary user - locks must be removed in place instead of creating parallel drafts. - """ - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - - payload = payload or CampaignVersionUpdateRequest() - source_version = _get_version_for_tenant(session, version_id, principal.tenant_id) - if source_version.campaign_id != campaign_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") - effective_json = ( - payload.campaign_json - if isinstance(payload.campaign_json, dict) - else source_version.raw_json - ) - _require_mail_profile_use_if_needed( - principal, - effective_json if isinstance(effective_json, dict) else {}, - ) - try: - version = fork_campaign_version_for_edit( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - raw_json=payload.campaign_json, - current_flow=payload.current_flow or "manual", - current_step=payload.current_step, - editor_state=payload.editor_state, - source_filename=payload.source_filename, - source_base_path=payload.source_base_path, - autosave=True, - migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings, - commit=False, - ) - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - audit_from_principal( - session, - principal, - action="campaign.version_forked_for_edit", - object_type="campaign_version", - object_id=version.id, - details={ - "campaign_id": campaign_id, - "source_version_id": version_id, - "version_number": version.version_number, - "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings, - }, - 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 LockedCampaignVersionError as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPathSecurityError as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - except Exception: - session.rollback() - raise - - -@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse) -def unlock_version_validation( - campaign_id: str, - version_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - """Unlock a successfully validated version before delivery starts. - - Unlocking invalidates validation/build state and removes generated jobs for - that version. Sent/final versions cannot be unlocked and must be copied. - """ - _get_campaign_for_principal(session, campaign_id, principal, write=True) - - return _campaign_version_detail_response( - session, - principal, - campaign_id, - lambda: unlock_validated_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - commit=False, - ), - audit_action="campaign.version_validation_unlocked", - ) - - -@router.post("/{campaign_id}/versions/{version_id}/lock-temporarily", response_model=CampaignVersionDetailResponse) -def lock_version_temporarily( - campaign_id: str, - version_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - return _campaign_version_detail_response( - session, - principal, - campaign_id, - lambda: lock_campaign_version_temporarily( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - user_id=principal.user.id, - commit=False, - ), - audit_action="campaign.version_user_locked_temporarily", - ) - - -@router.post("/{campaign_id}/versions/{version_id}/unlock-user-lock", response_model=CampaignVersionDetailResponse) -def unlock_version_user_lock( - campaign_id: str, - version_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - return _campaign_version_detail_response( - session, - principal, - campaign_id, - lambda: unlock_user_locked_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - commit=False, - ), - audit_action="campaign.version_user_lock_removed", - ) - - -@router.post("/{campaign_id}/versions/{version_id}/lock-permanently", response_model=CampaignVersionDetailResponse) -def lock_version_permanently( - campaign_id: str, - version_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - return _campaign_version_detail_response( - session, - principal, - campaign_id, - lambda: permanently_lock_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - user_id=principal.user.id, - commit=False, - ), - audit_action="campaign.version_user_locked_permanently", - ) - - -@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) -def update_version_detail( - campaign_id: str, - version_id: str, - payload: CampaignVersionUpdateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - return _update_campaign_version_detail_response( - session, - principal, - campaign_id, - version_id, - payload, - autosave=False, - audit_action="campaign.version_updated", - ) - - -@router.post("/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse) -def autosave_version( - campaign_id: str, - version_id: str, - payload: CampaignVersionUpdateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - return _update_campaign_version_detail_response( - session, - principal, - campaign_id, - version_id, - payload, - autosave=True, - audit_action="campaign.version_autosaved", - ) - - -@router.post("/{campaign_id}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse) -def set_version_step( - campaign_id: str, - version_id: str, - payload: CampaignVersionSetStepRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - return _campaign_version_detail_response( - session, - principal, - campaign_id, - lambda: update_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - current_flow=payload.current_flow, - current_step=payload.current_step, - autosave=True, - commit=False, - ), - audit_action="campaign.version_step_updated", - details={ - "campaign_id": campaign_id, - "current_flow": payload.current_flow, - "current_step": payload.current_step, - }, - ) - - -@router.post("/{campaign_id}/versions/{version_id}/review-state", response_model=CampaignVersionDetailResponse) -def set_version_review_state( - campaign_id: str, - version_id: str, - payload: CampaignReviewStateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - version = update_campaign_review_state( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - inspection_complete=payload.inspection_complete, - reviewed_message_keys=payload.reviewed_message_keys, - user_id=principal.user.id, - commit=False, - ) - audit_from_principal( - session, - principal, - action="campaign.message_review_updated", - object_type="campaign_version", - object_id=version.id, - details={ - "campaign_id": campaign_id, - "inspection_complete": payload.inspection_complete, - "reviewed_message_count": len(payload.reviewed_message_keys), - }, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate( - version, - context=_campaign_response_context(principal), - ) - except LockedCampaignVersionError as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - 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.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse) -def validate_version_partial( - campaign_id: str, - version_id: str, - payload: CampaignPartialValidationRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - version = get_campaign_version_for_tenant( - session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id - ) - campaign_json = payload.campaign_json if payload and payload.campaign_json is not None else version.raw_json - result = validate_campaign_partial(campaign_json, section=payload.section if payload else None) - audit_from_principal( - session, - principal, - action="campaign.version_partially_validated", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id, "section": result.get("section"), "ok": result.get("ok")}, - commit=True, - ) - return CampaignPartialValidationResponse(**result) - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/versions/{version_id}/publish", response_model=CampaignVersionDetailResponse) -def publish_version( - campaign_id: str, - version_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - return _campaign_version_detail_response( - session, - principal, - campaign_id, - lambda: publish_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - user_id=principal.user.id, - commit=False, - ), - audit_action="campaign.version_user_locked_permanently", - ) - - -@router.post("/versions/{version_id}/validate") -def validate_version( - version_id: str, - payload: ValidateCampaignRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), -): - _get_version_for_principal(session, version_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - payload = payload or ValidateCampaignRequest() - try: - version = _get_version_for_tenant(session, version_id, principal.tenant_id) - _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) - if is_user_locked_version(version) or is_version_final_locked(version): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.", - ) - link_result: CampaignAttachmentLinkMatchesResponse | None = None - if payload.check_files and payload.link_unshared_matches: - _require_permission(principal, "files:file:share") - campaign = _get_campaign_for_tenant(session, version.campaign_id, principal.tenant_id) - link_result = _link_campaign_attachment_matches( - session, - principal, - campaign=campaign, - version=version, - raw=version.raw_json if isinstance(version.raw_json, dict) else {}, - dry_run=False, - ) - audit_from_principal( - session, - principal, - action="campaign.attachment_matches_linked", - object_type="campaign_version", - object_id=version_id, - details={ - "matched_file_count": link_result.matched_file_count, - "already_linked_file_count": link_result.already_linked_file_count, - "linked_file_count": link_result.linked_file_count, - "during_validation": True, - }, - commit=True, - ) - result = validate_campaign_version( - session, - tenant_id=principal.tenant_id, - version_id=version_id, - check_files=payload.check_files, - user_id=principal.user.id, - ) - audit_from_principal( - session, - principal, - action="campaign.validated", - object_type="campaign_version", - object_id=version_id, - details={ - "check_files": payload.check_files, - "link_unshared_matches": payload.link_unshared_matches, - "linked_file_count": link_result.linked_file_count if link_result else 0, - "ok": result.get("ok"), - }, - commit=True, - ) - return public_campaign_payload( - result, - include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), - ) - except HTTPException: - raise - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/versions/{version_id}/build") -def build_version( - version_id: str, - payload: BuildCampaignRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")), -): - version = _get_version_for_principal(session, version_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) - try: - result = build_campaign_version( - session, - tenant_id=principal.tenant_id, - version_id=version_id, - write_eml=payload.write_eml if payload else True, - ) - audit_from_principal( - session, - principal, - action="campaign.messages_built", - object_type="campaign_version", - object_id=version_id, - details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")}, - commit=True, - ) - return public_campaign_payload( - result, - include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), - ) - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -def _job_review_key(job: CampaignJob) -> str: - return str(job.entry_id or job.entry_index) - - -def _job_summary_payload( - job: CampaignJob, - *, - reviewed_keys: set[str] | None = None, -) -> dict[str, object]: - review_key = _job_review_key(job) - return { - "id": job.id, - "campaign_version_id": job.campaign_version_id, - "entry_index": job.entry_index, - "entry_id": job.entry_id, - "recipient_email": job.recipient_email, - "subject": job.subject, - "message_id_header": job.message_id_header, - "build_status": job.build_status, - "validation_status": job.validation_status, - "queue_status": job.queue_status, - "send_status": job.send_status, - "delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"), - "postbox_status": getattr(job, "postbox_status", "not_requested"), - "imap_status": job.imap_status, - "eml_size_bytes": job.eml_size_bytes, - "eml_sha256": job.eml_sha256, - "attempt_count": job.attempt_count, - "postbox_attempt_count": getattr(job, "postbox_attempt_count", 0), - "postbox_target_count": len(getattr(job, "resolved_postbox_targets", None) or []), - "last_error": public_delivery_result_message( - last_error=job.last_error, - send_status=job.send_status, - imap_status=job.imap_status, - postbox_status=getattr(job, "postbox_status", "not_requested"), - ), - "queued_at": job.queued_at, - "outcome_unknown_at": job.outcome_unknown_at, - "sent_at": job.sent_at, - "created_at": job.created_at, - "updated_at": job.updated_at, - "issues_count": len(job.issues_snapshot or []), - "attachment_count": len(job.resolved_attachments or []), - "review_key": review_key, - "reviewed": review_key in reviewed_keys if reviewed_keys is not None else False, - "matched_file_count": sum( - len(item.get("matches") or []) - for item in (job.resolved_attachments or []) - if isinstance(item, dict) - ), - } - - -def _job_detail_payload(job: CampaignJob) -> dict[str, object]: - return { - **_job_summary_payload(job), - "message_id_header": job.message_id_header, - "issues": job.issues_snapshot or [], - "attachments": public_campaign_payload(job.resolved_attachments or []), - "resolved_recipients": job.resolved_recipients or {}, - "resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None) or [], - } - - -def _job_attempts_payload( - send_attempts: list[SendAttempt], - imap_attempts: list[ImapAppendAttempt], - postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), - *, - include_diagnostics: bool = False, -) -> dict[str, list[dict[str, object]]]: - smtp_payloads: list[dict[str, object]] = [] - for attempt in send_attempts: - payload: dict[str, object] = { - "id": attempt.id, - "attempt_number": attempt.attempt_number, - "status": attempt.status, - "smtp_status_code": attempt.smtp_status_code, - "started_at": attempt.started_at, - "finished_at": attempt.finished_at, - } - if include_diagnostics: - payload["claim_token"] = attempt.claim_token - payload["smtp_response"] = attempt.smtp_response - payload["error_type"] = attempt.error_type - payload["error_message"] = attempt.error_message - smtp_payloads.append(payload) - - imap_payloads: list[dict[str, object]] = [] - for attempt in imap_attempts: - payload = { - "id": attempt.id, - "attempt_number": attempt.attempt_number, - "status": attempt.status, - "folder": attempt.folder, - "created_at": attempt.created_at, - "updated_at": attempt.updated_at, - } - if include_diagnostics: - payload["claim_token"] = attempt.claim_token - payload["error_message"] = attempt.error_message - imap_payloads.append(payload) - postbox_payloads: list[dict[str, object]] = [] - for attempt in postbox_attempts: - payload = { - "id": attempt.id, - "target_index": attempt.target_index, - "attempt_number": attempt.attempt_number, - "status": attempt.status, - "postbox_id": attempt.postbox_id, - "address": attempt.address, - "holder_count": attempt.holder_count, - "vacant": attempt.vacant, - "duplicate": attempt.duplicate, - "target": attempt.target_snapshot or {}, - "started_at": attempt.started_at, - "finished_at": attempt.finished_at, - "error_code": attempt.error_code, - } - if include_diagnostics: - payload["idempotency_key"] = attempt.idempotency_key - payload["provider_delivery_id"] = attempt.provider_delivery_id - payload["provider_message_id"] = attempt.provider_message_id - payload["evidence"] = attempt.evidence or {} - payload["error_type"] = attempt.error_type - payload["error_message"] = attempt.error_message - postbox_payloads.append(payload) - return { - "smtp": smtp_payloads, - "imap": imap_payloads, - "postbox": postbox_payloads, - } - - -def _job_diagnostics_payload( - job: CampaignJob, - send_attempts: list[SendAttempt], - imap_attempts: list[ImapAppendAttempt], - postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), -) -> CampaignJobDiagnosticsResponse: - return CampaignJobDiagnosticsResponse( - job_id=job.id, - campaign_id=job.campaign_id, - campaign_version_id=job.campaign_version_id, - storage={ - "eml_local_path": job.eml_local_path, - "eml_storage_key": job.eml_storage_key, - "eml_size_bytes": job.eml_size_bytes, - "eml_sha256": job.eml_sha256, - }, - worker_claim={ - "claim_token": job.claim_token, - "claimed_at": job.claimed_at, - "smtp_started_at": job.smtp_started_at, - "outcome_unknown_at": job.outcome_unknown_at, - "last_error": job.last_error, - }, - attempts=_job_attempts_payload( - send_attempts, - imap_attempts, - postbox_attempts, - include_diagnostics=True, - ), - ) - - -def _review_metadata( - session: Session, - version: CampaignVersion | None, - base_filters: list[object], -) -> tuple[dict[str, object], set[str]]: - if version is None: - return _empty_review_metadata(), set() - - review_state, state_is_current = _current_review_state(version) - reviewed_keys = _current_reviewed_keys(review_state, state_is_current=state_is_current) - counts = _review_metadata_counts(_review_rows(session, base_filters), reviewed_keys) - return { - "inspection_complete": bool(state_is_current and review_state.get("inspection_complete") is True), - **counts, - }, reviewed_keys - - -def _empty_review_metadata() -> dict[str, object]: - return { - "inspection_complete": False, - "blocking_count": 0, - "required_count": 0, - "reviewed_required_count": 0, - "bulk_acceptable_count": 0, - } - - -def _current_review_state(version: CampaignVersion) -> tuple[dict[str, object], bool]: - build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} - build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "") - editor_state = version.editor_state if isinstance(version.editor_state, dict) else {} - review_state = editor_state.get("review_send") if isinstance(editor_state.get("review_send"), dict) else {} - state_token = str(review_state.get("build_token") or "") - return review_state, bool(build_token and state_token == build_token) - - -def _current_reviewed_keys(review_state: dict[str, object], *, state_is_current: bool) -> set[str]: - return { - str(value) - for value in (review_state.get("reviewed_message_keys") or []) - if state_is_current and str(value).strip() - } - - -def _review_rows(session: Session, base_filters: list[object]) -> list[tuple[object, int, str, str]]: - return ( - session.query( - CampaignJob.entry_id, - CampaignJob.entry_index, - CampaignJob.build_status, - CampaignJob.validation_status, - ) - .filter(*base_filters) - .all() - ) - - -def _review_metadata_counts(review_rows: list[tuple[object, int, str, str]], reviewed_keys: set[str]) -> dict[str, int]: - blocking_count = 0 - required_count = 0 - reviewed_required_count = 0 - bulk_acceptable_count = 0 - for entry_id, entry_index, build_status, validation_status in review_rows: - key = str(entry_id or entry_index) - if build_status != "built" or validation_status == "blocked": - blocking_count += 1 - if validation_status == "needs_review": - required_count += 1 - if key in reviewed_keys: - reviewed_required_count += 1 - elif validation_status in {"warning", "excluded"}: - bulk_acceptable_count += 1 - - return { - "blocking_count": blocking_count, - "required_count": required_count, - "reviewed_required_count": reviewed_required_count, - "bulk_acceptable_count": bulk_acceptable_count, - } - - -def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]: - result: dict[str, dict[str, int]] = {} - for field_name in ( - "build_status", - "validation_status", - "queue_status", - "send_status", - "postbox_status", - "imap_status", - ): - column = getattr(CampaignJob, field_name) - rows = session.query(column, func.count(CampaignJob.id)).filter(*filters).group_by(column).all() - result[field_name.removesuffix("_status")] = {str(value or "unknown"): int(count) for value, count in rows} - return result - - -CAMPAIGN_JOB_GRID_SORT_COLUMNS = { - "number": CampaignJob.entry_index, - "recipient": func.lower(func.coalesce(CampaignJob.recipient_email, "")), - "subject": func.lower(func.coalesce(CampaignJob.subject, "")), - "validation": CampaignJob.validation_status, - "queue": CampaignJob.queue_status, - "send": CampaignJob.send_status, - "postbox": CampaignJob.postbox_status, - "imap": CampaignJob.imap_status, - "attempts": CampaignJob.attempt_count, - "updated": CampaignJob.updated_at, -} -CAMPAIGN_JOB_GRID_LIST_FILTERS = { - "validation": (CampaignJob.validation_status, {item.value for item in JobValidationStatus}), - "queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}), - "send": (CampaignJob.send_status, {item.value for item in JobSendStatus}), - "postbox": ( - CampaignJob.postbox_status, - {item.value for item in JobPostboxStatus}, - ), - "imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}), -} - - -def _campaign_jobs_grid_filter_expressions(grid_filters: dict[str, str] | None) -> list[object]: - values = grid_filters or {} - expressions: list[object] = [] - recipient = values.get("recipient", "").strip() - if recipient: - pattern = _contains_pattern(recipient) - expressions.append(or_( - CampaignJob.recipient_email.ilike(pattern, escape="\\"), - CampaignJob.entry_id.ilike(pattern, escape="\\"), - )) - subject = values.get("subject", "").strip() - if subject: - expressions.append(CampaignJob.subject.ilike(_contains_pattern(subject), escape="\\")) - evidence = values.get("evidence", "").strip() - if evidence: - pattern = _contains_pattern(evidence) - expressions.append(or_( - CampaignJob.message_id_header.ilike(pattern, escape="\\"), - CampaignJob.eml_sha256.ilike(pattern, escape="\\"), - )) - attempts = values.get("attempts", "").strip() - if attempts: - expressions.append(_campaign_jobs_integer_filter(CampaignJob.attempt_count, attempts, column_id="attempts")) - for column_id, (column, allowed_values) in CAMPAIGN_JOB_GRID_LIST_FILTERS.items(): - raw_value = values.get(column_id, "").strip() - if not raw_value: - continue - selected = _campaign_jobs_list_filter(raw_value, column_id=column_id, allowed_values=allowed_values) - expressions.append(column.in_(selected)) - return expressions - - -def _campaign_jobs_list_filter(raw_value: str, *, column_id: str, allowed_values: set[str]) -> list[str]: - if raw_value.startswith("list:"): - try: - parsed = json.loads(raw_value[5:]) - except json.JSONDecodeError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"Invalid {column_id} list filter", - ) from exc - if not isinstance(parsed, list) or any(not isinstance(value, str) for value in parsed): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"Invalid {column_id} list filter", - ) - selected = list(dict.fromkeys(value.strip() for value in parsed if value.strip())) - else: - selected = list(dict.fromkeys(value.strip() for value in raw_value.split(",") if value.strip())) - if len(selected) > 50 or any(value not in allowed_values for value in selected): - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"Invalid {column_id} list filter", - ) - return selected - - -def _campaign_jobs_integer_filter(column: object, raw_value: str, *, column_id: str): - operator, separator, value = raw_value.partition(":") - if not separator: - operator, value = "eq", operator - if operator not in {"eq", "gt", "gte", "lt", "lte"}: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"Invalid {column_id} filter operator", - ) - try: - expected = int(value) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail=f"Invalid {column_id} filter value", - ) from exc - if operator == "gt": - return column > expected - if operator == "gte": - return column >= expected - if operator == "lt": - return column < expected - if operator == "lte": - return column <= expected - return column == expected - - -def _contains_pattern(value: str) -> str: - escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - return f"%{escaped}%" - - -def _campaign_jobs_ordering(sort_by: str, sort_direction: str) -> list[object]: - column = CAMPAIGN_JOB_GRID_SORT_COLUMNS.get(sort_by) - if column is None: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Unsupported Campaign job sort column", - ) - primary = column.desc() if sort_direction == "desc" else column.asc() - return [primary, CampaignJob.id.asc()] - - -def _campaign_jobs_query_context( - session: Session, - principal: ApiPrincipal, - *, - campaign_id: str, - version_id: str | None, - send_status: list[str] | None, - validation_status: list[str] | None, - imap_status: list[str] | None, - query_text: str | None, - grid_filters: dict[str, str] | None = None, -) -> tuple[Campaign, list[object], list[object], dict[str, object], set[str]]: - _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:read") - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - base_filters: list[object] = [CampaignJob.campaign_id == campaign.id, CampaignJob.tenant_id == principal.tenant_id] - selected_version: CampaignVersion | None = None - if version_id: - version = _get_version_for_tenant(session, version_id, principal.tenant_id) - if version.campaign_id != campaign.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") - selected_version = version - base_filters.append(CampaignJob.campaign_version_id == version.id) - - review_metadata, reviewed_keys = _review_metadata(session, selected_version, base_filters) - filtered = list(base_filters) - if send_status: - filtered.append(CampaignJob.send_status.in_(send_status)) - if validation_status: - filtered.append(CampaignJob.validation_status.in_(validation_status)) - if imap_status: - filtered.append(CampaignJob.imap_status.in_(imap_status)) - if query_text and query_text.strip(): - pattern = f"%{query_text.strip()}%" - filtered.append(or_( - CampaignJob.recipient_email.ilike(pattern), - CampaignJob.subject.ilike(pattern), - CampaignJob.entry_id.ilike(pattern), - )) - filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters)) - return campaign, base_filters, filtered, review_metadata, reviewed_keys - - -def _campaign_jobs_page_response( - session: Session, - *, - campaign_id: str, - version_id: str | None, - base_filters: list[object], - filtered: list[object], - reviewed_keys: set[str], - review_metadata: dict[str, object], - page: int, - page_size: int, - send_status: list[str] | None = None, - validation_status: list[str] | None = None, - imap_status: list[str] | None = None, - query_text: str | None = None, - grid_filters: dict[str, str] | None = None, - sort_by: str = "number", - sort_direction: str = "asc", - cursor: str | None = None, - changed_job_ids: set[str] | None = None, -) -> CampaignJobsResponse: - total_unfiltered, total = _campaign_jobs_page_counts(session, base_filters=base_filters, filtered=filtered) - pages = (total + page_size - 1) // page_size if total else 0 - fingerprint = _campaign_jobs_cursor_fingerprint( - campaign_id=campaign_id, - version_id=version_id, - page_size=page_size, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - grid_filters=grid_filters, - sort_by=sort_by, - sort_direction=sort_direction, - ) - ordering = _campaign_jobs_ordering(sort_by, sort_direction) - rows_plus_one, start_cursor = _campaign_jobs_page_rows( - session, - filtered=filtered, - ordering=ordering, - page=page, - page_size=page_size, - sort_by=sort_by, - sort_direction=sort_direction, - cursor=cursor, - fingerprint=fingerprint, - ) - jobs = rows_plus_one[:page_size] - next_cursor = _campaign_jobs_next_cursor( - jobs, - rows_plus_one=rows_plus_one, - page_size=page_size, - sort_by=sort_by, - sort_direction=sort_direction, - changed_job_ids=changed_job_ids, - fingerprint=fingerprint, - ) - if changed_job_ids is not None: - jobs = [job for job in jobs if job.id in changed_job_ids] - return CampaignJobsResponse( - jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs], - page=page, - page_size=page_size, - total=total, - total_unfiltered=total_unfiltered, - pages=pages, - cursor=start_cursor, - next_cursor=next_cursor, - counts=_status_counts(session, base_filters), - filtered_counts=_status_counts(session, filtered), - review=review_metadata, - ) - - -def _campaign_jobs_page_counts( - session: Session, - *, - base_filters: list[object], - filtered: list[object], -) -> tuple[int, int]: - total_unfiltered = int(session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0) - total = int(session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0) - return total_unfiltered, total - - -def _campaign_jobs_page_rows( - session: Session, - *, - filtered: list[object], - ordering: list[object], - page: int, - page_size: int, - sort_by: str, - sort_direction: str, - cursor: str | None, - fingerprint: str, -) -> tuple[list[CampaignJob], str | None]: - if cursor: - page_query = _campaign_jobs_query_after_cursor( - session, - filtered=filtered, - cursor=cursor, - fingerprint=fingerprint, - sort_by=sort_by, - sort_direction=sort_direction, - ) - return page_query.order_by(*ordering).limit(page_size + 1).all(), cursor - - effective_offset = (page - 1) * page_size - page_query = session.query(CampaignJob).filter(*filtered) - start_cursor = _campaign_jobs_offset_cursor( - page_query, - ordering=ordering, - effective_offset=effective_offset, - sort_by=sort_by, - sort_direction=sort_direction, - fingerprint=fingerprint, - ) - rows = page_query.order_by(*ordering).offset(effective_offset).limit(page_size + 1).all() - return rows, start_cursor - - -def _campaign_jobs_query_after_cursor( - session: Session, - *, - filtered: list[object], - cursor: str, - fingerprint: str, - sort_by: str, - sort_direction: str, -): - if sort_by != "number" or sort_direction != "asc": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Campaign job cursors require number ascending order", - ) - try: - cursor_values = decode_keyset_cursor(CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint) - if cursor_values is None: - raise KeysetCursorError("Invalid pagination cursor") - except KeysetCursorError as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - return session.query(CampaignJob).filter(*filtered).filter(_campaign_jobs_cursor_condition(cursor_values)) - - -def _campaign_jobs_offset_cursor( - query, - *, - ordering: list[object], - effective_offset: int, - sort_by: str, - sort_direction: str, - fingerprint: str, -) -> str | None: - if effective_offset <= 0 or sort_by != "number" or sort_direction != "asc": - return None - previous_row = query.order_by(*ordering).offset(effective_offset - 1).limit(1).first() - if previous_row is None: - return None - return _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint) - - -def _campaign_jobs_next_cursor( - jobs: list[CampaignJob], - *, - rows_plus_one: list[CampaignJob], - page_size: int, - sort_by: str, - sort_direction: str, - changed_job_ids: set[str] | None, - fingerprint: str, -) -> str | None: - cursor_supported = sort_by == "number" and sort_direction == "asc" - if changed_job_ids is not None or not cursor_supported or len(rows_plus_one) <= page_size or not jobs: - return None - return _campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint) - - -def _campaign_jobs_cursor_fingerprint( - *, - campaign_id: str, - version_id: str | None, - page_size: int, - send_status: list[str] | None, - validation_status: list[str] | None, - imap_status: list[str] | None, - query_text: str | None, - grid_filters: dict[str, str] | None, - sort_by: str, - sort_direction: str, -) -> str: - return keyset_query_fingerprint( - CAMPAIGN_JOBS_CURSOR_SCOPE, - { - "campaign_id": campaign_id, - "version_id": version_id or "", - "page_size": page_size, - "send_status": sorted(send_status or []), - "validation_status": sorted(validation_status or []), - "imap_status": sorted(imap_status or []), - "query": (query_text or "").strip(), - "grid_filters": sorted((grid_filters or {}).items()), - "order": [f"{sort_by}:{sort_direction}", "id:asc"], - }, - ) - - -def _campaign_jobs_cursor_for_row(row: CampaignJob, *, fingerprint: str) -> str: - return encode_keyset_cursor( - CAMPAIGN_JOBS_CURSOR_SCOPE, - fingerprint=fingerprint, - values={"id": row.id, "entry_index": row.entry_index}, - ) - - -def _campaign_jobs_cursor_condition(cursor_values: dict[str, object]): - cursor_id = cursor_values.get("id") - cursor_index = cursor_values.get("entry_index") - if not isinstance(cursor_id, str) or not cursor_id: - raise KeysetCursorError("Invalid pagination cursor") - try: - entry_index = int(cursor_index) - except (TypeError, ValueError) as exc: - raise KeysetCursorError("Invalid pagination cursor") from exc - return or_(CampaignJob.entry_index > entry_index, and_(CampaignJob.entry_index == entry_index, CampaignJob.id > cursor_id)) - - -def _campaign_jobs_delta_watermark(session: Session, tenant_id: str) -> str: - return encode_sequence_watermark( - max_sequence_id( - session, - tenant_id=tenant_id, - module_id=CAMPAIGNS_MODULE_ID, - collections=(CAMPAIGN_JOBS_COLLECTION,), - ) - ) - - -class CampaignJobsQuery: - def __init__( - self, - version_id: str | None = None, - page: int = Query(default=1, ge=1), - page_size: int = Query(default=50, ge=1, le=200), - cursor: str | None = Query(default=None), - send_status: list[str] | None = Query(default=None), - validation_status: list[str] | None = Query(default=None), - imap_status: list[str] | None = Query(default=None), - query_text: str | None = Query(default=None, alias="q", max_length=200), - sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "postbox", "imap", "attempts", "updated"] = Query(default="number"), - sort_direction: Literal["asc", "desc"] = Query(default="asc"), - filter_recipient: str | None = Query(default=None, max_length=500), - filter_subject: str | None = Query(default=None, max_length=1000), - filter_validation: str | None = Query(default=None, max_length=1000), - filter_queue: str | None = Query(default=None, max_length=1000), - filter_send: str | None = Query(default=None, max_length=1000), - filter_postbox: str | None = Query(default=None, max_length=1000), - filter_imap: str | None = Query(default=None, max_length=1000), - filter_attempts: str | None = Query(default=None, max_length=100), - filter_evidence: str | None = Query(default=None, max_length=500), - ) -> None: - self.version_id = version_id - self.page = page - self.page_size = page_size - self.cursor = cursor - self.send_status = send_status - self.validation_status = validation_status - self.imap_status = imap_status - self.query_text = query_text - self.sort_by = sort_by - self.sort_direction = sort_direction - self.grid_filters = { - column_id: value - for column_id, value in { - "recipient": filter_recipient, - "subject": filter_subject, - "validation": filter_validation, - "queue": filter_queue, - "send": filter_send, - "postbox": filter_postbox, - "imap": filter_imap, - "attempts": filter_attempts, - "evidence": filter_evidence, - }.items() - if value is not None and value.strip() - } - - -@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) -def list_jobs( - campaign_id: str, - filters: CampaignJobsQuery = Depends(CampaignJobsQuery), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), -): - """Return a lightweight, paginated job list with server-side filters. - - Complete recipients, attachment metadata, issues and attempt history are - available from the separate job-detail endpoint. - """ - - _campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context( - session, - principal, - campaign_id=campaign_id, - version_id=filters.version_id, - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - ) - return _campaign_jobs_page_response( - session, - campaign_id=campaign_id, - version_id=filters.version_id, - base_filters=base_filters, - filtered=filtered, - reviewed_keys=reviewed_keys, - review_metadata=review_metadata, - page=filters.page, - page_size=filters.page_size, - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - sort_by=filters.sort_by, - sort_direction=filters.sort_direction, - cursor=filters.cursor, - ) - - -def _campaign_jobs_full_delta_response( - session: Session, - *, - principal: ApiPrincipal, - campaign_id: str, - version_id: str | None, - page: int, - page_size: int, - send_status: list[str] | None, - validation_status: list[str] | None, - imap_status: list[str] | None, - query_text: str | None, - grid_filters: dict[str, str] | None, - sort_by: str, - sort_direction: str, - cursor: str | None = None, -) -> CampaignJobsDeltaResponse: - _campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context( - session, - principal, - campaign_id=campaign_id, - version_id=version_id, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - grid_filters=grid_filters, - ) - payload = _campaign_jobs_page_response( - session, - campaign_id=campaign_id, - version_id=version_id, - base_filters=base_filters, - filtered=filtered, - reviewed_keys=reviewed_keys, - review_metadata=review_metadata, - page=page, - page_size=page_size, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - grid_filters=grid_filters, - sort_by=sort_by, - sort_direction=sort_direction, - cursor=cursor, - ) - return CampaignJobsDeltaResponse( - **payload.model_dump(), - deleted=[], - watermark=_campaign_jobs_delta_watermark(session, principal.tenant_id), - has_more=False, - full=True, - ) - - -def _job_filter_membership_can_shift( - *, - send_status: list[str] | None, - validation_status: list[str] | None, - imap_status: list[str] | None, - query_text: str | None, - grid_filters: dict[str, str] | None, - sort_by: str, - sort_direction: str, -) -> bool: - return bool( - send_status - or validation_status - or imap_status - or (query_text and query_text.strip()) - or grid_filters - or sort_by != "number" - or sort_direction != "asc" - ) - - -@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse) -def list_jobs_delta( - campaign_id: str, - filters: CampaignJobsQuery = Depends(CampaignJobsQuery), - 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_jobs_full_delta_response( - session, - principal=principal, - campaign_id=campaign_id, - version_id=filters.version_id, - page=filters.page, - page_size=filters.page_size, - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - sort_by=filters.sort_by, - sort_direction=filters.sort_direction, - cursor=filters.cursor, - ) - - campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context( - session, - principal, - campaign_id=campaign_id, - version_id=filters.version_id, - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - ) - 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_JOBS_COLLECTION,), - ): - return _campaign_jobs_full_delta_response( - session, - principal=principal, - campaign_id=campaign_id, - version_id=filters.version_id, - page=filters.page, - page_size=filters.page_size, - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - sort_by=filters.sort_by, - sort_direction=filters.sort_direction, - cursor=filters.cursor, - ) - - entries_plus_one = sequence_entries_since( - session, - since=since_sequence, - tenant_id=principal.tenant_id, - module_id=CAMPAIGNS_MODULE_ID, - collections=(CAMPAIGN_JOBS_COLLECTION,), - limit=limit + 1, - ) - has_more = len(entries_plus_one) > limit - entries = entries_plus_one[:limit] - relevant_entries = [ - entry - for entry in entries - if (entry.payload or {}).get("campaign_id") == campaign.id - and (not filters.version_id or (entry.payload or {}).get("version_id") == filters.version_id) - ] - - if relevant_entries and ( - _job_filter_membership_can_shift( - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - sort_by=filters.sort_by, - sort_direction=filters.sort_direction, - ) - or any(entry.operation in {"created", "deleted"} for entry in relevant_entries) - ): - return _campaign_jobs_full_delta_response( - session, - principal=principal, - campaign_id=campaign_id, - version_id=filters.version_id, - page=filters.page, - page_size=filters.page_size, - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - sort_by=filters.sort_by, - sort_direction=filters.sort_direction, - cursor=filters.cursor, - ) - - changed_job_ids = { - entry.resource_id - for entry in relevant_entries - if entry.resource_type == "campaign_job" and entry.operation != "deleted" - } - payload = _campaign_jobs_page_response( - session, - campaign_id=campaign_id, - version_id=filters.version_id, - base_filters=base_filters, - filtered=filtered, - reviewed_keys=reviewed_keys, - review_metadata=review_metadata, - page=filters.page, - page_size=filters.page_size, - send_status=filters.send_status, - validation_status=filters.validation_status, - imap_status=filters.imap_status, - query_text=filters.query_text, - grid_filters=filters.grid_filters, - sort_by=filters.sort_by, - sort_direction=filters.sort_direction, - cursor=filters.cursor, - changed_job_ids=changed_job_ids, - ) - watermark = ( - encode_sequence_watermark(entries[-1].id) - if has_more and entries - else _campaign_jobs_delta_watermark(session, principal.tenant_id) - ) - return CampaignJobsDeltaResponse( - **payload.model_dump(), - deleted=[], - watermark=watermark, - has_more=has_more, - full=False, - ) - - -@router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse) -def get_job_detail( - campaign_id: str, - job_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), -): - _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:read") - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - job = session.get(CampaignJob, job_id) - if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found") - send_attempts = _job_attempt_rows( - session.query(SendAttempt) - .filter(SendAttempt.job_id == job.id) - .order_by(SendAttempt.attempt_number.asc()), - label="SMTP attempts for this campaign job", - ) - imap_attempts = _job_attempt_rows( - session.query(ImapAppendAttempt) - .filter(ImapAppendAttempt.job_id == job.id) - .order_by(ImapAppendAttempt.attempt_number.asc()), - label="IMAP attempts for this campaign job", - ) - postbox_attempts = _job_attempt_rows( - session.query(PostboxDeliveryAttempt) - .filter(PostboxDeliveryAttempt.job_id == job.id) - .order_by( - PostboxDeliveryAttempt.target_index.asc(), - PostboxDeliveryAttempt.attempt_number.asc(), - ), - label="Postbox attempts for this campaign job", - ) - return CampaignJobDetailResponse( - job=_job_detail_payload(job), - attempts=_job_attempts_payload( - send_attempts, - imap_attempts, - postbox_attempts, - ), - ) - - -@router.get( - "/{campaign_id}/jobs/{job_id}/diagnostics", - response_model=CampaignJobDiagnosticsResponse, -) -def get_job_diagnostics( - campaign_id: str, - job_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")), -): - """Return infrastructure details only to campaign operators/admins.""" - - _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:read") - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - job = session.get(CampaignJob, job_id) - if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found") - send_attempts = _job_attempt_rows( - session.query(SendAttempt) - .filter(SendAttempt.job_id == job.id) - .order_by(SendAttempt.attempt_number.asc()), - label="SMTP diagnostics for this campaign job", - ) - imap_attempts = _job_attempt_rows( - session.query(ImapAppendAttempt) - .filter(ImapAppendAttempt.job_id == job.id) - .order_by(ImapAppendAttempt.attempt_number.asc()), - label="IMAP diagnostics for this campaign job", - ) - postbox_attempts = _job_attempt_rows( - session.query(PostboxDeliveryAttempt) - .filter(PostboxDeliveryAttempt.job_id == job.id) - .order_by( - PostboxDeliveryAttempt.target_index.asc(), - PostboxDeliveryAttempt.attempt_number.asc(), - ), - label="Postbox diagnostics for this campaign job", - ) - return _job_diagnostics_payload( - job, - send_attempts, - imap_attempts, - postbox_attempts, - ) - - -@router.get("/{campaign_id}/summary") -def campaign_summary( - campaign_id: str, - version_id: str | None = None, - include_jobs: bool = False, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), -): - """Return dashboard-friendly campaign status counters and summaries.""" - _get_campaign_for_principal(session, campaign_id, principal) - if include_jobs: - _require_permission(principal, "campaigns:recipient:read") - - try: - return generate_campaign_report( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - include_jobs=include_jobs, - include_recent_failures=include_jobs, - include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), - ) - except CampaignReportError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - -@router.get("/{campaign_id}/report") -def campaign_report( - campaign_id: str, - version_id: str | None = None, - include_jobs: bool = False, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")), -): - """Return the recipient-level JSON report for one campaign.""" - _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:read") - - try: - return generate_campaign_report( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - include_jobs=include_jobs, - include_recent_failures=include_jobs, - include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), - ) - except CampaignReportError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - - -@router.get("/{campaign_id}/report/jobs.csv") -def campaign_jobs_csv( - campaign_id: str, - version_id: str | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")), -): - """Export per-job campaign status as CSV.""" - _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:export") - - try: - csv_text = generate_jobs_csv( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), - ) - except CampaignReportError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - return Response( - content=csv_text, - media_type="text/csv; charset=utf-8", - headers={"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'}, - ) - - - - -@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse) -def email_campaign_report( - campaign_id: str, - payload: ReportEmailRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")), -): - """Generate a campaign report and send it to one or more email addresses.""" - campaign = _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:export") - selected_version_id = payload.version_id or campaign.current_version_id - selected_version = session.get(CampaignVersion, selected_version_id) if selected_version_id else None - if selected_version is not None and selected_version.campaign_id == campaign.id: - _require_mail_profile_use_if_needed( - principal, - selected_version.raw_json if isinstance(selected_version.raw_json, dict) else {}, - ) - try: - result = send_campaign_report_email( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=payload.version_id, - to=payload.to, - include_jobs=payload.include_jobs, - attach_jobs_csv=payload.attach_jobs_csv, - attach_report_json=payload.attach_report_json, - dry_run=payload.dry_run, - ) - audit_from_principal( - session, - principal, - action="report.email_sent" if not payload.dry_run else "report.email_dry_run", - object_type="campaign", - object_id=campaign_id, - details=result.as_dict(), - commit=True, - ) - return ReportEmailResponse(result=result.as_dict()) - except CampaignReportError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - except (CampaignReportEmailError, MailProfileError, SmtpConfigurationError, SmtpSendError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except Exception as exc: - logger.error("Campaign report email failed with an unexpected internal error") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Campaign report email could not be completed.", - ) from exc - - - - -@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse) -def list_campaign_share_targets( - campaign_id: str, - limit: int = Query(default=500, ge=1, le=1000), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - directory = _access_directory() - users = [user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active"] - groups = [group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active"] - if len(users) > limit or len(groups) > limit: - raise HTTPException( - status_code=status.HTTP_413_CONTENT_TOO_LARGE, - detail=( - f"Campaign share targets exceed the maximum response size of {limit} " - "users or groups. Use a searchable directory selector." - ), - ) - return CampaignShareTargetsResponse( - users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users], - groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups], - ) - - -@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse) -def list_campaign_shares( - campaign_id: str, - page: int = Query(default=1, ge=1), - page_size: int = Query(default=500, ge=1, le=1000), - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), -): - campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) - query = ( - session.query(CampaignShare) - .filter(CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None)) - ) - total = query.order_by(None).count() - pages = max(1, (total + page_size - 1) // page_size) - shares = ( - query.order_by( - CampaignShare.target_type.asc(), - CampaignShare.target_id.asc(), - CampaignShare.id.asc(), - ) - .offset((page - 1) * page_size) - .limit(page_size) - .all() - ) - return CampaignShareListResponse( - shares=[CampaignShareItem.model_validate(item) for item in shares], - total=total, - page=page, - page_size=page_size, - pages=pages, - ) - - -@router.put("/{campaign_id}/owner", response_model=CampaignResponse) -def update_campaign_owner( - campaign_id: str, - payload: CampaignOwnerUpdateRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), -): - campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) - if payload.owner_user_id and payload.owner_group_id: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Choose either a user owner or a group owner, not both") - directory = _access_directory() - if payload.owner_user_id: - owner = directory.get_user(payload.owner_user_id) - if owner is not None and (owner.tenant_id != principal.tenant_id or owner.status != "active"): - owner = None - if owner is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found") - if payload.owner_group_id: - group = directory.get_group(payload.owner_group_id) - if group is not None and (group.tenant_id != principal.tenant_id or group.status != "active"): - group = None - if group is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found") - owner_changed = campaign.owner_user_id != payload.owner_user_id or campaign.owner_group_id != payload.owner_group_id - mail_profile_reselection_required = False - if owner_changed: - mail_profile_reselection_required = _clear_current_version_mail_profile_for_owner_transfer(session, campaign) - - campaign.owner_user_id = payload.owner_user_id - campaign.owner_group_id = payload.owner_group_id - session.add(campaign) - audit_from_principal( - session, - principal, - action="campaign.owner_updated", - object_type="campaign", - object_id=campaign.id, - details={**payload.model_dump(), "mail_profile_reselection_required": mail_profile_reselection_required}, - commit=True, - ) - return CampaignResponse.model_validate(campaign) - - -@router.post("/{campaign_id}/shares", response_model=CampaignShareItem, status_code=status.HTTP_201_CREATED) -def upsert_campaign_share( - campaign_id: str, - payload: CampaignShareUpsertRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), -): - campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) - directory = _access_directory() - if payload.target_type == "user": - target = directory.get_user(payload.target_id) - if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"): - target = None - else: - target = directory.get_group(payload.target_id) - if target is not None and (target.tenant_id != principal.tenant_id or target.status != "active"): - target = None - if target is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found") - share = ( - session.query(CampaignShare) - .filter(CampaignShare.campaign_id == campaign.id, CampaignShare.target_type == payload.target_type, CampaignShare.target_id == payload.target_id) - .one_or_none() - ) - if share is None: - share = CampaignShare( - tenant_id=principal.tenant_id, - campaign_id=campaign.id, - target_type=payload.target_type, - target_id=payload.target_id, - permission=payload.permission, - created_by_user_id=principal.user.id, - ) - else: - share.permission = payload.permission - share.revoked_at = None - session.add(share) - audit_from_principal(session, principal, action="campaign.share_upserted", object_type="campaign", object_id=campaign.id, details=payload.model_dump(), commit=True) - return CampaignShareItem.model_validate(share) - - -@router.delete("/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT) -def revoke_campaign_share( - campaign_id: str, - share_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), -): - campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) - share = session.query(CampaignShare).filter(CampaignShare.id == share_id, CampaignShare.campaign_id == campaign.id, CampaignShare.tenant_id == principal.tenant_id).one_or_none() - if share is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found") - share.revoked_at = utc_now() - session.add(share) - audit_from_principal(session, principal, action="campaign.share_revoked", object_type="campaign", object_id=campaign.id, details={"share_id": share_id}, commit=True) - return None - -# Queue / delivery control ------------------------------------------------- - - -@router.get("/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse) -def campaign_delivery_options( - campaign_id: str, - version_id: str | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends( - require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue") - ), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - try: - return CampaignDeliveryOptionsResponse( - **synchronous_send_options( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - ), - postbox_available=postbox_integration().available, - ) - except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse) -def queue_campaign( - campaign_id: str, - payload: QueueCampaignRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - payload = payload or QueueCampaignRequest() - _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) - try: - result = queue_campaign_jobs( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=payload.version_id, - include_warnings=payload.include_warnings, - enqueue_celery=payload.enqueue_celery, - dry_run=payload.dry_run, - ) - audit_from_principal( - session, - principal, - action="campaign.queued" if not payload.dry_run else "campaign.queue_dry_run", - object_type="campaign", - object_id=campaign_id, - details=result.as_dict(), - commit=True, - ) - return QueueCampaignResponse(**result.as_dict()) - except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/jobs/retry", response_model=CampaignActionResponse) -def retry_campaign_jobs( - campaign_id: str, - payload: CampaignRetryJobsRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:retry")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - payload = payload or CampaignRetryJobsRequest() - _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) - try: - result = queue_failed_jobs_for_retry( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=payload.version_id, - job_ids=payload.job_ids or None, - include_permanent=payload.include_permanent, - force_max_attempts=payload.force_max_attempts, - enqueue_celery=payload.enqueue_celery, - dry_run=payload.dry_run, - ) - audit_from_principal( - session, - principal, - action="campaign.jobs_retry_queued" if not payload.dry_run else "campaign.jobs_retry_dry_run", - object_type="campaign", - object_id=campaign_id, - details=result, - commit=True, - ) - return CampaignActionResponse(result=result) - except (QueueingError, ExecutionSnapshotError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse) -def send_unattempted_campaign_jobs( - campaign_id: str, - payload: CampaignSendUnattemptedRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - payload = payload or CampaignSendUnattemptedRequest() - _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) - try: - result = queue_unattempted_jobs( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=payload.version_id, - job_ids=payload.job_ids or None, - enqueue_celery=payload.enqueue_celery, - dry_run=payload.dry_run, - ) - audit_from_principal( - session, - principal, - action="campaign.unattempted_jobs_queued" if not payload.dry_run else "campaign.unattempted_jobs_dry_run", - object_type="campaign", - object_id=campaign_id, - details=result, - commit=True, - ) - return CampaignActionResponse(result=result) - except (QueueingError, ExecutionSnapshotError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/jobs/{job_id}/send", response_model=CampaignActionResponse) -def send_single_campaign_job_endpoint( - campaign_id: str, - job_id: str, - payload: CampaignSendJobRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - payload = payload or CampaignSendJobRequest() - _require_campaign_profile_use_if_needed(session, principal, campaign_id, None) - try: - result = send_single_campaign_job( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - job_id=job_id, - include_warnings=payload.include_warnings, - dry_run=payload.dry_run, - use_rate_limit=payload.use_rate_limit, - enqueue_imap_task=payload.enqueue_imap_task, - ) - audit_from_principal( - session, - principal, - action="campaign.single_message_sent" if not payload.dry_run else "campaign.single_message_send_dry_run", - object_type="campaign_job", - object_id=job_id, - details=result, - commit=True, - ) - return CampaignActionResponse(result=result) - except (QueueingError, ExecutionSnapshotError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/jobs/{job_id}/resolve-outcome", response_model=CampaignActionResponse) -def resolve_campaign_job_outcome( - campaign_id: str, - job_id: str, - payload: CampaignResolveOutcomeRequest, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - try: - result = reconcile_job_outcome( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - job_id=job_id, - decision=payload.decision, - note=payload.note, - attempt_id=payload.attempt_id, - commit=False, - ) - audit_from_principal( - session, - principal, - action="campaign.job_outcome_reconciled", - object_type="campaign_job", - object_id=job_id, - details=result, - commit=True, - ) - return CampaignActionResponse(result=result) - except (QueueingError, ExecutionSnapshotError) as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except Exception: - session.rollback() - raise - - -@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse) -def mock_send_campaign( - campaign_id: str, - payload: MockCampaignSendRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")), -): - """Run a fully visible mock delivery flow without mutating campaign state. - - The route validates and builds the selected version, then optionally records - mock SMTP deliveries and mock IMAP appends. It never talks to the configured - real SMTP/IMAP servers and it does not mark the version sent/final. - """ - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - - payload = payload or MockCampaignSendRequest() - _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) - try: - result = run_mock_campaign_send( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=payload.version_id, - send=payload.send, - include_warnings=payload.include_warnings, - include_needs_review=payload.include_needs_review, - append_sent=payload.append_sent, - clear_mailbox=payload.clear_mailbox, - check_files=payload.check_files, - ) - audit_from_principal( - session, - principal, - action="campaign.mock_send" if payload.send else "campaign.mock_send_review", - object_type="campaign", - object_id=campaign_id, - details={ - "version_id": result.get("version_id"), - "send_requested": payload.send, - "sent_count": result.get("send", {}).get("sent_count"), - "failed_count": result.get("send", {}).get("failed_count"), - }, - commit=True, - ) - return MockCampaignSendResponse(result=result) - except MockCampaignSendError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse) -def send_campaign_now_endpoint( - campaign_id: str, - payload: SendCampaignNowRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), -): - """Preflight and synchronously send a policy-bounded built execution.""" - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - - payload = payload or SendCampaignNowRequest() - try: - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - version_id = payload.version_id or campaign.current_version_id - if not version_id: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Campaign has no current version") - - version = _get_version_for_tenant(session, version_id, principal.tenant_id) - _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) - validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None - build_result: dict[str, object] | None = version.build_summary if isinstance(version.build_summary, dict) else None - if is_user_locked_version(version): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.", - ) - if not version.locked_at or not validation_result or validation_result.get("ok") is not True: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Campaign version must be validated and locked before dry-run or sending.", - ) - if not build_result: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, - detail="Campaign version must be built before dry-run or sending.", - ) - - delivery_result = send_campaign_now( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - include_warnings=payload.include_warnings, - dry_run=payload.dry_run, - use_rate_limit=payload.use_rate_limit, - enqueue_imap_task=payload.enqueue_imap_task, - ).as_dict() - response_result = public_send_campaign_now_result( - delivery_result, - validation_summary=validation_result, - build_summary=build_result, - ) - audit_from_principal( - session, - principal, - action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run", - object_type="campaign", - object_id=campaign_id, - details=send_campaign_now_audit_details(delivery_result), - commit=True, - ) - return SendCampaignNowResponse(result=response_result) - except SynchronousSendRejected as exc: - # A synchronous request stages queue state before the all-message - # preflight can run. Rejecting that preflight must not leave work - # eligible for a background worker when no provider effect occurred. - session.rollback() - audit_from_principal( - session, - principal, - action="campaign.send_now_rejected", - object_type="campaign", - object_id=campaign_id, - details={ - **exc.audit_details(), - "version_id": payload.version_id, - }, - commit=True, - ) - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except HTTPException: - raise - except (CampaignPersistenceError, QueueingError) as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - - -@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse) -def pause_campaign( - campaign_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - result = pause_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) - audit_from_principal(session, principal, action="campaign.paused", object_type="campaign", object_id=campaign_id, details=result, commit=True) - return CampaignActionResponse(result=result) - except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse) -def resume_campaign( - campaign_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - version_ids = { - row[0] - for row in session.query(CampaignJob.campaign_version_id) - .filter( - CampaignJob.tenant_id == principal.tenant_id, - CampaignJob.campaign_id == campaign_id, - CampaignJob.queue_status == JobQueueStatus.PAUSED.value, - ) - .distinct() - .all() - } - _require_campaign_versions_profile_use(session, principal, campaign_id, version_ids) - try: - result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) - audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True) - return CampaignActionResponse(result=result) - except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse) -def cancel_campaign( - campaign_id: str, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - result = cancel_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) - audit_from_principal(session, principal, action="campaign.cancelled", object_type="campaign", object_id=campaign_id, details=result, commit=True) - return CampaignActionResponse(result=result) - except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse) -def append_sent( - campaign_id: str, - payload: AppendSentRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), -): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - payload = payload or AppendSentRequest() - version_ids = { - row[0] - for row in session.query(CampaignJob.campaign_version_id) - .filter( - CampaignJob.tenant_id == principal.tenant_id, - CampaignJob.campaign_id == campaign_id, - CampaignJob.send_status.in_( - [JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value] - ), - CampaignJob.imap_status.in_( - [JobImapStatus.PENDING.value, JobImapStatus.FAILED.value] - ), - ) - .distinct() - .all() - } - _require_campaign_versions_profile_use(session, principal, campaign_id, version_ids) - try: - result = enqueue_pending_imap_appends( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - enqueue_celery=payload.enqueue_celery, - run_inline=payload.run_inline, - dry_run=payload.dry_run, - ) - audit_from_principal( - session, - principal, - action="campaign.append_sent_enqueued" if not payload.dry_run else "campaign.append_sent_dry_run", - object_type="campaign", - object_id=campaign_id, - details=result, - commit=True, - ) - return CampaignActionResponse(result=result) - except QueueingError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -class CampaignAttachmentPreviewRequest(BaseModel): - include_unmatched: bool = True - include_unlinked_candidates: bool = False - campaign_json: dict[str, object] | None = None - - -class CampaignAttachmentPreviewResponse(BaseModel): - campaign_id: str - version_id: str - shared_file_count: int - candidate_file_count: int = 0 - matched_file_count: int = 0 - linked_file_count: int = 0 - unlinked_file_count: int = 0 - rules: list[dict[str, object]] = Field(default_factory=list) - linkable_files: list[dict[str, object]] = Field(default_factory=list) - unused_shared_files: list[dict[str, object]] = Field(default_factory=list) - - -class CampaignAttachmentLinkMatchesRequest(BaseModel): - campaign_json: dict[str, object] | None = None - dry_run: bool = False - - -class CampaignAttachmentLinkMatchesResponse(BaseModel): - campaign_id: str - version_id: str - matched_file_count: int - already_linked_file_count: int - linked_file_count: int - dry_run: bool = False - linked_files: list[dict[str, object]] = Field(default_factory=list) - linkable_files: list[dict[str, object]] = Field(default_factory=list) - - -def _file_preview(session: Session, asset) -> dict[str, object]: - version, blob = files_integration().current_version_and_blob(session, asset) - return { - "id": asset.id, - "version_id": version.id, - "blob_id": blob.id, - "display_path": asset.display_path, - "filename": asset.filename, - "owner_type": asset.owner_type, - "owner_id": asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id, - "checksum_sha256": blob.checksum_sha256, - "size_bytes": blob.size_bytes, - "content_type": blob.content_type, - "linked_to_campaign": True, - } - - -def _managed_preview_file(item: dict[str, object]) -> dict[str, object]: - return { - "id": item["asset_id"], - "version_id": item["version_id"], - "blob_id": item["blob_id"], - "display_path": item["display_path"], - "filename": item["filename"], - "owner_type": item["owner_type"], - "owner_id": item["owner_id"], - "checksum_sha256": item["checksum_sha256"], - "size_bytes": item["size_bytes"], - "content_type": item["content_type"], - "linked_to_campaign": bool(item.get("linked_to_campaign", True)), - } - - -def _attachment_preview_for_version( - session: Session, - principal: ApiPrincipal, - *, - campaign: Campaign, - version: CampaignVersion, - raw: dict[str, object], - include_unmatched: bool, - include_unlinked_candidates: bool, -) -> CampaignAttachmentPreviewResponse: - files = files_integration() - assert_server_safe_campaign_paths(raw, managed_files_available=files.available) - with files.prepared_campaign_snapshot( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign.id, - raw_json=raw, - include_bytes=False, - prefix="govoplan-managed-preview-", - include_unlinked_candidates=include_unlinked_candidates, - user_id=principal.user.id, - is_admin=has_scope(principal, "files:file:admin"), - ) as prepared: - prepared_raw = load_campaign_json(prepared.path) - config = load_campaign_config_from_json(session, tenant_id=principal.tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) - report = resolve_campaign_attachments(config, campaign_file=prepared.path) - rules: list[dict[str, object]] = [] - matched_asset_ids: set[str] = set() - linked_asset_ids: set[str] = set() - linkable_by_id: dict[str, dict[str, object]] = {} - - for entry in report.entries: - for attachment in entry.attachments: - managed_matches = files.managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path) - matches: list[dict[str, object]] = [] - for item in managed_matches: - asset_id = str(item["asset_id"]) - matched_asset_ids.add(asset_id) - if bool(item.get("linked_to_campaign", True)): - linked_asset_ids.add(asset_id) - preview = _managed_preview_file(item) - matches.append(preview) - if not preview["linked_to_campaign"]: - linkable_by_id.setdefault(asset_id, preview) - if not matches: - matches = [ - { - "id": "", - "display_path": match, - "filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], - "owner_type": "legacy", - "owner_id": "", - "linked_to_campaign": True, - } - for match in attachment.matches - ] - rules.append({ - "source": attachment.scope.value, - "entry_index": entry.entry_index, - "entry_id": entry.entry_id, - "index": attachment.index, - "attachment_id": attachment.attachment_id, - "label": attachment.label, - "required": attachment.required, - "pattern": attachment.file_filter, - "base_path_name": attachment.base_path_name, - "base_path": attachment.base_path, - "status": attachment.status.value, - "behavior": attachment.behavior.value if attachment.behavior else None, - "zip_included": attachment.zip_enabled, - "zip_mode": attachment.zip_mode.value, - "zip_archive_id": attachment.zip_archive_id, - "zip_filename": attachment.zip_filename, - "matches": matches, - "match_count": len(matches), - "linked_match_count": sum(1 for match in matches if bool(match.get("linked_to_campaign", True))), - "unlinked_match_count": sum(1 for match in matches if not bool(match.get("linked_to_campaign", True))), - "issues": [issue.model_dump(mode="json") for issue in attachment.issues], - }) - - unused = [asset for asset in prepared.shared_assets if asset.id not in matched_asset_ids] - return CampaignAttachmentPreviewResponse( - campaign_id=campaign.id, - version_id=version.id, - shared_file_count=len(prepared.shared_assets), - candidate_file_count=len(getattr(prepared, "candidate_assets", prepared.shared_assets)), - matched_file_count=len(matched_asset_ids), - linked_file_count=len(linked_asset_ids), - unlinked_file_count=len(linkable_by_id), - rules=rules, - linkable_files=list(linkable_by_id.values()), - unused_shared_files=[_file_preview(session, asset) for asset in unused] if include_unmatched else [], - ) - - -def _link_campaign_attachment_matches( - session: Session, - principal: ApiPrincipal, - *, - campaign: Campaign, - version: CampaignVersion, - raw: dict[str, object], - dry_run: bool = False, -) -> CampaignAttachmentLinkMatchesResponse: - preview = _attachment_preview_for_version( - session, - principal, - campaign=campaign, - version=version, - raw=raw, - include_unmatched=False, - include_unlinked_candidates=True, - ) - file_ids = [str(item.get("id") or "") for item in preview.linkable_files if item.get("id")] - linked_files: list[dict[str, object]] = [] - if file_ids and not dry_run: - files = files_integration() - shares = files.share_assets_with_campaign( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign.id, - file_ids=file_ids, - user_id=principal.user.id, - is_admin=has_scope(principal, "files:file:admin"), - ) - share_by_asset_id = {str(item.get("file_asset_id") or ""): item for item in shares} - linked_files = [ - {**item, "share": share_by_asset_id.get(str(item.get("id") or ""))} - for item in preview.linkable_files - ] - return CampaignAttachmentLinkMatchesResponse( - campaign_id=campaign.id, - version_id=version.id, - matched_file_count=preview.matched_file_count, - already_linked_file_count=preview.linked_file_count, - linked_file_count=0 if dry_run else len(file_ids), - dry_run=dry_run, - linked_files=linked_files, - linkable_files=preview.linkable_files, - ) - - -@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse) -def preview_campaign_attachments( - campaign_id: str, - version_id: str, - payload: CampaignAttachmentPreviewRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("files:file:read")), -): - _get_campaign_for_principal(session, campaign_id, principal) - _require_permission(principal, "campaigns:recipient:read") - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - version = _get_version_for_tenant(session, version_id, principal.tenant_id) - if version.campaign_id != campaign.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") - - payload = payload or CampaignAttachmentPreviewRequest() - raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json - raw = raw if isinstance(raw, dict) else {} - _require_mail_profile_use_if_needed(principal, raw) - try: - return _attachment_preview_for_version( - session, - principal, - campaign=campaign, - version=version, - raw=raw, - include_unmatched=payload.include_unmatched, - include_unlinked_candidates=payload.include_unlinked_candidates, - ) - except CampaignPathSecurityError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc - - -@router.post("/{campaign_id}/versions/{version_id}/attachments/link-matches", response_model=CampaignAttachmentLinkMatchesResponse) -def link_campaign_attachment_matches( - campaign_id: str, - version_id: str, - payload: CampaignAttachmentLinkMatchesRequest | None = None, - session: Session = Depends(get_session), - principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), -): - _require_permission(principal, "files:file:share") - _get_campaign_for_principal(session, campaign_id, principal, write=True) - _require_permission(principal, "campaigns:recipient:read") - campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) - version = _get_version_for_tenant(session, version_id, principal.tenant_id) - if version.campaign_id != campaign.id: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") - if is_user_locked_version(version) or is_version_final_locked(version): - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Locked campaign versions cannot link new attachment files") - payload = payload or CampaignAttachmentLinkMatchesRequest() - raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json - raw = raw if isinstance(raw, dict) else {} - _require_mail_profile_use_if_needed(principal, raw) - try: - result = _link_campaign_attachment_matches( - session, - principal, - campaign=campaign, - version=version, - raw=raw, - dry_run=payload.dry_run, - ) - audit_from_principal( - session, - principal, - action="campaign.attachment_matches_linked" if not payload.dry_run else "campaign.attachment_matches_link_previewed", - object_type="campaign_version", - object_id=version_id, - details={ - "matched_file_count": result.matched_file_count, - "already_linked_file_count": result.already_linked_file_count, - "linked_file_count": result.linked_file_count, - "dry_run": result.dry_run, - }, - commit=True, - ) - return result - except HTTPException: - raise - except Exception as exc: - session.rollback() - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + router.routes.extend(workflow_router.routes) diff --git a/src/govoplan_campaign/backend/routes/__init__.py b/src/govoplan_campaign/backend/routes/__init__.py new file mode 100644 index 0000000..0691d55 --- /dev/null +++ b/src/govoplan_campaign/backend/routes/__init__.py @@ -0,0 +1 @@ +"""Focused HTTP route modules for the campaign API.""" diff --git a/src/govoplan_campaign/backend/routes/attachments.py b/src/govoplan_campaign/backend/routes/attachments.py new file mode 100644 index 0000000..bae23f9 --- /dev/null +++ b/src/govoplan_campaign/backend/routes/attachments.py @@ -0,0 +1,398 @@ +from __future__ import annotations + + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field + +from govoplan_core.auth import ApiPrincipal, has_scope, require_scope +from govoplan_core.audit.logging import audit_from_principal +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignVersion, +) +from govoplan_core.db.session import get_session +from govoplan_campaign.backend.persistence.campaigns import ( + load_campaign_config_from_json, +) +from govoplan_campaign.backend.integrations import ( + files_integration, +) +from govoplan_campaign.backend.path_security import ( + CampaignPathSecurityError, + assert_server_safe_campaign_paths, +) +from govoplan_campaign.backend.campaign.loader import load_campaign_json +from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments +from govoplan_campaign.backend.persistence.versions import ( + is_version_final_locked, + is_user_locked_version, +) + + +from govoplan_campaign.backend.route_support import ( + _get_campaign_for_principal, + _get_campaign_for_tenant, + _get_version_for_tenant, + _require_mail_profile_use_if_needed, + _require_permission, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +class CampaignAttachmentPreviewRequest(BaseModel): + include_unmatched: bool = True + include_unlinked_candidates: bool = False + campaign_json: dict[str, object] | None = None + + +class CampaignAttachmentPreviewResponse(BaseModel): + campaign_id: str + version_id: str + shared_file_count: int + candidate_file_count: int = 0 + matched_file_count: int = 0 + linked_file_count: int = 0 + unlinked_file_count: int = 0 + rules: list[dict[str, object]] = Field(default_factory=list) + linkable_files: list[dict[str, object]] = Field(default_factory=list) + unused_shared_files: list[dict[str, object]] = Field(default_factory=list) + + +class CampaignAttachmentLinkMatchesRequest(BaseModel): + campaign_json: dict[str, object] | None = None + dry_run: bool = False + + +class CampaignAttachmentLinkMatchesResponse(BaseModel): + campaign_id: str + version_id: str + matched_file_count: int + already_linked_file_count: int + linked_file_count: int + dry_run: bool = False + linked_files: list[dict[str, object]] = Field(default_factory=list) + linkable_files: list[dict[str, object]] = Field(default_factory=list) + + +def _file_preview(session: Session, asset) -> dict[str, object]: + version, blob = files_integration().current_version_and_blob(session, asset) + return { + "id": asset.id, + "version_id": version.id, + "blob_id": blob.id, + "display_path": asset.display_path, + "filename": asset.filename, + "owner_type": asset.owner_type, + "owner_id": asset.owner_user_id + if asset.owner_type == "user" + else asset.owner_group_id, + "checksum_sha256": blob.checksum_sha256, + "size_bytes": blob.size_bytes, + "content_type": blob.content_type, + "linked_to_campaign": True, + } + + +def _managed_preview_file(item: dict[str, object]) -> dict[str, object]: + return { + "id": item["asset_id"], + "version_id": item["version_id"], + "blob_id": item["blob_id"], + "display_path": item["display_path"], + "filename": item["filename"], + "owner_type": item["owner_type"], + "owner_id": item["owner_id"], + "checksum_sha256": item["checksum_sha256"], + "size_bytes": item["size_bytes"], + "content_type": item["content_type"], + "linked_to_campaign": bool(item.get("linked_to_campaign", True)), + } + + +def _attachment_preview_for_version( + session: Session, + principal: ApiPrincipal, + *, + campaign: Campaign, + version: CampaignVersion, + raw: dict[str, object], + include_unmatched: bool, + include_unlinked_candidates: bool, +) -> CampaignAttachmentPreviewResponse: + files = files_integration() + assert_server_safe_campaign_paths(raw, managed_files_available=files.available) + with files.prepared_campaign_snapshot( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign.id, + raw_json=raw, + include_bytes=False, + prefix="govoplan-managed-preview-", + include_unlinked_candidates=include_unlinked_candidates, + user_id=principal.user.id, + is_admin=has_scope(principal, "files:file:admin"), + ) as prepared: + prepared_raw = load_campaign_json(prepared.path) + config = load_campaign_config_from_json( + session, + tenant_id=principal.tenant_id, + raw_json=prepared_raw, + campaign_id=campaign.id, + ) + report = resolve_campaign_attachments(config, campaign_file=prepared.path) + rules: list[dict[str, object]] = [] + matched_asset_ids: set[str] = set() + linked_asset_ids: set[str] = set() + linkable_by_id: dict[str, dict[str, object]] = {} + + for entry in report.entries: + for attachment in entry.attachments: + managed_matches = files.managed_match_payloads( + attachment.matches, prepared.managed_files_by_local_path + ) + matches: list[dict[str, object]] = [] + for item in managed_matches: + asset_id = str(item["asset_id"]) + matched_asset_ids.add(asset_id) + if bool(item.get("linked_to_campaign", True)): + linked_asset_ids.add(asset_id) + preview = _managed_preview_file(item) + matches.append(preview) + if not preview["linked_to_campaign"]: + linkable_by_id.setdefault(asset_id, preview) + if not matches: + matches = [ + { + "id": "", + "display_path": match, + "filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], + "owner_type": "legacy", + "owner_id": "", + "linked_to_campaign": True, + } + for match in attachment.matches + ] + rules.append( + { + "source": attachment.scope.value, + "entry_index": entry.entry_index, + "entry_id": entry.entry_id, + "index": attachment.index, + "attachment_id": attachment.attachment_id, + "label": attachment.label, + "required": attachment.required, + "pattern": attachment.file_filter, + "base_path_name": attachment.base_path_name, + "base_path": attachment.base_path, + "status": attachment.status.value, + "behavior": attachment.behavior.value + if attachment.behavior + else None, + "zip_included": attachment.zip_enabled, + "zip_mode": attachment.zip_mode.value, + "zip_archive_id": attachment.zip_archive_id, + "zip_filename": attachment.zip_filename, + "matches": matches, + "match_count": len(matches), + "linked_match_count": sum( + 1 + for match in matches + if bool(match.get("linked_to_campaign", True)) + ), + "unlinked_match_count": sum( + 1 + for match in matches + if not bool(match.get("linked_to_campaign", True)) + ), + "issues": [ + issue.model_dump(mode="json") for issue in attachment.issues + ], + } + ) + + unused = [ + asset + for asset in prepared.shared_assets + if asset.id not in matched_asset_ids + ] + return CampaignAttachmentPreviewResponse( + campaign_id=campaign.id, + version_id=version.id, + shared_file_count=len(prepared.shared_assets), + candidate_file_count=len( + getattr(prepared, "candidate_assets", prepared.shared_assets) + ), + matched_file_count=len(matched_asset_ids), + linked_file_count=len(linked_asset_ids), + unlinked_file_count=len(linkable_by_id), + rules=rules, + linkable_files=list(linkable_by_id.values()), + unused_shared_files=[_file_preview(session, asset) for asset in unused] + if include_unmatched + else [], + ) + + +def _link_campaign_attachment_matches( + session: Session, + principal: ApiPrincipal, + *, + campaign: Campaign, + version: CampaignVersion, + raw: dict[str, object], + dry_run: bool = False, +) -> CampaignAttachmentLinkMatchesResponse: + preview = _attachment_preview_for_version( + session, + principal, + campaign=campaign, + version=version, + raw=raw, + include_unmatched=False, + include_unlinked_candidates=True, + ) + file_ids = [ + str(item.get("id") or "") for item in preview.linkable_files if item.get("id") + ] + linked_files: list[dict[str, object]] = [] + if file_ids and not dry_run: + files = files_integration() + shares = files.share_assets_with_campaign( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign.id, + file_ids=file_ids, + user_id=principal.user.id, + is_admin=has_scope(principal, "files:file:admin"), + ) + share_by_asset_id = { + str(item.get("file_asset_id") or ""): item for item in shares + } + linked_files = [ + {**item, "share": share_by_asset_id.get(str(item.get("id") or ""))} + for item in preview.linkable_files + ] + return CampaignAttachmentLinkMatchesResponse( + campaign_id=campaign.id, + version_id=version.id, + matched_file_count=preview.matched_file_count, + already_linked_file_count=preview.linked_file_count, + linked_file_count=0 if dry_run else len(file_ids), + dry_run=dry_run, + linked_files=linked_files, + linkable_files=preview.linkable_files, + ) + + +@router.post( + "/{campaign_id}/versions/{version_id}/attachments/preview", + response_model=CampaignAttachmentPreviewResponse, +) +def preview_campaign_attachments( + campaign_id: str, + version_id: str, + payload: CampaignAttachmentPreviewRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" + ) + + payload = payload or CampaignAttachmentPreviewRequest() + raw = ( + payload.campaign_json + if isinstance(payload.campaign_json, dict) + else version.raw_json + ) + raw = raw if isinstance(raw, dict) else {} + _require_mail_profile_use_if_needed(principal, raw) + try: + return _attachment_preview_for_version( + session, + principal, + campaign=campaign, + version=version, + raw=raw, + include_unmatched=payload.include_unmatched, + include_unlinked_candidates=payload.include_unlinked_candidates, + ) + except CampaignPathSecurityError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post( + "/{campaign_id}/versions/{version_id}/attachments/link-matches", + response_model=CampaignAttachmentLinkMatchesResponse, +) +def link_campaign_attachment_matches( + campaign_id: str, + version_id: str, + payload: CampaignAttachmentLinkMatchesRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), +): + _require_permission(principal, "files:file:share") + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" + ) + if is_user_locked_version(version) or is_version_final_locked(version): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Locked campaign versions cannot link new attachment files", + ) + payload = payload or CampaignAttachmentLinkMatchesRequest() + raw = ( + payload.campaign_json + if isinstance(payload.campaign_json, dict) + else version.raw_json + ) + raw = raw if isinstance(raw, dict) else {} + _require_mail_profile_use_if_needed(principal, raw) + try: + result = _link_campaign_attachment_matches( + session, + principal, + campaign=campaign, + version=version, + raw=raw, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.attachment_matches_linked" + if not payload.dry_run + else "campaign.attachment_matches_link_previewed", + object_type="campaign_version", + object_id=version_id, + details={ + "matched_file_count": result.matched_file_count, + "already_linked_file_count": result.already_linked_file_count, + "linked_file_count": result.linked_file_count, + "dry_run": result.dry_run, + }, + commit=True, + ) + return result + except HTTPException: + raise + except Exception as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc diff --git a/src/govoplan_campaign/backend/routes/campaigns.py b/src/govoplan_campaign/backend/routes/campaigns.py new file mode 100644 index 0000000..7081a36 --- /dev/null +++ b/src/govoplan_campaign/backend/routes/campaigns.py @@ -0,0 +1,1362 @@ +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) diff --git a/src/govoplan_campaign/backend/routes/delivery.py b/src/govoplan_campaign/backend/routes/delivery.py new file mode 100644 index 0000000..0e4e7bf --- /dev/null +++ b/src/govoplan_campaign/backend/routes/delivery.py @@ -0,0 +1,639 @@ +from __future__ import annotations + + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.schemas import ( + AppendSentRequest, + CampaignActionResponse, + CampaignRetryJobsRequest, + CampaignSendJobRequest, + CampaignSendUnattemptedRequest, + CampaignResolveOutcomeRequest, + CampaignDeliveryOptionsResponse, + MockCampaignSendRequest, + MockCampaignSendResponse, + QueueCampaignRequest, + QueueCampaignResponse, + SendCampaignNowRequest, + SendCampaignNowResponse, +) +from govoplan_core.auth import ApiPrincipal, require_any_scope, require_scope +from govoplan_core.audit.logging import audit_from_principal +from govoplan_campaign.backend.db.models import ( + CampaignJob, + JobImapStatus, + JobQueueStatus, + JobSendStatus, +) +from govoplan_campaign.backend.integrations import ( + postbox_integration, +) +from govoplan_core.db.session import get_session +from govoplan_campaign.backend.response_security import ( + public_send_campaign_now_result, + send_campaign_now_audit_details, +) +from govoplan_campaign.backend.persistence.campaigns import ( + CampaignPersistenceError, +) +from govoplan_campaign.backend.persistence.versions import ( + is_user_locked_version, +) + +from govoplan_campaign.backend.dev.mock_campaign import ( + MockCampaignSendError, + run_mock_campaign_send, +) +from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError +from govoplan_campaign.backend.sending.jobs import ( + QueueingError, + SynchronousSendRejected, + cancel_campaign_jobs, + enqueue_pending_imap_appends, + pause_campaign_jobs, + queue_campaign_jobs, + queue_failed_jobs_for_retry, + queue_unattempted_jobs, + reconcile_job_outcome, + resume_campaign_jobs, + send_campaign_now, + send_single_campaign_job, + synchronous_send_options, +) + +from govoplan_campaign.backend.route_support import ( + _get_campaign_for_principal, + _get_campaign_for_tenant, + _get_version_for_tenant, + _require_campaign_profile_use_if_needed, + _require_campaign_versions_profile_use, + _require_mail_profile_use_if_needed, + _require_permission, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +@router.get( + "/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse +) +def campaign_delivery_options( + campaign_id: str, + version_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue") + ), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + try: + return CampaignDeliveryOptionsResponse( + **synchronous_send_options( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ), + postbox_available=postbox_integration().available, + ) + except QueueingError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse) +def queue_campaign( + campaign_id: str, + payload: QueueCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or QueueCampaignRequest() + _require_campaign_profile_use_if_needed( + session, principal, campaign_id, payload.version_id + ) + try: + result = queue_campaign_jobs( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + include_warnings=payload.include_warnings, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.queued" + if not payload.dry_run + else "campaign.queue_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result.as_dict(), + commit=True, + ) + return QueueCampaignResponse(**result.as_dict()) + except QueueingError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/jobs/retry", response_model=CampaignActionResponse) +def retry_campaign_jobs( + campaign_id: str, + payload: CampaignRetryJobsRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:retry")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or CampaignRetryJobsRequest() + _require_campaign_profile_use_if_needed( + session, principal, campaign_id, payload.version_id + ) + try: + result = queue_failed_jobs_for_retry( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + job_ids=payload.job_ids or None, + include_permanent=payload.include_permanent, + force_max_attempts=payload.force_max_attempts, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.jobs_retry_queued" + if not payload.dry_run + else "campaign.jobs_retry_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except (QueueingError, ExecutionSnapshotError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post( + "/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse +) +def send_unattempted_campaign_jobs( + campaign_id: str, + payload: CampaignSendUnattemptedRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or CampaignSendUnattemptedRequest() + _require_campaign_profile_use_if_needed( + session, principal, campaign_id, payload.version_id + ) + try: + result = queue_unattempted_jobs( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + job_ids=payload.job_ids or None, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.unattempted_jobs_queued" + if not payload.dry_run + else "campaign.unattempted_jobs_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except (QueueingError, ExecutionSnapshotError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/jobs/{job_id}/send", response_model=CampaignActionResponse) +def send_single_campaign_job_endpoint( + campaign_id: str, + job_id: str, + payload: CampaignSendJobRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or CampaignSendJobRequest() + _require_campaign_profile_use_if_needed(session, principal, campaign_id, None) + try: + result = send_single_campaign_job( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + job_id=job_id, + include_warnings=payload.include_warnings, + dry_run=payload.dry_run, + use_rate_limit=payload.use_rate_limit, + enqueue_imap_task=payload.enqueue_imap_task, + ) + audit_from_principal( + session, + principal, + action="campaign.single_message_sent" + if not payload.dry_run + else "campaign.single_message_send_dry_run", + object_type="campaign_job", + object_id=job_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except (QueueingError, ExecutionSnapshotError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post( + "/{campaign_id}/jobs/{job_id}/resolve-outcome", + response_model=CampaignActionResponse, +) +def resolve_campaign_job_outcome( + campaign_id: str, + job_id: str, + payload: CampaignResolveOutcomeRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + try: + result = reconcile_job_outcome( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + job_id=job_id, + decision=payload.decision, + note=payload.note, + attempt_id=payload.attempt_id, + commit=False, + ) + audit_from_principal( + session, + principal, + action="campaign.job_outcome_reconciled", + object_type="campaign_job", + object_id=job_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except (QueueingError, ExecutionSnapshotError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except Exception: + session.rollback() + raise + + +@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse) +def mock_send_campaign( + campaign_id: str, + payload: MockCampaignSendRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")), +): + """Run a fully visible mock delivery flow without mutating campaign state. + + The route validates and builds the selected version, then optionally records + mock SMTP deliveries and mock IMAP appends. It never talks to the configured + real SMTP/IMAP servers and it does not mark the version sent/final. + """ + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + + payload = payload or MockCampaignSendRequest() + _require_campaign_profile_use_if_needed( + session, principal, campaign_id, payload.version_id + ) + try: + result = run_mock_campaign_send( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + send=payload.send, + include_warnings=payload.include_warnings, + include_needs_review=payload.include_needs_review, + append_sent=payload.append_sent, + clear_mailbox=payload.clear_mailbox, + check_files=payload.check_files, + ) + audit_from_principal( + session, + principal, + action="campaign.mock_send" + if payload.send + else "campaign.mock_send_review", + object_type="campaign", + object_id=campaign_id, + details={ + "version_id": result.get("version_id"), + "send_requested": payload.send, + "sent_count": result.get("send", {}).get("sent_count"), + "failed_count": result.get("send", {}).get("failed_count"), + }, + commit=True, + ) + return MockCampaignSendResponse(result=result) + except MockCampaignSendError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse) +def send_campaign_now_endpoint( + campaign_id: str, + payload: SendCampaignNowRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), +): + """Preflight and synchronously send a policy-bounded built execution.""" + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + + payload = payload or SendCampaignNowRequest() + try: + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + version_id = payload.version_id or campaign.current_version_id + if not version_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Campaign has no current version", + ) + + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + _require_mail_profile_use_if_needed( + principal, version.raw_json if isinstance(version.raw_json, dict) else {} + ) + validation_result: dict[str, object] | None = ( + version.validation_summary + if isinstance(version.validation_summary, dict) + else None + ) + build_result: dict[str, object] | None = ( + version.build_summary if isinstance(version.build_summary, dict) else None + ) + if is_user_locked_version(version): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.", + ) + if ( + not version.locked_at + or not validation_result + or validation_result.get("ok") is not True + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Campaign version must be validated and locked before dry-run or sending.", + ) + if not build_result: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Campaign version must be built before dry-run or sending.", + ) + + delivery_result = send_campaign_now( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + include_warnings=payload.include_warnings, + dry_run=payload.dry_run, + use_rate_limit=payload.use_rate_limit, + enqueue_imap_task=payload.enqueue_imap_task, + ).as_dict() + response_result = public_send_campaign_now_result( + delivery_result, + validation_summary=validation_result, + build_summary=build_result, + ) + audit_from_principal( + session, + principal, + action="campaign.sent_now" + if not payload.dry_run + else "campaign.send_now_dry_run", + object_type="campaign", + object_id=campaign_id, + details=send_campaign_now_audit_details(delivery_result), + commit=True, + ) + return SendCampaignNowResponse(result=response_result) + except SynchronousSendRejected as exc: + # A synchronous request stages queue state before the all-message + # preflight can run. Rejecting that preflight must not leave work + # eligible for a background worker when no provider effect occurred. + session.rollback() + audit_from_principal( + session, + principal, + action="campaign.send_now_rejected", + object_type="campaign", + object_id=campaign_id, + details={ + **exc.audit_details(), + "version_id": payload.version_id, + }, + commit=True, + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except HTTPException: + raise + except (CampaignPersistenceError, QueueingError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse) +def pause_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + result = pause_campaign_jobs( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id + ) + audit_from_principal( + session, + principal, + action="campaign.paused", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse) +def resume_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + version_ids = { + row[0] + for row in session.query(CampaignJob.campaign_version_id) + .filter( + CampaignJob.tenant_id == principal.tenant_id, + CampaignJob.campaign_id == campaign_id, + CampaignJob.queue_status == JobQueueStatus.PAUSED.value, + ) + .distinct() + .all() + } + _require_campaign_versions_profile_use(session, principal, campaign_id, version_ids) + try: + result = resume_campaign_jobs( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id + ) + audit_from_principal( + session, + principal, + action="campaign.resumed", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse) +def cancel_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + result = cancel_campaign_jobs( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id + ) + audit_from_principal( + session, + principal, + action="campaign.cancelled", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse) +def append_sent( + campaign_id: str, + payload: AppendSentRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + payload = payload or AppendSentRequest() + version_ids = { + row[0] + for row in session.query(CampaignJob.campaign_version_id) + .filter( + CampaignJob.tenant_id == principal.tenant_id, + CampaignJob.campaign_id == campaign_id, + CampaignJob.send_status.in_( + [JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value] + ), + CampaignJob.imap_status.in_( + [JobImapStatus.PENDING.value, JobImapStatus.FAILED.value] + ), + ) + .distinct() + .all() + } + _require_campaign_versions_profile_use(session, principal, campaign_id, version_ids) + try: + result = enqueue_pending_imap_appends( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + enqueue_celery=payload.enqueue_celery, + run_inline=payload.run_inline, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.append_sent_enqueued" + if not payload.dry_run + else "campaign.append_sent_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc diff --git a/src/govoplan_campaign/backend/routes/jobs.py b/src/govoplan_campaign/backend/routes/jobs.py new file mode 100644 index 0000000..5719c28 --- /dev/null +++ b/src/govoplan_campaign/backend/routes/jobs.py @@ -0,0 +1,436 @@ +from __future__ import annotations + + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.schemas import ( + CampaignJobsResponse, + CampaignJobsDeltaResponse, + CampaignJobDetailResponse, + CampaignJobDiagnosticsResponse, +) +from govoplan_core.auth import ApiPrincipal, require_scope +from govoplan_core.core.change_sequence import ( + decode_sequence_watermark, + encode_sequence_watermark, + sequence_entries_since, + sequence_watermark_is_expired, +) +from govoplan_campaign.backend.change_tracking import ( + CAMPAIGNS_MODULE_ID, + CAMPAIGN_JOBS_COLLECTION, +) +from govoplan_campaign.backend.db.models import ( + CampaignJob, + ImapAppendAttempt, + PostboxDeliveryAttempt, + SendAttempt, +) +from govoplan_core.db.session import get_session + + +from govoplan_campaign.backend.route_support import ( + _get_campaign_for_principal, + _get_campaign_for_tenant, + _require_permission, + job_attempt_rows as _job_attempt_rows, +) +from govoplan_campaign.backend.services.job_queries import ( + CampaignJobsQuery, + _campaign_jobs_delta_watermark, + _campaign_jobs_page_response, + _campaign_jobs_query_context, + _job_attempts_payload, + _job_detail_payload, + _job_diagnostics_payload, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) +def list_jobs( + campaign_id: str, + filters: CampaignJobsQuery = Depends(CampaignJobsQuery), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + """Return a lightweight, paginated job list with server-side filters. + + Complete recipients, attachment metadata, issues and attempt history are + available from the separate job-detail endpoint. + """ + + _campaign, base_filters, filtered, review_metadata, reviewed_keys = ( + _campaign_jobs_query_context( + session, + principal, + campaign_id=campaign_id, + version_id=filters.version_id, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + ) + ) + return _campaign_jobs_page_response( + session, + campaign_id=campaign_id, + version_id=filters.version_id, + base_filters=base_filters, + filtered=filtered, + reviewed_keys=reviewed_keys, + review_metadata=review_metadata, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + sort_by=filters.sort_by, + sort_direction=filters.sort_direction, + cursor=filters.cursor, + ) + + +def _campaign_jobs_full_delta_response( + session: Session, + *, + principal: ApiPrincipal, + campaign_id: str, + version_id: str | None, + page: int, + page_size: int, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, + grid_filters: dict[str, str] | None, + sort_by: str, + sort_direction: str, + cursor: str | None = None, +) -> CampaignJobsDeltaResponse: + _campaign, base_filters, filtered, review_metadata, reviewed_keys = ( + _campaign_jobs_query_context( + session, + principal, + campaign_id=campaign_id, + version_id=version_id, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + grid_filters=grid_filters, + ) + ) + payload = _campaign_jobs_page_response( + session, + campaign_id=campaign_id, + version_id=version_id, + base_filters=base_filters, + filtered=filtered, + reviewed_keys=reviewed_keys, + review_metadata=review_metadata, + page=page, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + grid_filters=grid_filters, + sort_by=sort_by, + sort_direction=sort_direction, + cursor=cursor, + ) + return CampaignJobsDeltaResponse( + **payload.model_dump(), + deleted=[], + watermark=_campaign_jobs_delta_watermark(session, principal.tenant_id), + has_more=False, + full=True, + ) + + +def _job_filter_membership_can_shift( + *, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, + grid_filters: dict[str, str] | None, + sort_by: str, + sort_direction: str, +) -> bool: + return bool( + send_status + or validation_status + or imap_status + or (query_text and query_text.strip()) + or grid_filters + or sort_by != "number" + or sort_direction != "asc" + ) + + +@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse) +def list_jobs_delta( + campaign_id: str, + filters: CampaignJobsQuery = Depends(CampaignJobsQuery), + 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_jobs_full_delta_response( + session, + principal=principal, + campaign_id=campaign_id, + version_id=filters.version_id, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + sort_by=filters.sort_by, + sort_direction=filters.sort_direction, + cursor=filters.cursor, + ) + + campaign, base_filters, filtered, review_metadata, reviewed_keys = ( + _campaign_jobs_query_context( + session, + principal, + campaign_id=campaign_id, + version_id=filters.version_id, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + ) + ) + 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_JOBS_COLLECTION,), + ): + return _campaign_jobs_full_delta_response( + session, + principal=principal, + campaign_id=campaign_id, + version_id=filters.version_id, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + sort_by=filters.sort_by, + sort_direction=filters.sort_direction, + cursor=filters.cursor, + ) + + entries_plus_one = sequence_entries_since( + session, + since=since_sequence, + tenant_id=principal.tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=(CAMPAIGN_JOBS_COLLECTION,), + limit=limit + 1, + ) + has_more = len(entries_plus_one) > limit + entries = entries_plus_one[:limit] + relevant_entries = [ + entry + for entry in entries + if (entry.payload or {}).get("campaign_id") == campaign.id + and ( + not filters.version_id + or (entry.payload or {}).get("version_id") == filters.version_id + ) + ] + + if relevant_entries and ( + _job_filter_membership_can_shift( + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + sort_by=filters.sort_by, + sort_direction=filters.sort_direction, + ) + or any(entry.operation in {"created", "deleted"} for entry in relevant_entries) + ): + return _campaign_jobs_full_delta_response( + session, + principal=principal, + campaign_id=campaign_id, + version_id=filters.version_id, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + sort_by=filters.sort_by, + sort_direction=filters.sort_direction, + cursor=filters.cursor, + ) + + changed_job_ids = { + entry.resource_id + for entry in relevant_entries + if entry.resource_type == "campaign_job" and entry.operation != "deleted" + } + payload = _campaign_jobs_page_response( + session, + campaign_id=campaign_id, + version_id=filters.version_id, + base_filters=base_filters, + filtered=filtered, + reviewed_keys=reviewed_keys, + review_metadata=review_metadata, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + grid_filters=filters.grid_filters, + sort_by=filters.sort_by, + sort_direction=filters.sort_direction, + cursor=filters.cursor, + changed_job_ids=changed_job_ids, + ) + watermark = ( + encode_sequence_watermark(entries[-1].id) + if has_more and entries + else _campaign_jobs_delta_watermark(session, principal.tenant_id) + ) + return CampaignJobsDeltaResponse( + **payload.model_dump(), + deleted=[], + watermark=watermark, + has_more=has_more, + full=False, + ) + + +@router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse) +def get_job_detail( + campaign_id: str, + job_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + job = session.get(CampaignJob, job_id) + if ( + not job + or job.campaign_id != campaign.id + or job.tenant_id != principal.tenant_id + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found" + ) + send_attempts = _job_attempt_rows( + session.query(SendAttempt) + .filter(SendAttempt.job_id == job.id) + .order_by(SendAttempt.attempt_number.asc()), + label="SMTP attempts for this campaign job", + ) + imap_attempts = _job_attempt_rows( + session.query(ImapAppendAttempt) + .filter(ImapAppendAttempt.job_id == job.id) + .order_by(ImapAppendAttempt.attempt_number.asc()), + label="IMAP attempts for this campaign job", + ) + postbox_attempts = _job_attempt_rows( + session.query(PostboxDeliveryAttempt) + .filter(PostboxDeliveryAttempt.job_id == job.id) + .order_by( + PostboxDeliveryAttempt.target_index.asc(), + PostboxDeliveryAttempt.attempt_number.asc(), + ), + label="Postbox attempts for this campaign job", + ) + return CampaignJobDetailResponse( + job=_job_detail_payload(job), + attempts=_job_attempts_payload( + send_attempts, + imap_attempts, + postbox_attempts, + ), + ) + + +@router.get( + "/{campaign_id}/jobs/{job_id}/diagnostics", + response_model=CampaignJobDiagnosticsResponse, +) +def get_job_diagnostics( + campaign_id: str, + job_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")), +): + """Return infrastructure details only to campaign operators/admins.""" + + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + job = session.get(CampaignJob, job_id) + if ( + not job + or job.campaign_id != campaign.id + or job.tenant_id != principal.tenant_id + ): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found" + ) + send_attempts = _job_attempt_rows( + session.query(SendAttempt) + .filter(SendAttempt.job_id == job.id) + .order_by(SendAttempt.attempt_number.asc()), + label="SMTP diagnostics for this campaign job", + ) + imap_attempts = _job_attempt_rows( + session.query(ImapAppendAttempt) + .filter(ImapAppendAttempt.job_id == job.id) + .order_by(ImapAppendAttempt.attempt_number.asc()), + label="IMAP diagnostics for this campaign job", + ) + postbox_attempts = _job_attempt_rows( + session.query(PostboxDeliveryAttempt) + .filter(PostboxDeliveryAttempt.job_id == job.id) + .order_by( + PostboxDeliveryAttempt.target_index.asc(), + PostboxDeliveryAttempt.attempt_number.asc(), + ), + label="Postbox diagnostics for this campaign job", + ) + return _job_diagnostics_payload( + job, + send_attempts, + imap_attempts, + postbox_attempts, + ) diff --git a/src/govoplan_campaign/backend/routes/reports.py b/src/govoplan_campaign/backend/routes/reports.py new file mode 100644 index 0000000..fef3a3b --- /dev/null +++ b/src/govoplan_campaign/backend/routes/reports.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.schemas import ( + ReportEmailRequest, + ReportEmailResponse, +) +from govoplan_core.auth import ApiPrincipal, has_scope, require_scope +from govoplan_core.audit.logging import audit_from_principal +from govoplan_campaign.backend.db.models import ( + CampaignVersion, +) +from govoplan_core.db.session import get_session +from govoplan_campaign.backend.reports.campaigns import ( + CampaignReportError, + generate_campaign_report, + generate_jobs_csv, +) +from govoplan_campaign.backend.reports.emailing import ( + CampaignReportEmailError, + send_campaign_report_email, +) +from govoplan_campaign.backend.integrations import ( + MailProfileError, + SmtpConfigurationError, + SmtpSendError, +) + + +from govoplan_campaign.backend.route_support import ( + _get_campaign_for_principal, + _require_mail_profile_use_if_needed, + _require_permission, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) +logger = logging.getLogger(__name__) + + +@router.get("/{campaign_id}/summary") +def campaign_summary( + campaign_id: str, + version_id: str | None = None, + include_jobs: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + """Return dashboard-friendly campaign status counters and summaries.""" + _get_campaign_for_principal(session, campaign_id, principal) + if include_jobs: + _require_permission(principal, "campaigns:recipient:read") + + try: + return generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + include_jobs=include_jobs, + include_recent_failures=include_jobs, + include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), + ) + except CampaignReportError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + + +@router.get("/{campaign_id}/report") +def campaign_report( + campaign_id: str, + version_id: str | None = None, + include_jobs: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")), +): + """Return the recipient-level JSON report for one campaign.""" + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + + try: + return generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + include_jobs=include_jobs, + include_recent_failures=include_jobs, + include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), + ) + except CampaignReportError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + + +@router.get("/{campaign_id}/report/jobs.csv") +def campaign_jobs_csv( + campaign_id: str, + version_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")), +): + """Export per-job campaign status as CSV.""" + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:export") + + try: + csv_text = generate_jobs_csv( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), + ) + except CampaignReportError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + return Response( + content=csv_text, + media_type="text/csv; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"' + }, + ) + + +@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse) +def email_campaign_report( + campaign_id: str, + payload: ReportEmailRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")), +): + """Generate a campaign report and send it to one or more email addresses.""" + campaign = _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:export") + selected_version_id = payload.version_id or campaign.current_version_id + selected_version = ( + session.get(CampaignVersion, selected_version_id) + if selected_version_id + else None + ) + if selected_version is not None and selected_version.campaign_id == campaign.id: + _require_mail_profile_use_if_needed( + principal, + selected_version.raw_json + if isinstance(selected_version.raw_json, dict) + else {}, + ) + try: + result = send_campaign_report_email( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + to=payload.to, + include_jobs=payload.include_jobs, + attach_jobs_csv=payload.attach_jobs_csv, + attach_report_json=payload.attach_report_json, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="report.email_sent" + if not payload.dry_run + else "report.email_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result.as_dict(), + commit=True, + ) + return ReportEmailResponse(result=result.as_dict()) + except CampaignReportError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except ( + CampaignReportEmailError, + MailProfileError, + SmtpConfigurationError, + SmtpSendError, + ) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except Exception as exc: + logger.error("Campaign report email failed with an unexpected internal error") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Campaign report email could not be completed.", + ) from exc diff --git a/src/govoplan_campaign/backend/routes/sharing.py b/src/govoplan_campaign/backend/routes/sharing.py new file mode 100644 index 0000000..cb78645 --- /dev/null +++ b/src/govoplan_campaign/backend/routes/sharing.py @@ -0,0 +1,272 @@ +from __future__ import annotations + + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.schemas import ( + CampaignShareItem, + CampaignShareListResponse, + CampaignShareTargetItem, + CampaignShareTargetsResponse, + CampaignShareUpsertRequest, + CampaignOwnerUpdateRequest, + CampaignResponse, +) +from govoplan_core.auth import ApiPrincipal, require_scope +from govoplan_core.audit.logging import audit_from_principal +from govoplan_campaign.backend.db.models import ( + CampaignShare, +) +from govoplan_core.db.session import get_session +from govoplan_core.security.time import utc_now + + +from govoplan_campaign.backend.route_support import ( + _access_directory, + _clear_current_version_mail_profile_for_owner_transfer, + _get_campaign_for_principal, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse) +def list_campaign_share_targets( + campaign_id: str, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + directory = _access_directory() + users = [ + user + for user in directory.users_for_tenant(principal.tenant_id) + if user.status == "active" + ] + groups = [ + group + for group in directory.groups_for_tenant(principal.tenant_id) + if group.status == "active" + ] + if len(users) > limit or len(groups) > limit: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail=( + f"Campaign share targets exceed the maximum response size of {limit} " + "users or groups. Use a searchable directory selector." + ), + ) + return CampaignShareTargetsResponse( + users=[ + CampaignShareTargetItem( + id=item.id, name=item.display_name or item.email, secondary=item.email + ) + for item in users + ], + groups=[ + CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) + for item in groups + ], + ) + + +@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse) +def list_campaign_shares( + campaign_id: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + query = session.query(CampaignShare).filter( + CampaignShare.tenant_id == principal.tenant_id, + CampaignShare.campaign_id == campaign.id, + CampaignShare.revoked_at.is_(None), + ) + total = query.order_by(None).count() + pages = max(1, (total + page_size - 1) // page_size) + shares = ( + query.order_by( + CampaignShare.target_type.asc(), + CampaignShare.target_id.asc(), + CampaignShare.id.asc(), + ) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return CampaignShareListResponse( + shares=[CampaignShareItem.model_validate(item) for item in shares], + total=total, + page=page, + page_size=page_size, + pages=pages, + ) + + +@router.put("/{campaign_id}/owner", response_model=CampaignResponse) +def update_campaign_owner( + campaign_id: str, + payload: CampaignOwnerUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + if payload.owner_user_id and payload.owner_group_id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Choose either a user owner or a group owner, not both", + ) + directory = _access_directory() + if payload.owner_user_id: + owner = directory.get_user(payload.owner_user_id) + if owner is not None and ( + owner.tenant_id != principal.tenant_id or owner.status != "active" + ): + owner = None + if owner is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found" + ) + if payload.owner_group_id: + group = directory.get_group(payload.owner_group_id) + if group is not None and ( + group.tenant_id != principal.tenant_id or group.status != "active" + ): + group = None + if group is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found" + ) + owner_changed = ( + campaign.owner_user_id != payload.owner_user_id + or campaign.owner_group_id != payload.owner_group_id + ) + mail_profile_reselection_required = False + if owner_changed: + mail_profile_reselection_required = ( + _clear_current_version_mail_profile_for_owner_transfer(session, campaign) + ) + + campaign.owner_user_id = payload.owner_user_id + campaign.owner_group_id = payload.owner_group_id + session.add(campaign) + audit_from_principal( + session, + principal, + action="campaign.owner_updated", + object_type="campaign", + object_id=campaign.id, + details={ + **payload.model_dump(), + "mail_profile_reselection_required": mail_profile_reselection_required, + }, + commit=True, + ) + return CampaignResponse.model_validate(campaign) + + +@router.post( + "/{campaign_id}/shares", + response_model=CampaignShareItem, + status_code=status.HTTP_201_CREATED, +) +def upsert_campaign_share( + campaign_id: str, + payload: CampaignShareUpsertRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + directory = _access_directory() + if payload.target_type == "user": + target = directory.get_user(payload.target_id) + if target is not None and ( + target.tenant_id != principal.tenant_id or target.status != "active" + ): + target = None + else: + target = directory.get_group(payload.target_id) + if target is not None and ( + target.tenant_id != principal.tenant_id or target.status != "active" + ): + target = None + if target is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found" + ) + share = ( + session.query(CampaignShare) + .filter( + CampaignShare.campaign_id == campaign.id, + CampaignShare.target_type == payload.target_type, + CampaignShare.target_id == payload.target_id, + ) + .one_or_none() + ) + if share is None: + share = CampaignShare( + tenant_id=principal.tenant_id, + campaign_id=campaign.id, + target_type=payload.target_type, + target_id=payload.target_id, + permission=payload.permission, + created_by_user_id=principal.user.id, + ) + else: + share.permission = payload.permission + share.revoked_at = None + session.add(share) + audit_from_principal( + session, + principal, + action="campaign.share_upserted", + object_type="campaign", + object_id=campaign.id, + details=payload.model_dump(), + commit=True, + ) + return CampaignShareItem.model_validate(share) + + +@router.delete( + "/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT +) +def revoke_campaign_share( + campaign_id: str, + share_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + share = ( + session.query(CampaignShare) + .filter( + CampaignShare.id == share_id, + CampaignShare.campaign_id == campaign.id, + CampaignShare.tenant_id == principal.tenant_id, + ) + .one_or_none() + ) + if share is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found" + ) + share.revoked_at = utc_now() + session.add(share) + audit_from_principal( + session, + principal, + action="campaign.share_revoked", + object_type="campaign", + object_id=campaign.id, + details={"share_id": share_id}, + commit=True, + ) + return None + + +# Queue / delivery control ------------------------------------------------- diff --git a/src/govoplan_campaign/backend/routes/versions.py b/src/govoplan_campaign/backend/routes/versions.py new file mode 100644 index 0000000..cd5f36e --- /dev/null +++ b/src/govoplan_campaign/backend/routes/versions.py @@ -0,0 +1,667 @@ +from __future__ import annotations + + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.schemas import ( + BuildCampaignRequest, + CampaignCreateResponse, + CampaignResponse, + CampaignVersionDetailResponse, + CampaignVersionResponse, + CampaignVersionSetStepRequest, + CampaignReviewStateRequest, + CampaignVersionUpdateRequest, + CampaignPartialValidationRequest, + CampaignPartialValidationResponse, + ValidateCampaignRequest, +) +from govoplan_core.auth import ApiPrincipal, has_scope, require_scope +from govoplan_core.audit.logging import audit_from_principal +from govoplan_campaign.backend.db.models import ( + CampaignVersion, +) +from govoplan_core.db.session import get_session +from govoplan_campaign.backend.response_security import ( + public_campaign_payload, +) +from govoplan_campaign.backend.persistence.campaigns import ( + CampaignPersistenceError, + build_campaign_version, + validate_campaign_version, +) +from govoplan_campaign.backend.path_security import CampaignPathSecurityError +from govoplan_campaign.backend.persistence.versions import ( + LockedCampaignVersionError, + fork_campaign_version_for_edit, + is_version_final_locked, + is_user_locked_version, + get_campaign_version_for_tenant, + lock_campaign_version_temporarily, + permanently_lock_campaign_version, + publish_campaign_version, + unlock_user_locked_campaign_version, + unlock_validated_campaign_version, + update_campaign_version, + update_campaign_review_state, + validate_campaign_partial, +) + + +from govoplan_campaign.backend.route_support import ( + _campaign_response_context, + _campaign_version_detail_response, + _get_campaign_for_principal, + _get_campaign_for_tenant, + _get_version_for_principal, + _get_version_for_tenant, + _require_mail_profile_use_if_needed, + _require_permission, + _update_campaign_version_detail_response, + _write_current_version_snapshot_if_available, + bounded_query_rows as _bounded_query_rows, +) +from govoplan_campaign.backend.routes.attachments import ( + CampaignAttachmentLinkMatchesResponse, + _link_campaign_attachment_matches, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) +def list_versions( + campaign_id: str, + limit: int = Query(default=500, ge=1, le=1000), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + versions = _bounded_query_rows( + session.query(CampaignVersion) + .filter(CampaignVersion.campaign_id == campaign.id) + .order_by(CampaignVersion.version_number.desc()), + limit=limit, + label="Campaign version history", + ) + return [ + CampaignVersionResponse.model_validate( + item, + context=_campaign_response_context(principal), + ) + for item in versions + ] + + +@router.get( + "/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse +) +def get_version_detail( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + try: + version = get_campaign_version_for_tenant( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + return CampaignVersionDetailResponse.model_validate( + version, + context=_campaign_response_context(principal), + ) + except CampaignPersistenceError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + + +@router.post( + "/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse +) +def fork_version_for_edit( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")), +): + """Create the campaign's next and only editable working version. + + A new working copy may be created only after the current version is + permanently user-locked or delivery-final. Validation and temporary user + locks must be removed in place instead of creating parallel drafts. + """ + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + + payload = payload or CampaignVersionUpdateRequest() + source_version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if source_version.campaign_id != campaign_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found" + ) + effective_json = ( + payload.campaign_json + if isinstance(payload.campaign_json, dict) + else source_version.raw_json + ) + _require_mail_profile_use_if_needed( + principal, + effective_json if isinstance(effective_json, dict) else {}, + ) + try: + version = fork_campaign_version_for_edit( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow or "manual", + current_step=payload.current_step, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=True, + migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings, + commit=False, + ) + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + audit_from_principal( + session, + principal, + action="campaign.version_forked_for_edit", + object_type="campaign_version", + object_id=version.id, + details={ + "campaign_id": campaign_id, + "source_version_id": version_id, + "version_number": version.version_number, + "legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings, + }, + 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 LockedCampaignVersionError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + except CampaignPathSecurityError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except CampaignPersistenceError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except Exception: + session.rollback() + raise + + +@router.post( + "/{campaign_id}/versions/{version_id}/unlock-validation", + response_model=CampaignVersionDetailResponse, +) +def unlock_version_validation( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + """Unlock a successfully validated version before delivery starts. + + Unlocking invalidates validation/build state and removes generated jobs for + that version. Sent/final versions cannot be unlocked and must be copied. + """ + _get_campaign_for_principal(session, campaign_id, principal, write=True) + + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: unlock_validated_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + commit=False, + ), + audit_action="campaign.version_validation_unlocked", + ) + + +@router.post( + "/{campaign_id}/versions/{version_id}/lock-temporarily", + response_model=CampaignVersionDetailResponse, +) +def lock_version_temporarily( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: lock_campaign_version_temporarily( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + user_id=principal.user.id, + commit=False, + ), + audit_action="campaign.version_user_locked_temporarily", + ) + + +@router.post( + "/{campaign_id}/versions/{version_id}/unlock-user-lock", + response_model=CampaignVersionDetailResponse, +) +def unlock_version_user_lock( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: unlock_user_locked_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + commit=False, + ), + audit_action="campaign.version_user_lock_removed", + ) + + +@router.post( + "/{campaign_id}/versions/{version_id}/lock-permanently", + response_model=CampaignVersionDetailResponse, +) +def lock_version_permanently( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: permanently_lock_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + user_id=principal.user.id, + commit=False, + ), + audit_action="campaign.version_user_locked_permanently", + ) + + +@router.put( + "/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse +) +def update_version_detail( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + return _update_campaign_version_detail_response( + session, + principal, + campaign_id, + version_id, + payload, + autosave=False, + audit_action="campaign.version_updated", + ) + + +@router.post( + "/{campaign_id}/versions/{version_id}/autosave", + response_model=CampaignVersionDetailResponse, +) +def autosave_version( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + return _update_campaign_version_detail_response( + session, + principal, + campaign_id, + version_id, + payload, + autosave=True, + audit_action="campaign.version_autosaved", + ) + + +@router.post( + "/{campaign_id}/versions/{version_id}/set-step", + response_model=CampaignVersionDetailResponse, +) +def set_version_step( + campaign_id: str, + version_id: str, + payload: CampaignVersionSetStepRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + current_flow=payload.current_flow, + current_step=payload.current_step, + autosave=True, + commit=False, + ), + audit_action="campaign.version_step_updated", + details={ + "campaign_id": campaign_id, + "current_flow": payload.current_flow, + "current_step": payload.current_step, + }, + ) + + +@router.post( + "/{campaign_id}/versions/{version_id}/review-state", + response_model=CampaignVersionDetailResponse, +) +def set_version_review_state( + campaign_id: str, + version_id: str, + payload: CampaignReviewStateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = update_campaign_review_state( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + inspection_complete=payload.inspection_complete, + reviewed_message_keys=payload.reviewed_message_keys, + user_id=principal.user.id, + commit=False, + ) + audit_from_principal( + session, + principal, + action="campaign.message_review_updated", + object_type="campaign_version", + object_id=version.id, + details={ + "campaign_id": campaign_id, + "inspection_complete": payload.inspection_complete, + "reviewed_message_count": len(payload.reviewed_message_keys), + }, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate( + version, + context=_campaign_response_context(principal), + ) + except LockedCampaignVersionError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + 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.post( + "/{campaign_id}/versions/{version_id}/validate-partial", + response_model=CampaignPartialValidationResponse, +) +def validate_version_partial( + campaign_id: str, + version_id: str, + payload: CampaignPartialValidationRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = get_campaign_version_for_tenant( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + campaign_json = ( + payload.campaign_json + if payload and payload.campaign_json is not None + else version.raw_json + ) + result = validate_campaign_partial( + campaign_json, section=payload.section if payload else None + ) + audit_from_principal( + session, + principal, + action="campaign.version_partially_validated", + object_type="campaign_version", + object_id=version.id, + details={ + "campaign_id": campaign_id, + "section": result.get("section"), + "ok": result.get("ok"), + }, + commit=True, + ) + return CampaignPartialValidationResponse(**result) + except CampaignPersistenceError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + + +@router.post( + "/{campaign_id}/versions/{version_id}/publish", + response_model=CampaignVersionDetailResponse, +) +def publish_version( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: publish_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + user_id=principal.user.id, + commit=False, + ), + audit_action="campaign.version_user_locked_permanently", + ) + + +@router.post("/versions/{version_id}/validate") +def validate_version( + version_id: str, + payload: ValidateCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), +): + _get_version_for_principal(session, version_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or ValidateCampaignRequest() + try: + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + _require_mail_profile_use_if_needed( + principal, version.raw_json if isinstance(version.raw_json, dict) else {} + ) + if is_user_locked_version(version) or is_version_final_locked(version): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.", + ) + link_result: CampaignAttachmentLinkMatchesResponse | None = None + if payload.check_files and payload.link_unshared_matches: + _require_permission(principal, "files:file:share") + campaign = _get_campaign_for_tenant( + session, version.campaign_id, principal.tenant_id + ) + link_result = _link_campaign_attachment_matches( + session, + principal, + campaign=campaign, + version=version, + raw=version.raw_json if isinstance(version.raw_json, dict) else {}, + dry_run=False, + ) + audit_from_principal( + session, + principal, + action="campaign.attachment_matches_linked", + object_type="campaign_version", + object_id=version_id, + details={ + "matched_file_count": link_result.matched_file_count, + "already_linked_file_count": link_result.already_linked_file_count, + "linked_file_count": link_result.linked_file_count, + "during_validation": True, + }, + commit=True, + ) + result = validate_campaign_version( + session, + tenant_id=principal.tenant_id, + version_id=version_id, + check_files=payload.check_files, + user_id=principal.user.id, + ) + audit_from_principal( + session, + principal, + action="campaign.validated", + object_type="campaign_version", + object_id=version_id, + details={ + "check_files": payload.check_files, + "link_unshared_matches": payload.link_unshared_matches, + "linked_file_count": link_result.linked_file_count + if link_result + else 0, + "ok": result.get("ok"), + }, + commit=True, + ) + return public_campaign_payload( + result, + include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), + ) + except HTTPException: + raise + except CampaignPersistenceError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + + +@router.post("/versions/{version_id}/build") +def build_version( + version_id: str, + payload: BuildCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")), +): + version = _get_version_for_principal(session, version_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + _require_mail_profile_use_if_needed( + principal, version.raw_json if isinstance(version.raw_json, dict) else {} + ) + try: + result = build_campaign_version( + session, + tenant_id=principal.tenant_id, + version_id=version_id, + write_eml=payload.write_eml if payload else True, + ) + audit_from_principal( + session, + principal, + action="campaign.messages_built", + object_type="campaign_version", + object_id=version_id, + details={ + "write_eml": payload.write_eml if payload else True, + "built_count": result.get("built_count"), + }, + commit=True, + ) + return public_campaign_payload( + result, + include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"), + ) + except CampaignPersistenceError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail=str(exc) + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc diff --git a/src/govoplan_campaign/backend/services/job_queries.py b/src/govoplan_campaign/backend/services/job_queries.py new file mode 100644 index 0000000..5bb3d5d --- /dev/null +++ b/src/govoplan_campaign/backend/services/job_queries.py @@ -0,0 +1,884 @@ +from __future__ import annotations + +import json +from collections.abc import Sequence +from typing import Literal + +from fastapi import HTTPException, Query, status +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.schemas import ( + CampaignJobsResponse, + CampaignJobDiagnosticsResponse, +) +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.change_sequence import ( + encode_sequence_watermark, + max_sequence_id, +) +from govoplan_core.core.pagination import ( + KeysetCursorError, + decode_keyset_cursor, + encode_keyset_cursor, + keyset_query_fingerprint, +) +from govoplan_campaign.backend.change_tracking import ( + CAMPAIGNS_MODULE_ID, + CAMPAIGN_JOBS_COLLECTION, +) +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignJob, + CampaignVersion, + ImapAppendAttempt, + JobImapStatus, + JobPostboxStatus, + JobQueueStatus, + JobSendStatus, + JobValidationStatus, + PostboxDeliveryAttempt, + SendAttempt, +) +from govoplan_campaign.backend.response_security import ( + public_campaign_payload, + public_delivery_result_message, +) + + +from govoplan_campaign.backend.route_support import ( + _get_campaign_for_principal, + _get_campaign_for_tenant, + _get_version_for_tenant, + _require_permission, +) + + +CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs" + + +def _job_review_key(job: CampaignJob) -> str: + return str(job.entry_id or job.entry_index) + + +def _job_summary_payload( + job: CampaignJob, + *, + reviewed_keys: set[str] | None = None, +) -> dict[str, object]: + review_key = _job_review_key(job) + return { + "id": job.id, + "campaign_version_id": job.campaign_version_id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "subject": job.subject, + "message_id_header": job.message_id_header, + "build_status": job.build_status, + "validation_status": job.validation_status, + "queue_status": job.queue_status, + "send_status": job.send_status, + "delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"), + "postbox_status": getattr(job, "postbox_status", "not_requested"), + "imap_status": job.imap_status, + "eml_size_bytes": job.eml_size_bytes, + "eml_sha256": job.eml_sha256, + "attempt_count": job.attempt_count, + "postbox_attempt_count": getattr(job, "postbox_attempt_count", 0), + "postbox_target_count": len( + getattr(job, "resolved_postbox_targets", None) or [] + ), + "last_error": public_delivery_result_message( + last_error=job.last_error, + send_status=job.send_status, + imap_status=job.imap_status, + postbox_status=getattr(job, "postbox_status", "not_requested"), + ), + "queued_at": job.queued_at, + "outcome_unknown_at": job.outcome_unknown_at, + "sent_at": job.sent_at, + "created_at": job.created_at, + "updated_at": job.updated_at, + "issues_count": len(job.issues_snapshot or []), + "attachment_count": len(job.resolved_attachments or []), + "review_key": review_key, + "reviewed": review_key in reviewed_keys if reviewed_keys is not None else False, + "matched_file_count": sum( + len(item.get("matches") or []) + for item in (job.resolved_attachments or []) + if isinstance(item, dict) + ), + } + + +def _job_detail_payload(job: CampaignJob) -> dict[str, object]: + return { + **_job_summary_payload(job), + "message_id_header": job.message_id_header, + "issues": job.issues_snapshot or [], + "attachments": public_campaign_payload(job.resolved_attachments or []), + "resolved_recipients": job.resolved_recipients or {}, + "resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None) + or [], + } + + +def _job_attempts_payload( + send_attempts: list[SendAttempt], + imap_attempts: list[ImapAppendAttempt], + postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), + *, + include_diagnostics: bool = False, +) -> dict[str, list[dict[str, object]]]: + smtp_payloads: list[dict[str, object]] = [] + for attempt in send_attempts: + payload: dict[str, object] = { + "id": attempt.id, + "attempt_number": attempt.attempt_number, + "status": attempt.status, + "smtp_status_code": attempt.smtp_status_code, + "started_at": attempt.started_at, + "finished_at": attempt.finished_at, + } + if include_diagnostics: + payload["claim_token"] = attempt.claim_token + payload["smtp_response"] = attempt.smtp_response + payload["error_type"] = attempt.error_type + payload["error_message"] = attempt.error_message + smtp_payloads.append(payload) + + imap_payloads: list[dict[str, object]] = [] + for attempt in imap_attempts: + payload = { + "id": attempt.id, + "attempt_number": attempt.attempt_number, + "status": attempt.status, + "folder": attempt.folder, + "created_at": attempt.created_at, + "updated_at": attempt.updated_at, + } + if include_diagnostics: + payload["claim_token"] = attempt.claim_token + payload["error_message"] = attempt.error_message + imap_payloads.append(payload) + postbox_payloads: list[dict[str, object]] = [] + for attempt in postbox_attempts: + payload = { + "id": attempt.id, + "target_index": attempt.target_index, + "attempt_number": attempt.attempt_number, + "status": attempt.status, + "postbox_id": attempt.postbox_id, + "address": attempt.address, + "holder_count": attempt.holder_count, + "vacant": attempt.vacant, + "duplicate": attempt.duplicate, + "target": attempt.target_snapshot or {}, + "started_at": attempt.started_at, + "finished_at": attempt.finished_at, + "error_code": attempt.error_code, + } + if include_diagnostics: + payload["idempotency_key"] = attempt.idempotency_key + payload["provider_delivery_id"] = attempt.provider_delivery_id + payload["provider_message_id"] = attempt.provider_message_id + payload["evidence"] = attempt.evidence or {} + payload["error_type"] = attempt.error_type + payload["error_message"] = attempt.error_message + postbox_payloads.append(payload) + return { + "smtp": smtp_payloads, + "imap": imap_payloads, + "postbox": postbox_payloads, + } + + +def _job_diagnostics_payload( + job: CampaignJob, + send_attempts: list[SendAttempt], + imap_attempts: list[ImapAppendAttempt], + postbox_attempts: Sequence[PostboxDeliveryAttempt] = (), +) -> CampaignJobDiagnosticsResponse: + return CampaignJobDiagnosticsResponse( + job_id=job.id, + campaign_id=job.campaign_id, + campaign_version_id=job.campaign_version_id, + storage={ + "eml_local_path": job.eml_local_path, + "eml_storage_key": job.eml_storage_key, + "eml_size_bytes": job.eml_size_bytes, + "eml_sha256": job.eml_sha256, + }, + worker_claim={ + "claim_token": job.claim_token, + "claimed_at": job.claimed_at, + "smtp_started_at": job.smtp_started_at, + "outcome_unknown_at": job.outcome_unknown_at, + "last_error": job.last_error, + }, + attempts=_job_attempts_payload( + send_attempts, + imap_attempts, + postbox_attempts, + include_diagnostics=True, + ), + ) + + +def _review_metadata( + session: Session, + version: CampaignVersion | None, + base_filters: list[object], +) -> tuple[dict[str, object], set[str]]: + if version is None: + return _empty_review_metadata(), set() + + review_state, state_is_current = _current_review_state(version) + reviewed_keys = _current_reviewed_keys( + review_state, state_is_current=state_is_current + ) + counts = _review_metadata_counts(_review_rows(session, base_filters), reviewed_keys) + return { + "inspection_complete": bool( + state_is_current and review_state.get("inspection_complete") is True + ), + **counts, + }, reviewed_keys + + +def _empty_review_metadata() -> dict[str, object]: + return { + "inspection_complete": False, + "blocking_count": 0, + "required_count": 0, + "reviewed_required_count": 0, + "bulk_acceptable_count": 0, + } + + +def _current_review_state(version: CampaignVersion) -> tuple[dict[str, object], bool]: + build_summary = ( + version.build_summary if isinstance(version.build_summary, dict) else {} + ) + build_token = str( + build_summary.get("build_token") or build_summary.get("built_at") or "" + ) + editor_state = ( + version.editor_state if isinstance(version.editor_state, dict) else {} + ) + review_state = ( + editor_state.get("review_send") + if isinstance(editor_state.get("review_send"), dict) + else {} + ) + state_token = str(review_state.get("build_token") or "") + return review_state, bool(build_token and state_token == build_token) + + +def _current_reviewed_keys( + review_state: dict[str, object], *, state_is_current: bool +) -> set[str]: + return { + str(value) + for value in (review_state.get("reviewed_message_keys") or []) + if state_is_current and str(value).strip() + } + + +def _review_rows( + session: Session, base_filters: list[object] +) -> list[tuple[object, int, str, str]]: + return ( + session.query( + CampaignJob.entry_id, + CampaignJob.entry_index, + CampaignJob.build_status, + CampaignJob.validation_status, + ) + .filter(*base_filters) + .all() + ) + + +def _review_metadata_counts( + review_rows: list[tuple[object, int, str, str]], reviewed_keys: set[str] +) -> dict[str, int]: + blocking_count = 0 + required_count = 0 + reviewed_required_count = 0 + bulk_acceptable_count = 0 + for entry_id, entry_index, build_status, validation_status in review_rows: + key = str(entry_id or entry_index) + if build_status != "built" or validation_status == "blocked": + blocking_count += 1 + if validation_status == "needs_review": + required_count += 1 + if key in reviewed_keys: + reviewed_required_count += 1 + elif validation_status in {"warning", "excluded"}: + bulk_acceptable_count += 1 + + return { + "blocking_count": blocking_count, + "required_count": required_count, + "reviewed_required_count": reviewed_required_count, + "bulk_acceptable_count": bulk_acceptable_count, + } + + +def _status_counts( + session: Session, filters: list[object] +) -> dict[str, dict[str, int]]: + result: dict[str, dict[str, int]] = {} + for field_name in ( + "build_status", + "validation_status", + "queue_status", + "send_status", + "postbox_status", + "imap_status", + ): + column = getattr(CampaignJob, field_name) + rows = ( + session.query(column, func.count(CampaignJob.id)) + .filter(*filters) + .group_by(column) + .all() + ) + result[field_name.removesuffix("_status")] = { + str(value or "unknown"): int(count) for value, count in rows + } + return result + + +CAMPAIGN_JOB_GRID_SORT_COLUMNS = { + "number": CampaignJob.entry_index, + "recipient": func.lower(func.coalesce(CampaignJob.recipient_email, "")), + "subject": func.lower(func.coalesce(CampaignJob.subject, "")), + "validation": CampaignJob.validation_status, + "queue": CampaignJob.queue_status, + "send": CampaignJob.send_status, + "postbox": CampaignJob.postbox_status, + "imap": CampaignJob.imap_status, + "attempts": CampaignJob.attempt_count, + "updated": CampaignJob.updated_at, +} +CAMPAIGN_JOB_GRID_LIST_FILTERS = { + "validation": ( + CampaignJob.validation_status, + {item.value for item in JobValidationStatus}, + ), + "queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}), + "send": (CampaignJob.send_status, {item.value for item in JobSendStatus}), + "postbox": ( + CampaignJob.postbox_status, + {item.value for item in JobPostboxStatus}, + ), + "imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}), +} + + +def _campaign_jobs_grid_filter_expressions( + grid_filters: dict[str, str] | None, +) -> list[object]: + values = grid_filters or {} + expressions: list[object] = [] + recipient = values.get("recipient", "").strip() + if recipient: + pattern = _contains_pattern(recipient) + expressions.append( + or_( + CampaignJob.recipient_email.ilike(pattern, escape="\\"), + CampaignJob.entry_id.ilike(pattern, escape="\\"), + ) + ) + subject = values.get("subject", "").strip() + if subject: + expressions.append( + CampaignJob.subject.ilike(_contains_pattern(subject), escape="\\") + ) + evidence = values.get("evidence", "").strip() + if evidence: + pattern = _contains_pattern(evidence) + expressions.append( + or_( + CampaignJob.message_id_header.ilike(pattern, escape="\\"), + CampaignJob.eml_sha256.ilike(pattern, escape="\\"), + ) + ) + attempts = values.get("attempts", "").strip() + if attempts: + expressions.append( + _campaign_jobs_integer_filter( + CampaignJob.attempt_count, attempts, column_id="attempts" + ) + ) + for column_id, (column, allowed_values) in CAMPAIGN_JOB_GRID_LIST_FILTERS.items(): + raw_value = values.get(column_id, "").strip() + if not raw_value: + continue + selected = _campaign_jobs_list_filter( + raw_value, column_id=column_id, allowed_values=allowed_values + ) + expressions.append(column.in_(selected)) + return expressions + + +def _campaign_jobs_list_filter( + raw_value: str, *, column_id: str, allowed_values: set[str] +) -> list[str]: + if raw_value.startswith("list:"): + try: + parsed = json.loads(raw_value[5:]) + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid {column_id} list filter", + ) from exc + if not isinstance(parsed, list) or any( + not isinstance(value, str) for value in parsed + ): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid {column_id} list filter", + ) + selected = list( + dict.fromkeys(value.strip() for value in parsed if value.strip()) + ) + else: + selected = list( + dict.fromkeys( + value.strip() for value in raw_value.split(",") if value.strip() + ) + ) + if len(selected) > 50 or any(value not in allowed_values for value in selected): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid {column_id} list filter", + ) + return selected + + +def _campaign_jobs_integer_filter(column: object, raw_value: str, *, column_id: str): + operator, separator, value = raw_value.partition(":") + if not separator: + operator, value = "eq", operator + if operator not in {"eq", "gt", "gte", "lt", "lte"}: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid {column_id} filter operator", + ) + try: + expected = int(value) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=f"Invalid {column_id} filter value", + ) from exc + if operator == "gt": + return column > expected + if operator == "gte": + return column >= expected + if operator == "lt": + return column < expected + if operator == "lte": + return column <= expected + return column == expected + + +def _contains_pattern(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + +def _campaign_jobs_ordering(sort_by: str, sort_direction: str) -> list[object]: + column = CAMPAIGN_JOB_GRID_SORT_COLUMNS.get(sort_by) + if column is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Unsupported Campaign job sort column", + ) + primary = column.desc() if sort_direction == "desc" else column.asc() + return [primary, CampaignJob.id.asc()] + + +def _campaign_jobs_query_context( + session: Session, + principal: ApiPrincipal, + *, + campaign_id: str, + version_id: str | None, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, + grid_filters: dict[str, str] | None = None, +) -> tuple[Campaign, list[object], list[object], dict[str, object], set[str]]: + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + base_filters: list[object] = [ + CampaignJob.campaign_id == campaign.id, + CampaignJob.tenant_id == principal.tenant_id, + ] + selected_version: CampaignVersion | None = None + if version_id: + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Campaign version not found", + ) + selected_version = version + base_filters.append(CampaignJob.campaign_version_id == version.id) + + review_metadata, reviewed_keys = _review_metadata( + session, selected_version, base_filters + ) + filtered = list(base_filters) + if send_status: + filtered.append(CampaignJob.send_status.in_(send_status)) + if validation_status: + filtered.append(CampaignJob.validation_status.in_(validation_status)) + if imap_status: + filtered.append(CampaignJob.imap_status.in_(imap_status)) + if query_text and query_text.strip(): + pattern = f"%{query_text.strip()}%" + filtered.append( + or_( + CampaignJob.recipient_email.ilike(pattern), + CampaignJob.subject.ilike(pattern), + CampaignJob.entry_id.ilike(pattern), + ) + ) + filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters)) + return campaign, base_filters, filtered, review_metadata, reviewed_keys + + +def _campaign_jobs_page_response( + session: Session, + *, + campaign_id: str, + version_id: str | None, + base_filters: list[object], + filtered: list[object], + reviewed_keys: set[str], + review_metadata: dict[str, object], + page: int, + page_size: int, + send_status: list[str] | None = None, + validation_status: list[str] | None = None, + imap_status: list[str] | None = None, + query_text: str | None = None, + grid_filters: dict[str, str] | None = None, + sort_by: str = "number", + sort_direction: str = "asc", + cursor: str | None = None, + changed_job_ids: set[str] | None = None, +) -> CampaignJobsResponse: + total_unfiltered, total = _campaign_jobs_page_counts( + session, base_filters=base_filters, filtered=filtered + ) + pages = (total + page_size - 1) // page_size if total else 0 + fingerprint = _campaign_jobs_cursor_fingerprint( + campaign_id=campaign_id, + version_id=version_id, + page_size=page_size, + send_status=send_status, + validation_status=validation_status, + imap_status=imap_status, + query_text=query_text, + grid_filters=grid_filters, + sort_by=sort_by, + sort_direction=sort_direction, + ) + ordering = _campaign_jobs_ordering(sort_by, sort_direction) + rows_plus_one, start_cursor = _campaign_jobs_page_rows( + session, + filtered=filtered, + ordering=ordering, + page=page, + page_size=page_size, + sort_by=sort_by, + sort_direction=sort_direction, + cursor=cursor, + fingerprint=fingerprint, + ) + jobs = rows_plus_one[:page_size] + next_cursor = _campaign_jobs_next_cursor( + jobs, + rows_plus_one=rows_plus_one, + page_size=page_size, + sort_by=sort_by, + sort_direction=sort_direction, + changed_job_ids=changed_job_ids, + fingerprint=fingerprint, + ) + if changed_job_ids is not None: + jobs = [job for job in jobs if job.id in changed_job_ids] + return CampaignJobsResponse( + jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs], + page=page, + page_size=page_size, + total=total, + total_unfiltered=total_unfiltered, + pages=pages, + cursor=start_cursor, + next_cursor=next_cursor, + counts=_status_counts(session, base_filters), + filtered_counts=_status_counts(session, filtered), + review=review_metadata, + ) + + +def _campaign_jobs_page_counts( + session: Session, + *, + base_filters: list[object], + filtered: list[object], +) -> tuple[int, int]: + total_unfiltered = int( + session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0 + ) + total = int( + session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0 + ) + return total_unfiltered, total + + +def _campaign_jobs_page_rows( + session: Session, + *, + filtered: list[object], + ordering: list[object], + page: int, + page_size: int, + sort_by: str, + sort_direction: str, + cursor: str | None, + fingerprint: str, +) -> tuple[list[CampaignJob], str | None]: + if cursor: + page_query = _campaign_jobs_query_after_cursor( + session, + filtered=filtered, + cursor=cursor, + fingerprint=fingerprint, + sort_by=sort_by, + sort_direction=sort_direction, + ) + return page_query.order_by(*ordering).limit(page_size + 1).all(), cursor + + effective_offset = (page - 1) * page_size + page_query = session.query(CampaignJob).filter(*filtered) + start_cursor = _campaign_jobs_offset_cursor( + page_query, + ordering=ordering, + effective_offset=effective_offset, + sort_by=sort_by, + sort_direction=sort_direction, + fingerprint=fingerprint, + ) + rows = ( + page_query.order_by(*ordering) + .offset(effective_offset) + .limit(page_size + 1) + .all() + ) + return rows, start_cursor + + +def _campaign_jobs_query_after_cursor( + session: Session, + *, + filtered: list[object], + cursor: str, + fingerprint: str, + sort_by: str, + sort_direction: str, +): + if sort_by != "number" or sort_direction != "asc": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Campaign job cursors require number ascending order", + ) + try: + cursor_values = decode_keyset_cursor( + CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint + ) + if cursor_values is None: + raise KeysetCursorError("Invalid pagination cursor") + except KeysetCursorError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc) + ) from exc + return ( + session.query(CampaignJob) + .filter(*filtered) + .filter(_campaign_jobs_cursor_condition(cursor_values)) + ) + + +def _campaign_jobs_offset_cursor( + query, + *, + ordering: list[object], + effective_offset: int, + sort_by: str, + sort_direction: str, + fingerprint: str, +) -> str | None: + if effective_offset <= 0 or sort_by != "number" or sort_direction != "asc": + return None + previous_row = ( + query.order_by(*ordering).offset(effective_offset - 1).limit(1).first() + ) + if previous_row is None: + return None + return _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint) + + +def _campaign_jobs_next_cursor( + jobs: list[CampaignJob], + *, + rows_plus_one: list[CampaignJob], + page_size: int, + sort_by: str, + sort_direction: str, + changed_job_ids: set[str] | None, + fingerprint: str, +) -> str | None: + cursor_supported = sort_by == "number" and sort_direction == "asc" + if ( + changed_job_ids is not None + or not cursor_supported + or len(rows_plus_one) <= page_size + or not jobs + ): + return None + return _campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint) + + +def _campaign_jobs_cursor_fingerprint( + *, + campaign_id: str, + version_id: str | None, + page_size: int, + send_status: list[str] | None, + validation_status: list[str] | None, + imap_status: list[str] | None, + query_text: str | None, + grid_filters: dict[str, str] | None, + sort_by: str, + sort_direction: str, +) -> str: + return keyset_query_fingerprint( + CAMPAIGN_JOBS_CURSOR_SCOPE, + { + "campaign_id": campaign_id, + "version_id": version_id or "", + "page_size": page_size, + "send_status": sorted(send_status or []), + "validation_status": sorted(validation_status or []), + "imap_status": sorted(imap_status or []), + "query": (query_text or "").strip(), + "grid_filters": sorted((grid_filters or {}).items()), + "order": [f"{sort_by}:{sort_direction}", "id:asc"], + }, + ) + + +def _campaign_jobs_cursor_for_row(row: CampaignJob, *, fingerprint: str) -> str: + return encode_keyset_cursor( + CAMPAIGN_JOBS_CURSOR_SCOPE, + fingerprint=fingerprint, + values={"id": row.id, "entry_index": row.entry_index}, + ) + + +def _campaign_jobs_cursor_condition(cursor_values: dict[str, object]): + cursor_id = cursor_values.get("id") + cursor_index = cursor_values.get("entry_index") + if not isinstance(cursor_id, str) or not cursor_id: + raise KeysetCursorError("Invalid pagination cursor") + try: + entry_index = int(cursor_index) + except (TypeError, ValueError) as exc: + raise KeysetCursorError("Invalid pagination cursor") from exc + return or_( + CampaignJob.entry_index > entry_index, + and_(CampaignJob.entry_index == entry_index, CampaignJob.id > cursor_id), + ) + + +def _campaign_jobs_delta_watermark(session: Session, tenant_id: str) -> str: + return encode_sequence_watermark( + max_sequence_id( + session, + tenant_id=tenant_id, + module_id=CAMPAIGNS_MODULE_ID, + collections=(CAMPAIGN_JOBS_COLLECTION,), + ) + ) + + +class CampaignJobsQuery: + def __init__( + self, + version_id: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + cursor: str | None = Query(default=None), + send_status: list[str] | None = Query(default=None), + validation_status: list[str] | None = Query(default=None), + imap_status: list[str] | None = Query(default=None), + query_text: str | None = Query(default=None, alias="q", max_length=200), + sort_by: Literal[ + "number", + "recipient", + "subject", + "validation", + "queue", + "send", + "postbox", + "imap", + "attempts", + "updated", + ] = Query(default="number"), + sort_direction: Literal["asc", "desc"] = Query(default="asc"), + filter_recipient: str | None = Query(default=None, max_length=500), + filter_subject: str | None = Query(default=None, max_length=1000), + filter_validation: str | None = Query(default=None, max_length=1000), + filter_queue: str | None = Query(default=None, max_length=1000), + filter_send: str | None = Query(default=None, max_length=1000), + filter_postbox: str | None = Query(default=None, max_length=1000), + filter_imap: str | None = Query(default=None, max_length=1000), + filter_attempts: str | None = Query(default=None, max_length=100), + filter_evidence: str | None = Query(default=None, max_length=500), + ) -> None: + self.version_id = version_id + self.page = page + self.page_size = page_size + self.cursor = cursor + self.send_status = send_status + self.validation_status = validation_status + self.imap_status = imap_status + self.query_text = query_text + self.sort_by = sort_by + self.sort_direction = sort_direction + self.grid_filters = { + column_id: value + for column_id, value in { + "recipient": filter_recipient, + "subject": filter_subject, + "validation": filter_validation, + "queue": filter_queue, + "send": filter_send, + "postbox": filter_postbox, + "imap": filter_imap, + "attempts": filter_attempts, + "evidence": filter_evidence, + }.items() + if value is not None and value.strip() + } diff --git a/tests/test_aggregate_report_routes.py b/tests/test_aggregate_report_routes.py index e1090bd..302c605 100644 --- a/tests/test_aggregate_report_routes.py +++ b/tests/test_aggregate_report_routes.py @@ -9,7 +9,10 @@ from sqlalchemy import create_engine from sqlalchemy.orm import Session from govoplan_access.backend.db.models import Account, Group, User -from govoplan_campaign.backend import router +from govoplan_campaign.backend import router as campaign_api +from govoplan_campaign.backend.routes import campaigns as campaign_routes +from govoplan_campaign.backend.routes import jobs as job_routes +from govoplan_campaign.backend.routes import reports as report_routes from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion from govoplan_campaign.backend.reports.aggregate import ( AggregateCampaignReportError, @@ -37,10 +40,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None: campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1") with ( - patch.object(router, "_get_campaign_for_principal", return_value=campaign), + patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as full_report_denied, ): - router.campaign_report( + report_routes.campaign_report( "campaign-1", session=session, principal=principal, # type: ignore[arg-type] @@ -49,10 +52,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None: assert "campaigns:recipient:read" in full_report_denied.value.detail with ( - patch.object(router, "_get_campaign_for_principal", return_value=campaign), + patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as job_detail_denied, ): - router.get_job_detail( + job_routes.get_job_detail( "campaign-1", "job-1", session=session, @@ -61,10 +64,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None: assert job_detail_denied.value.status_code == 403 with ( - patch.object(router, "_get_campaign_for_principal", return_value=campaign), + patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as report_email_denied, ): - router.email_campaign_report( + report_routes.email_campaign_report( "campaign-1", ReportEmailRequest(to=["auditor@example.test"]), session=session, @@ -74,10 +77,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None: assert "campaigns:recipient:export" in report_email_denied.value.detail with ( - patch.object(router, "_get_campaign_for_principal", return_value=campaign), + patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as diagnostics_denied, ): - router.get_job_diagnostics( + job_routes.get_job_diagnostics( "campaign-1", "job-1", session=session, @@ -93,14 +96,14 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None: safe_projection = Mock() with ( - patch.object(router, "_get_campaign_for_principal") as acl, + patch.object(campaign_routes, "_get_campaign_for_principal") as acl, patch.object( - router, + campaign_routes, "generate_aggregate_campaign_report", return_value=safe_projection, ) as generate, ): - result = router.aggregate_campaign_report( + result = campaign_routes.aggregate_campaign_report( "campaign-1", session=session, principal=principal, # type: ignore[arg-type] @@ -118,7 +121,7 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None: @pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"]) def test_aggregate_routes_require_report_read_permission(path: str) -> None: - route = next(item for item in router.router.routes if item.path == path) + route = next(item for item in campaign_api.router.routes if item.path == path) dependency = next(item for item in route.dependant.dependencies if item.name == "principal") with pytest.raises(HTTPException) as denied: diff --git a/tests/test_campaign_audit_atomicity.py b/tests/test_campaign_audit_atomicity.py index f8ae40f..f836557 100644 --- a/tests/test_campaign_audit_atomicity.py +++ b/tests/test_campaign_audit_atomicity.py @@ -6,7 +6,9 @@ from unittest.mock import MagicMock, patch import pytest from fastapi import HTTPException -from govoplan_campaign.backend import router +from govoplan_campaign.backend import route_support +from govoplan_campaign.backend.routes import campaigns as campaign_routes +from govoplan_campaign.backend.routes import versions as version_routes from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest @@ -34,13 +36,13 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() -> return version with ( - patch.object(router, "_get_campaign_for_principal"), - patch.object(router, "_get_version_for_tenant", return_value=version), - patch.object(router, "update_campaign_version", side_effect=mutate), - patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), + patch.object(route_support, "_get_campaign_for_principal"), + patch.object(route_support, "_get_version_for_tenant", return_value=version), + patch.object(route_support, "update_campaign_version", side_effect=mutate), + patch.object(route_support, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), ): with pytest.raises(HTTPException, match="audit unavailable") as captured: - router._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test + route_support._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test session, principal, # type: ignore[arg-type] "campaign-1", @@ -68,15 +70,15 @@ def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> No return forked with ( - patch.object(router, "_get_campaign_for_principal", return_value=campaign), - patch.object(router, "_require_permission"), - patch.object(router, "_get_version_for_tenant", return_value=source), - patch.object(router, "_get_campaign_for_tenant", return_value=campaign), - patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate), - patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), + patch.object(version_routes, "_get_campaign_for_principal", return_value=campaign), + patch.object(version_routes, "_require_permission"), + patch.object(version_routes, "_get_version_for_tenant", return_value=source), + patch.object(version_routes, "_get_campaign_for_tenant", return_value=campaign), + patch.object(version_routes, "fork_campaign_version_for_edit", side_effect=mutate), + patch.object(version_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), ): with pytest.raises(RuntimeError, match="audit unavailable"): - router.fork_version_for_edit( + version_routes.fork_version_for_edit( "campaign-1", "version-1", CampaignVersionUpdateRequest(), @@ -102,12 +104,12 @@ def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() -> ) with ( - patch.object(router, "_get_campaign_for_principal", return_value=campaign), - patch.object(router, "_sync_campaign_metadata_to_current_version"), - patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), + patch.object(campaign_routes, "_get_campaign_for_principal", return_value=campaign), + patch.object(campaign_routes, "_sync_campaign_metadata_to_current_version"), + patch.object(campaign_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")), ): with pytest.raises(RuntimeError, match="audit unavailable"): - router.update_campaign_metadata_endpoint( + campaign_routes.update_campaign_metadata_endpoint( "campaign-1", CampaignUpdateRequest(name="New name"), session=session, diff --git a/tests/test_imap_append_idempotency.py b/tests/test_imap_append_idempotency.py index cf2f1a3..24c7ba4 100644 --- a/tests/test_imap_append_idempotency.py +++ b/tests/test_imap_append_idempotency.py @@ -11,7 +11,7 @@ from alembic.migration import MigrationContext from alembic.operations import Operations from sqlalchemy import create_engine, inspect, text -from govoplan_campaign.backend import router +from govoplan_campaign.backend.routes import delivery as router from govoplan_campaign.backend.db.models import ( CampaignJob, ImapAppendAttempt, @@ -410,14 +410,14 @@ def test_reconciliation_rolls_back_state_when_audit_fails( return {"decision": decision, "job_id": "job-1"} with ( - patch("govoplan_campaign.backend.router._get_campaign_for_principal"), - patch("govoplan_campaign.backend.router._require_permission"), + patch("govoplan_campaign.backend.routes.delivery._get_campaign_for_principal"), + patch("govoplan_campaign.backend.routes.delivery._require_permission"), patch( - "govoplan_campaign.backend.router.reconcile_job_outcome", + "govoplan_campaign.backend.routes.delivery.reconcile_job_outcome", side_effect=mutate_without_commit, ), patch( - "govoplan_campaign.backend.router.audit_from_principal", + "govoplan_campaign.backend.routes.delivery.audit_from_principal", side_effect=RuntimeError("audit unavailable"), ), ): diff --git a/tests/test_job_list_query.py b/tests/test_job_list_query.py index d7fe72c..780f2bb 100644 --- a/tests/test_job_list_query.py +++ b/tests/test_job_list_query.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from govoplan_access.backend.db.models import Account, Group, User from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion -from govoplan_campaign.backend.router import ( +from govoplan_campaign.backend.services.job_queries import ( _campaign_jobs_grid_filter_expressions, _campaign_jobs_ordering, _campaign_jobs_page_response, diff --git a/tests/test_mail_profile_boundary.py b/tests/test_mail_profile_boundary.py index a1468d0..d7df714 100644 --- a/tests/test_mail_profile_boundary.py +++ b/tests/test_mail_profile_boundary.py @@ -6,7 +6,8 @@ from unittest.mock import call, patch import pytest from fastapi import HTTPException -from govoplan_campaign.backend import router +from govoplan_campaign.backend import route_support +from govoplan_campaign.backend.routes import versions as router from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema from govoplan_campaign.backend.campaign.mail_profile_boundary import ( CampaignMailProfileBoundaryError, @@ -214,7 +215,7 @@ def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None: patch.object(router, "_get_campaign_for_principal", return_value=campaign), patch.object(router, "_require_permission"), patch.object(router, "_get_version_for_tenant", return_value=source), - patch.object(router, "has_scope", return_value=False), + patch.object(route_support, "has_scope", return_value=False), patch.object(router, "fork_campaign_version_for_edit") as fork, ): with pytest.raises(HTTPException) as captured: @@ -234,8 +235,8 @@ def test_campaign_wide_effect_authorizes_every_affected_version() -> None: session = object() principal = SimpleNamespace(tenant_id="tenant-1") - with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile: - router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test + with patch.object(route_support, "_require_campaign_profile_use_if_needed") as require_profile: + route_support._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test session, # type: ignore[arg-type] principal, # type: ignore[arg-type] "campaign-1", diff --git a/tests/test_partial_validation.py b/tests/test_partial_validation.py index b23f8f0..845ba9d 100644 --- a/tests/test_partial_validation.py +++ b/tests/test_partial_validation.py @@ -5,7 +5,7 @@ import unittest from pathlib import Path from unittest.mock import patch -from govoplan_campaign.backend import router +from govoplan_campaign.backend.routes import campaigns as router from govoplan_campaign.backend.campaign.models import CampaignConfig from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.persistence.versions import validate_campaign_partial diff --git a/tests/test_report_email_security.py b/tests/test_report_email_security.py index c87ba34..d1b56b2 100644 --- a/tests/test_report_email_security.py +++ b/tests/test_report_email_security.py @@ -7,7 +7,7 @@ import pytest from fastapi import HTTPException from pydantic import ValidationError -from govoplan_campaign.backend import router +from govoplan_campaign.backend.routes import reports as router from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email from govoplan_campaign.backend.schemas import ReportEmailRequest diff --git a/tests/test_response_security.py b/tests/test_response_security.py index dffeb2a..4c36ade 100644 --- a/tests/test_response_security.py +++ b/tests/test_response_security.py @@ -6,7 +6,7 @@ from datetime import UTC, datetime from types import SimpleNamespace from govoplan_campaign.backend.response_security import public_campaign_payload -from govoplan_campaign.backend.router import ( +from govoplan_campaign.backend.services.job_queries import ( _job_attempts_payload, _job_detail_payload, _job_diagnostics_payload, diff --git a/tests/test_route_registration.py b/tests/test_route_registration.py new file mode 100644 index 0000000..c95207d --- /dev/null +++ b/tests/test_route_registration.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from collections import Counter + +from govoplan_campaign.backend.router import router +from govoplan_campaign.backend.routes.attachments import router as attachments_router +from govoplan_campaign.backend.routes.campaigns import router as campaigns_router +from govoplan_campaign.backend.routes.delivery import router as delivery_router +from govoplan_campaign.backend.routes.jobs import router as jobs_router +from govoplan_campaign.backend.routes.reports import router as reports_router +from govoplan_campaign.backend.routes.sharing import router as sharing_router +from govoplan_campaign.backend.routes.versions import router as versions_router + + +def _operation_keys(candidate_router) -> list[tuple[str, str]]: + return [ + (method, route.path) + for route in candidate_router.routes + for method in sorted(route.methods or ()) + ] + + +def test_campaign_router_composes_every_workflow_operation_once() -> None: + workflow_routers = ( + campaigns_router, + versions_router, + jobs_router, + reports_router, + sharing_router, + delivery_router, + attachments_router, + ) + expected = [ + operation + for workflow_router in workflow_routers + for operation in _operation_keys(workflow_router) + ] + actual = _operation_keys(router) + + assert actual == expected + assert len(actual) == 62 + assert not [operation for operation, count in Counter(actual).items() if count > 1] + + +def test_key_routes_are_owned_by_their_focused_router() -> None: + expectations = ( + (campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")), + (versions_router, ("POST", "/campaigns/versions/{version_id}/build")), + (jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")), + (reports_router, ("GET", "/campaigns/{campaign_id}/report")), + (sharing_router, ("POST", "/campaigns/{campaign_id}/shares")), + (delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")), + ( + attachments_router, + ( + "POST", + "/campaigns/{campaign_id}/versions/{version_id}/attachments/preview", + ), + ), + ) + + for workflow_router, operation in expectations: + assert operation in _operation_keys(workflow_router) diff --git a/tests/test_send_now_response_security.py b/tests/test_send_now_response_security.py index b9a9967..745612e 100644 --- a/tests/test_send_now_response_security.py +++ b/tests/test_send_now_response_security.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import Mock, patch -from govoplan_campaign.backend import router +from govoplan_campaign.backend.routes import delivery as router from govoplan_campaign.backend.schemas import SendCampaignNowRequest from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult diff --git a/tests/test_synchronous_delivery_policy.py b/tests/test_synchronous_delivery_policy.py index 301c4f1..7fa8d36 100644 --- a/tests/test_synchronous_delivery_policy.py +++ b/tests/test_synchronous_delivery_policy.py @@ -6,7 +6,8 @@ from unittest.mock import Mock, patch import pytest from fastapi import HTTPException -from govoplan_campaign.backend import router +from govoplan_campaign.backend import router as campaign_api +from govoplan_campaign.backend.routes import delivery as router from govoplan_campaign.backend.delivery_policy import ( CampaignDeliveryPolicyError, DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS, @@ -295,7 +296,7 @@ def test_delivery_endpoints_require_their_mode_permission_and_recipient_authorit path: str, required_scope: str, ) -> None: - route = next(item for item in router.router.routes if item.path == path) + route = next(item for item in campaign_api.router.routes if item.path == path) dependency = next(item for item in route.dependant.dependencies if item.name == "principal") with pytest.raises(HTTPException) as missing_mode_permission: