diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index d2dccf4..e47876f 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -145,6 +145,14 @@ approve/reject chains, delegation, substitutions, escalation, and signatures belong to the optional Approvals capability. Campaign must not claim an approval merely because validation, building, or message review completed. +When a campaign has an Approval request reference, mock and real delivery +resolve `approvals.requests` and require an approved request for the exact +`campaign_version` subject and current version id. A missing Approvals module, +unknown request, pending/rejected chain, or approval for an older version blocks +delivery with an explicit requirement. Campaign stores the approval reference, +not Approval tables; changing the campaign version requires a new exact-subject +approval. + Normal readers and reviewers see business state and safe evidence. Process-local paths, storage keys, worker claim tokens, and raw provider diagnostics require the dedicated diagnostic permission and must not leak through ordinary campaign, diff --git a/src/govoplan_campaign/backend/approval_gate.py b/src/govoplan_campaign/backend/approval_gate.py new file mode 100644 index 0000000..ec36841 --- /dev/null +++ b/src/govoplan_campaign/backend/approval_gate.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Mapping + +from sqlalchemy.orm import Session + +from govoplan_core.core.approvals import ( + ApprovalRequestCreateCommand, + ApprovalRequestRef, + ApprovalStepDefinition, +) +from govoplan_campaign.backend.db.models import Campaign, CampaignVersion +from govoplan_campaign.backend.integrations import ( + ApprovalGateUnavailable, + approvals_integration, +) +from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot + + +APPROVAL_GATE_KEY = "approval_gate" +SUBJECT_MODULE = "campaigns" +SUBJECT_TYPE = "campaign_execution" + + +class CampaignApprovalGateError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class _TenantPrincipal: + tenant_id: str + account_id: str | None = None + + +def campaign_approval_gate(version: CampaignVersion) -> dict[str, object] | None: + state = version.editor_state if isinstance(version.editor_state, dict) else {} + gate = state.get(APPROVAL_GATE_KEY) + return dict(gate) if isinstance(gate, dict) else None + + +def request_campaign_approval( + session: Session, + principal: object, + *, + campaign: Campaign, + version: CampaignVersion, + title: str, + description: str | None, + steps: tuple[ApprovalStepDefinition, ...], + idempotency_key: str, + template_id: str | None = None, + template_revision: int | None = None, + unique_actors_across_steps: bool = True, + expires_at: datetime | None = None, + policy_refs: tuple[str, ...] = (), +) -> ApprovalRequestRef: + if campaign.tenant_id != str(getattr(principal, "tenant_id", "") or ""): + raise CampaignApprovalGateError("Campaign approval tenant mismatch.") + if version.campaign_id != campaign.id: + raise CampaignApprovalGateError("Campaign approval version mismatch.") + snapshot = ensure_execution_snapshot(session, version) + digest = str(version.execution_snapshot_hash or "") + if len(digest) != 64: + raise CampaignApprovalGateError( + "Build a valid Campaign execution snapshot before requesting approval." + ) + subject_version = _subject_version(version, snapshot.build_token) + evidence_actors = _campaign_evidence_actors(campaign, version) + try: + request = approvals_integration().create_request( + session, + principal, + command=ApprovalRequestCreateCommand( + title=title, + description=description, + subject_module=SUBJECT_MODULE, + subject_type=SUBJECT_TYPE, + subject_id=version.id, + subject_version=subject_version, + subject_digest=digest, + steps=steps, + separation_of_duties=True, + unique_actors_across_steps=unique_actors_across_steps, + expires_at=expires_at, + policy_refs=policy_refs, + evidence_actors=evidence_actors, + template_id=template_id, + template_revision=template_revision, + metadata={ + "campaign_id": campaign.id, + "campaign_version_number": version.version_number, + "execution_snapshot_version": snapshot.snapshot_version, + }, + ), + idempotency_key=idempotency_key, + ) + except ApprovalGateUnavailable as exc: + raise CampaignApprovalGateError(str(exc)) from exc + state = copy.deepcopy(version.editor_state or {}) + state[APPROVAL_GATE_KEY] = { + "request_id": request.id, + "request_revision": request.revision, + "subject_version": subject_version, + "subject_digest": digest, + "requested_at": datetime.now(UTC).isoformat(), + "requested_by_user_id": _actor_id(principal), + } + version.editor_state = state + session.add(version) + session.flush() + return request + + +def assert_campaign_approval( + session: Session, + *, + tenant_id: str, + version: CampaignVersion, +) -> None: + gate = campaign_approval_gate(version) + if gate is None: + return + snapshot = ensure_execution_snapshot(session, version) + digest = str(version.execution_snapshot_hash or "") + subject_version = _subject_version(version, snapshot.build_token) + if ( + gate.get("subject_digest") != digest + or gate.get("subject_version") != subject_version + ): + raise CampaignApprovalGateError( + "Campaign execution changed after approval was requested. Request approval for the current build." + ) + request_id = str(gate.get("request_id") or "").strip() + if not request_id: + raise CampaignApprovalGateError( + "Campaign approval gate has no request reference." + ) + try: + check = approvals_integration().check_approved( + session, + _TenantPrincipal(tenant_id=tenant_id), + request_id=request_id, + subject_module=SUBJECT_MODULE, + subject_type=SUBJECT_TYPE, + subject_id=version.id, + subject_version=subject_version, + subject_digest=digest, + ) + except (ApprovalGateUnavailable, LookupError, ValueError) as exc: + raise CampaignApprovalGateError(str(exc)) from exc + if not check.approved: + raise CampaignApprovalGateError( + f"Campaign delivery requires Approval request {request_id}, which is {check.state}." + ) + + +def campaign_approval_status( + session: Session, + *, + tenant_id: str, + version: CampaignVersion, +) -> dict[str, object]: + gate = campaign_approval_gate(version) + integration = approvals_integration() + if gate is None: + return { + "configured": False, + "available": integration.available, + "approved": False, + "state": "not_required", + "explanation": None, + } + try: + assert_campaign_approval(session, tenant_id=tenant_id, version=version) + except CampaignApprovalGateError as exc: + return { + **gate, + "configured": True, + "available": integration.available, + "approved": False, + "state": "unavailable" if not integration.available else "pending", + "explanation": str(exc), + } + return { + **gate, + "configured": True, + "available": True, + "approved": True, + "state": "approved", + "explanation": None, + } + + +def clear_campaign_approval_gate(version: CampaignVersion) -> None: + state = copy.deepcopy(version.editor_state or {}) + state.pop(APPROVAL_GATE_KEY, None) + version.editor_state = state + + +def _campaign_evidence_actors( + campaign: Campaign, + version: CampaignVersion, +) -> Mapping[str, tuple[str, ...]]: + validation = ( + version.validation_summary + if isinstance(version.validation_summary, dict) + else {} + ) + build = version.build_summary if isinstance(version.build_summary, dict) else {} + editor = version.editor_state if isinstance(version.editor_state, dict) else {} + review = ( + editor.get("review_send") if isinstance(editor.get("review_send"), dict) else {} + ) + review_decisions = ( + review.get("issue_decisions") + if isinstance(review.get("issue_decisions"), list) + else [] + ) + values = { + "author": (campaign.created_by_user_id,), + "owner": (campaign.owner_user_id,), + "validator": ( + validation.get("validated_by_user_id"), + version.locked_by_user_id, + ), + "builder": (build.get("built_by_user_id"),), + "reviewer": ( + review.get("updated_by_user_id"), + *( + item.get("actor_user_id") + for item in review_decisions + if isinstance(item, dict) + ), + ), + } + return { + role: tuple(dict.fromkeys(str(item) for item in actors if item)) + for role, actors in values.items() + if any(actors) + } + + +def _subject_version(version: CampaignVersion, build_token: str | None) -> str: + return str(build_token or f"campaign-version-{version.version_number}")[:120] + + +def _actor_id(principal: object) -> str | None: + for name in ("account_id", "user_id", "identity_id", "membership_id"): + value = str(getattr(principal, name, "") or "").strip() + if value: + return value + return None + + +__all__ = [ + "CampaignApprovalGateError", + "assert_campaign_approval", + "campaign_approval_gate", + "campaign_approval_status", + "clear_campaign_approval_gate", + "request_campaign_approval", +] diff --git a/src/govoplan_campaign/backend/approval_schemas.py b/src/govoplan_campaign/backend/approval_schemas.py new file mode 100644 index 0000000..d99a4f4 --- /dev/null +++ b/src/govoplan_campaign/backend/approval_schemas.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from govoplan_core.core.approvals import ApprovalActorSelector, ApprovalStepDefinition + + +class CampaignApprovalSelectorInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + kind: Literal["account", "group", "role", "function_assignment", "any_account"] + value: str = Field(min_length=1, max_length=255) + label: str | None = Field(default=None, max_length=255) + + +class CampaignApprovalStepInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + key: str = Field(min_length=1, max_length=120) + label: str = Field(min_length=1, max_length=255) + selectors: list[CampaignApprovalSelectorInput] = Field(min_length=1, max_length=500) + required_approvals: int = Field(default=1, ge=1, le=500) + rejection_policy: Literal["fail_fast", "collect"] = "fail_fast" + due_at: datetime | None = None + signature_required: bool = False + forbidden_evidence_roles: list[ + Literal["author", "owner", "validator", "builder", "reviewer"] + ] = Field(default_factory=list, max_length=5) + + def to_definition(self) -> ApprovalStepDefinition: + return ApprovalStepDefinition( + key=self.key, + label=self.label, + selectors=tuple( + ApprovalActorSelector(item.kind, item.value, item.label) + for item in self.selectors + ), + required_approvals=self.required_approvals, + rejection_policy=self.rejection_policy, + due_at=self.due_at, + signature_required=self.signature_required, + forbidden_evidence_roles=tuple(self.forbidden_evidence_roles), + ) + + +class CampaignApprovalRequestInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field( + default="Approve Campaign delivery", min_length=1, max_length=255 + ) + description: str | None = Field(default=None, max_length=10_000) + steps: list[CampaignApprovalStepInput] = Field(default_factory=list, max_length=100) + template_id: str | None = Field(default=None, max_length=36) + template_revision: int | None = Field(default=None, ge=1) + unique_actors_across_steps: bool = True + expires_at: datetime | None = None + policy_refs: list[str] = Field(default_factory=list, max_length=500) + idempotency_key: str = Field(min_length=1, max_length=160) + + @model_validator(mode="after") + def validate_source(self) -> "CampaignApprovalRequestInput": + template = self.template_id is not None or self.template_revision is not None + if template and (self.template_id is None or self.template_revision is None): + raise ValueError("Template id and revision must be supplied together.") + if template and self.steps: + raise ValueError("Use either an Approval template or inline steps.") + if not template and not self.steps: + raise ValueError( + "At least one Approval step is required without a template." + ) + return self + + +__all__ = ["CampaignApprovalRequestInput"] diff --git a/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py b/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py index 5547b32..6869f8c 100644 --- a/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py +++ b/src/govoplan_campaign/backend/campaign/mail_profile_boundary.py @@ -13,7 +13,9 @@ CAMPAIGN_MAIL_SERVER_KEYS = frozenset( "imap_credential_id", } ) -CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"}) +CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset( + {"created_from", "field_overrides", "opt_ins"} +) CAMPAIGN_OPT_IN_KEYS = frozenset( {"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"} ) @@ -27,6 +29,16 @@ CAMPAIGN_REVIEW_STATE_KEYS = frozenset( "updated_by_user_id", } ) +CAMPAIGN_APPROVAL_GATE_KEYS = frozenset( + { + "request_id", + "request_revision", + "subject_version", + "subject_digest", + "requested_at", + "requested_by_user_id", + } +) class CampaignMailProfileBoundaryError(ValueError): @@ -94,10 +106,13 @@ def validate_campaign_editor_state( if value is None: return {} if not isinstance(value, dict): - raise CampaignMailProfileBoundaryError("Campaign editor state must be an object") + raise CampaignMailProfileBoundaryError( + "Campaign editor state must be an object" + ) allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS) if allow_server_review_state: allowed.add("review_send") + allowed.add("approval_gate") if any(key not in allowed for key in value): raise CampaignMailProfileBoundaryError( "Campaign editor state contains unsupported or transport-owned fields" @@ -113,11 +128,13 @@ def validate_campaign_editor_state( if "opt_ins" in value: result["opt_ins"] = _validated_opt_ins(value["opt_ins"]) if "field_overrides" in value: - result["field_overrides"] = _validated_field_overrides( - value["field_overrides"] - ) + result["field_overrides"] = _validated_field_overrides(value["field_overrides"]) if "review_send" in value: result["review_send"] = _validated_server_review_state(value["review_send"]) + if "approval_gate" in value: + result["approval_gate"] = _validated_server_approval_gate( + value["approval_gate"] + ) return result @@ -162,6 +179,13 @@ def public_campaign_editor_state( result["review_send"] = review_state except CampaignMailProfileBoundaryError: pass + if "approval_gate" in value: + try: + result["approval_gate"] = _validated_server_approval_gate( + value["approval_gate"] + ) + except CampaignMailProfileBoundaryError: + pass return result @@ -170,9 +194,48 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]: state = public_campaign_editor_state(value) state.pop("review_send", None) + state.pop("approval_gate", None) return state +def _validated_server_approval_gate(value: Any) -> dict[str, Any]: + if not isinstance(value, dict) or any( + key not in CAMPAIGN_APPROVAL_GATE_KEYS for key in value + ): + raise CampaignMailProfileBoundaryError("Campaign approval gate is invalid") + required_strings = ( + "request_id", + "subject_version", + "subject_digest", + "requested_at", + ) + if any( + not isinstance(value.get(key), str) or not str(value.get(key)).strip() + for key in required_strings + ): + raise CampaignMailProfileBoundaryError( + "Campaign approval gate references are invalid" + ) + digest = str(value["subject_digest"]) + if len(digest) != 64 or any( + character not in "0123456789abcdef" for character in digest + ): + raise CampaignMailProfileBoundaryError( + "Campaign approval gate digest is invalid" + ) + revision = value.get("request_revision") + if not isinstance(revision, int) or revision < 1: + raise CampaignMailProfileBoundaryError( + "Campaign approval gate revision is invalid" + ) + requested_by = value.get("requested_by_user_id") + if requested_by is not None and not isinstance(requested_by, str): + raise CampaignMailProfileBoundaryError( + "Campaign approval gate actor is invalid" + ) + return copy.deepcopy(value) + + def _is_valid_reviewed_message_key(value: Any) -> bool: return isinstance(value, str) and bool(value.strip()) and len(value) <= 512 @@ -214,9 +277,7 @@ def _validated_issue_decisions(value: Any) -> list[dict[str, Any]]: "issue_codes", } for item in value: - if not isinstance(item, dict) or any( - key not in allowed_keys for key in item - ): + if not isinstance(item, dict) or any(key not in allowed_keys for key in item): raise CampaignMailProfileBoundaryError( "Campaign review issue decision is invalid" ) @@ -237,9 +298,7 @@ def _validated_issue_decisions(value: Any) -> list[dict[str, Any]]: error="Campaign review decision is invalid", ), "reason": item.get("reason"), - "actor_user_id": _validated_review_actor( - item.get("actor_user_id") - ), + "actor_user_id": _validated_review_actor(item.get("actor_user_id")), "decided_at": _validated_required_string( item.get("decided_at"), max_length=128, @@ -364,12 +423,18 @@ def campaign_mail_resource_ids( } -def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]: +def campaign_mail_profile_boundary_violations( + raw_json: dict[str, Any] | None, +) -> tuple[str, ...]: server = raw_json.get("server") if isinstance(raw_json, dict) else None if not isinstance(server, dict): return () - violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS] + violations = [ + f"/server/{key}" + for key in sorted(server) + if key not in CAMPAIGN_MAIL_SERVER_KEYS + ] for key in CAMPAIGN_MAIL_SERVER_KEYS: if key not in server: continue @@ -378,9 +443,7 @@ def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) - violations.append(f"/server/{key}") references = campaign_mail_resource_ids(raw_json) if references["mail_profile_id"] is None and any( - references[key] - for key in references - if key != "mail_profile_id" + references[key] for key in references if key != "mail_profile_id" ): violations.append("/server/mail_profile_id") for protocol in ("smtp", "imap"): diff --git a/src/govoplan_campaign/backend/integrations.py b/src/govoplan_campaign/backend/integrations.py index f379a6f..fdcbb4b 100644 --- a/src/govoplan_campaign/backend/integrations.py +++ b/src/govoplan_campaign/backend/integrations.py @@ -7,6 +7,13 @@ from contextlib import contextmanager from pathlib import Path from typing import Any, Iterator +from govoplan_core.core.approvals import ( + ApprovalCheck, + ApprovalRequestCreateCommand, + ApprovalRequestProvider, + ApprovalRequestRef, + CAPABILITY_APPROVAL_REQUESTS, +) from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_DELIVERY, @@ -29,6 +36,7 @@ MAIL_CAPABILITY = "mail.campaign_delivery" POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE +APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS class OptionalModuleUnavailable(RuntimeError): @@ -40,7 +48,9 @@ class SmtpConfigurationError(RuntimeError): class SmtpSendError(RuntimeError): - def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None: + def __init__( + self, message: str, *, temporary: bool = False, outcome_unknown: bool = False + ) -> None: super().__init__(message) self.temporary = temporary self.outcome_unknown = outcome_unknown @@ -75,6 +85,10 @@ class PostboxDeliveryUnavailable(OptionalModuleUnavailable): pass +class ApprovalGateUnavailable(OptionalModuleUnavailable): + pass + + class _PreparedCampaignSnapshot: def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None: self._directory = directory @@ -103,20 +117,30 @@ class FilesCampaignIntegration: yield prepared return - raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {} + raw_json = ( + kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {} + ) prefix = str(kwargs.get("prefix") or "govoplan-campaign-") directory = Path(tempfile.mkdtemp(prefix=prefix)) - snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json) - snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8") + snapshot = _PreparedCampaignSnapshot( + directory, directory / "campaign.json", raw_json + ) + snapshot.path.write_text( + json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8" + ) try: yield snapshot finally: snapshot.cleanup() - def managed_match_payloads(self, matches: Any, managed_files_by_local_path: dict[str, Any]) -> list[dict[str, Any]]: + def managed_match_payloads( + self, matches: Any, managed_files_by_local_path: dict[str, Any] + ) -> list[dict[str, Any]]: if self._delegate is None: return [] - return self._delegate.managed_match_payloads(matches, managed_files_by_local_path) + return self._delegate.managed_match_payloads( + matches, managed_files_by_local_path + ) def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]: if self._delegate is not None: @@ -127,20 +151,30 @@ class FilesCampaignIntegration: return dict(attachment) return {"path": str(attachment)} - def annotate_built_messages_with_managed_files(self, built_messages: Any, managed_files_by_local_path: dict[str, Any]) -> None: + def annotate_built_messages_with_managed_files( + self, built_messages: Any, managed_files_by_local_path: dict[str, Any] + ) -> None: if self._delegate is not None: - self._delegate.annotate_built_messages_with_managed_files(built_messages, managed_files_by_local_path) + self._delegate.annotate_built_messages_with_managed_files( + built_messages, managed_files_by_local_path + ) - def record_campaign_attachment_uses_for_jobs(self, session: Any, jobs: Any, *, stage: str) -> None: + def record_campaign_attachment_uses_for_jobs( + self, session: Any, jobs: Any, *, stage: str + ) -> None: if self._delegate is not None: - self._delegate.record_campaign_attachment_uses_for_jobs(session, jobs, stage=stage) + self._delegate.record_campaign_attachment_uses_for_jobs( + session, jobs, stage=stage + ) def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]: if self._delegate is None: raise OptionalModuleUnavailable("Files module is not available") return self._delegate.current_version_and_blob(session, asset) - def share_assets_with_campaign(self, session: Any, **kwargs: Any) -> list[dict[str, Any]]: + def share_assets_with_campaign( + self, session: Any, **kwargs: Any + ) -> list[dict[str, Any]]: if self._delegate is None: raise OptionalModuleUnavailable("Files module is not available") return self._delegate.share_assets_with_campaign(session, **kwargs) @@ -154,7 +188,9 @@ class MailCampaignIntegration: def __init__(self, delegate: Any | None = None) -> None: self._delegate = delegate if delegate is not None: - self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError) + self.MailProfileError = getattr( + delegate, "MailProfileError", MailProfileError + ) MailProfileError = MailProfileError SmtpConfigurationError = SmtpConfigurationError @@ -177,26 +213,38 @@ class MailCampaignIntegration: raise MailProfileError("Mail module is not available") return self._delegate - def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None: + def assert_campaign_mail_policy_allows_json( + self, session: Any, **kwargs: Any + ) -> None: if self._delegate is None: raw_json = kwargs.get("raw_json") - profile_id = self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {}) + profile_id = self.mail_profile_id_from_campaign_json( + raw_json if isinstance(raw_json, dict) else {} + ) if profile_id: - raise MailProfileError("Campaign mail-server profiles require the mail module") + raise MailProfileError( + "Campaign mail-server profiles require the mail module" + ) return None try: - return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs) + return self._delegate.assert_campaign_mail_policy_allows_json( + session, **kwargs + ) except getattr(self._delegate, "MailProfileError", MailProfileError) as exc: raise MailProfileError(str(exc)) from exc - def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None: + def mail_profile_id_from_campaign_json( + self, raw_json: dict[str, Any] + ) -> str | None: if self._delegate is not None: return self._delegate.mail_profile_id_from_campaign_json(raw_json) server = raw_json.get("server") if isinstance(raw_json, dict) else None profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None return str(profile_id).strip() if profile_id else None - def campaign_profile_delivery_summary(self, session: Any, **kwargs: Any) -> dict[str, Any]: + def campaign_profile_delivery_summary( + self, session: Any, **kwargs: Any + ) -> dict[str, Any]: delegate = self._require() try: return delegate.campaign_profile_delivery_summary(session, **kwargs) @@ -213,8 +261,14 @@ class MailCampaignIntegration: try: return delegate.send_campaign_email_bytes(*args, **kwargs) except getattr(delegate, "SmtpSendError", SmtpSendError) as exc: - raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc - except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc: + raise SmtpSendError( + str(exc), + temporary=bool(getattr(exc, "temporary", False)), + outcome_unknown=bool(getattr(exc, "outcome_unknown", False)), + ) from exc + except getattr( + delegate, "SmtpConfigurationError", SmtpConfigurationError + ) as exc: raise SmtpConfigurationError(str(exc)) from exc except getattr(delegate, "MailProfileError", MailProfileError) as exc: raise MailProfileError(str(exc)) from exc @@ -229,7 +283,9 @@ class MailCampaignIntegration: temporary=getattr(exc, "temporary", None), outcome_unknown=bool(getattr(exc, "outcome_unknown", False)), ) from exc - except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc: + except getattr( + delegate, "ImapConfigurationError", ImapConfigurationError + ) as exc: raise ImapConfigurationError(str(exc)) from exc except getattr(delegate, "MailProfileError", MailProfileError) as exc: raise MailProfileError(str(exc)) from exc @@ -302,8 +358,7 @@ class PostboxCampaignIntegration: @property def available(self) -> bool: return ( - self._delivery_delegate is not None - and self._directory_delegate is not None + self._delivery_delegate is not None and self._directory_delegate is not None ) @property @@ -381,6 +436,63 @@ class PostboxCampaignIntegration: return summaries +class ApprovalCampaignIntegration: + def __init__(self, delegate: object | None = None) -> None: + self._delegate = ( + delegate if isinstance(delegate, ApprovalRequestProvider) else None + ) + + @property + def available(self) -> bool: + return self._delegate is not None + + def create_request( + self, + session: object, + principal: object, + *, + command: ApprovalRequestCreateCommand, + idempotency_key: str, + ) -> ApprovalRequestRef: + if self._delegate is None: + raise ApprovalGateUnavailable( + "Campaign approval gates require the Approvals module." + ) + return self._delegate.create_request( + session, + principal, + command=command, + idempotency_key=idempotency_key, + ) + + def check_approved( + self, + session: object, + principal: object, + *, + request_id: str, + subject_module: str, + subject_type: str, + subject_id: str, + subject_version: str | None, + subject_digest: str, + ) -> ApprovalCheck: + if self._delegate is None: + raise ApprovalGateUnavailable( + "Campaign delivery is approval-gated, but the Approvals module is unavailable." + ) + return self._delegate.check_approved( + session, + principal, + request_id=request_id, + subject_module=subject_module, + subject_type=subject_type, + subject_id=subject_id, + subject_version=subject_version, + subject_digest=subject_digest, + ) + + def files_integration() -> FilesCampaignIntegration: return FilesCampaignIntegration(capability(FILES_CAPABILITY)) @@ -395,3 +507,7 @@ def postbox_integration() -> PostboxCampaignIntegration: capability(POSTBOX_DIRECTORY_CAPABILITY), capability(POSTBOX_EVIDENCE_CAPABILITY), ) + + +def approvals_integration() -> ApprovalCampaignIntegration: + return ApprovalCampaignIntegration(capability(APPROVALS_CAPABILITY)) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index bac4181..7432ae6 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -2,7 +2,11 @@ from __future__ import annotations from pathlib import Path -from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER +from govoplan_core.core.access import ( + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, +) +from govoplan_core.core.approvals import CAPABILITY_APPROVAL_REQUESTS from govoplan_core.core.campaigns import ( CAPABILITY_CAMPAIGNS_ACCESS, CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, @@ -10,7 +14,10 @@ from govoplan_core.core.campaigns import ( CAPABILITY_CAMPAIGNS_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_RETENTION, ) -from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) from govoplan_core.core.ownership import OwnershipProviderRegistration from govoplan_core.core.modules import ( DocumentationCondition, @@ -39,12 +46,17 @@ from govoplan_core.core.postbox import ( from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata -from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics +from govoplan_campaign.backend.documentation import ( + CAMPAIGN_USER_DOCUMENTATION, + documentation_topics, +) register_campaign_change_tracking() -def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition: +def _permission( + scope: str, label: str, description: str, category: str +) -> PermissionDefinition: module_id, resource, action = scope.split(":", 2) return PermissionDefinition( scope=scope, @@ -59,32 +71,162 @@ def _permission(scope: str, label: str, description: str, category: str) -> Perm PERMISSIONS = ( - _permission("campaigns:campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"), - _permission("campaigns:campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"), - _permission("campaigns:campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"), - _permission("campaigns:campaign:copy", "Copy campaigns", "Create campaigns or working versions from existing campaigns.", "Campaigns"), - _permission("campaigns:campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"), - _permission("campaigns:campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"), - _permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"), - _permission("campaigns:ownership:accept_group", "Accept group campaign ownership", "Accept or decline campaign ownership on behalf of a group the actor belongs to.", "Campaign governance"), - _permission("campaigns:ownership:recover", "Recover campaign ownership", "Participate in delayed, quorum-backed administrative campaign ownership recovery.", "Campaign governance"), - _permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"), - _permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"), - _permission("campaigns:campaign:review", "Complete campaign review", "Record review completion and the exact built messages inspected.", "Campaigns"), - _permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"), - _permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"), - _permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"), - _permission("campaigns:campaign:send", "Send campaigns", "Start real Mail or Postbox delivery.", "Campaigns"), - _permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"), - _permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown Mail, Postbox, or IMAP attempts after inspection.", "Campaigns"), - _permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"), - _permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"), - _permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"), - _permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"), - _permission("campaigns:recipient:export", "Export recipients", "Export recipient data and reports.", "Recipients"), - _permission("campaigns:report:read", "View reports", "View campaign delivery reports and aggregate outcomes.", "Reports"), - _permission("campaigns:report:export", "Export reports", "Download detailed campaign reports.", "Reports"), - _permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"), + _permission( + "campaigns:campaign:read", + "View campaigns", + "Open campaign metadata, versions and permitted message summaries.", + "Campaigns", + ), + _permission( + "campaigns:campaign:create", + "Create campaigns", + "Create new campaigns in an accessible tenant scope.", + "Campaigns", + ), + _permission( + "campaigns:campaign:update", + "Edit campaigns", + "Edit current working campaign versions.", + "Campaigns", + ), + _permission( + "campaigns:campaign:copy", + "Copy campaigns", + "Create campaigns or working versions from existing campaigns.", + "Campaigns", + ), + _permission( + "campaigns:campaign:archive", + "Archive campaigns", + "Archive campaigns without destroying audit evidence.", + "Campaigns", + ), + _permission( + "campaigns:campaign:delete", + "Delete campaigns", + "Delete draft-only campaigns where retention policy allows it.", + "Campaigns", + ), + _permission( + "campaigns:campaign:share", + "Share campaigns", + "Grant or revoke explicit campaign access.", + "Campaigns", + ), + _permission( + "campaigns:ownership:accept_group", + "Accept group campaign ownership", + "Accept or decline campaign ownership on behalf of a group the actor belongs to.", + "Campaign governance", + ), + _permission( + "campaigns:ownership:recover", + "Recover campaign ownership", + "Participate in delayed, quorum-backed administrative campaign ownership recovery.", + "Campaign governance", + ), + _permission( + "campaigns:campaign:validate", + "Validate campaigns", + "Run technical validation and manage validation locks.", + "Campaigns", + ), + _permission( + "campaigns:campaign:build", + "Build campaigns", + "Build exact messages and attachment evidence.", + "Campaigns", + ), + _permission( + "campaigns:campaign:review", + "Complete campaign review", + "Record review completion and the exact built messages inspected.", + "Campaigns", + ), + _permission( + "campaigns:campaign:send_test", + "Mock-send campaigns", + "Use mock delivery and verification tools.", + "Campaigns", + ), + _permission( + "campaigns:campaign:queue", + "Queue campaigns", + "Place approved executions into the delivery queue.", + "Campaigns", + ), + _permission( + "campaigns:campaign:control", + "Control delivery", + "Pause, resume or cancel queued and sending jobs.", + "Campaigns", + ), + _permission( + "campaigns:campaign:send", + "Send campaigns", + "Start real Mail or Postbox delivery.", + "Campaigns", + ), + _permission( + "campaigns:campaign:retry", + "Retry delivery", + "Retry failed or unattempted delivery jobs.", + "Campaigns", + ), + _permission( + "campaigns:campaign:reconcile", + "Reconcile delivery", + "Resolve outcome-unknown Mail, Postbox, or IMAP attempts after inspection.", + "Campaigns", + ), + _permission( + "campaigns:diagnostic:read", + "View campaign diagnostics", + "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", + "Campaign operations", + ), + _permission( + "campaigns:recipient:read", + "View recipients", + "Read recipient lists and recipient-specific campaign data.", + "Recipients", + ), + _permission( + "campaigns:recipient:write", + "Edit recipients", + "Create and edit recipient rows and field values.", + "Recipients", + ), + _permission( + "campaigns:recipient:import", + "Import recipients", + "Bulk-import recipient lists.", + "Recipients", + ), + _permission( + "campaigns:recipient:export", + "Export recipients", + "Export recipient data and reports.", + "Recipients", + ), + _permission( + "campaigns:report:read", + "View reports", + "View campaign delivery reports and aggregate outcomes.", + "Reports", + ), + _permission( + "campaigns:report:export", + "Export reports", + "Download detailed campaign reports.", + "Reports", + ), + _permission( + "campaigns:report:send", + "Send reports", + "Email campaign reports to configured recipients.", + "Reports", + ), ) OPERATOR_QUEUE_REQUIRED_ANY = ( @@ -108,9 +250,7 @@ ROLE_TEMPLATES = ( slug="campaign_aggregate_reader", name="Campaign aggregate reader", description="View privacy-protected outcome totals without recipient or delivery diagnostics.", - permissions=( - "campaigns:report:read", - ), + permissions=("campaigns:report:read",), ), RoleTemplate( slug="campaign_manager", @@ -166,7 +306,11 @@ ROLE_TEMPLATES = ( def _tenant_summary(session, tenant_id: str) -> dict[str, int]: from govoplan_campaign.backend.db.models import Campaign - return {"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count()} + return { + "campaigns": session.query(Campaign) + .filter(Campaign.tenant_id == tenant_id) + .count() + } def _tenant_summary_batch(session, tenant_ids) -> dict[str, dict[str, int]]: @@ -183,10 +327,7 @@ def _tenant_summary_batch(session, tenant_ids) -> dict[str, dict[str, int]]: .group_by(Campaign.tenant_id) .all() ) - return { - tenant_id: {"campaigns": int(count)} - for tenant_id, count in rows - } + return {tenant_id: {"campaigns": int(count)} for tenant_id, count in rows} def _campaigns_router(context: ModuleContext): @@ -207,14 +348,21 @@ manifest = ModuleManifest( id="campaigns", name="Campaigns", version="0.1.12", - required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), - optional_capabilities=(CAPABILITY_ACCESS_REFERENCE_OPTIONS,), + required_capabilities=( + CAPABILITY_AUTH_PRINCIPAL_RESOLVER, + CAPABILITY_AUTH_PERMISSION_EVALUATOR, + ), + optional_capabilities=( + CAPABILITY_ACCESS_REFERENCE_OPTIONS, + CAPABILITY_APPROVAL_REQUESTS, + ), optional_dependencies=( "files", "mail", "notifications", "addresses", "postbox", + "approvals", ), provides_interfaces=( ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"), @@ -278,6 +426,12 @@ manifest = ModuleManifest( version_max_exclusive="0.2.0", optional=True, ), + ModuleInterfaceRequirement( + name=CAPABILITY_APPROVAL_REQUESTS, + version_min="0.1.0", + version_max_exclusive="0.2.0", + optional=True, + ), ), permissions=PERMISSIONS, route_factory=_campaigns_router, @@ -285,7 +439,13 @@ manifest = ModuleManifest( tenant_summary_providers=(_tenant_summary,), tenant_summary_batch_providers=(_tenant_summary_batch,), nav_items=( - NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20), + NavItem( + path="/campaigns", + label="Campaigns", + icon="campaign", + required_any=CAMPAIGN_MODULE_REQUIRED_ANY, + order=20, + ), ), frontend=FrontendModule( module_id="campaigns", @@ -321,8 +481,16 @@ manifest = ModuleManifest( FrontendRoute(path="/templates", component="TemplatesPage", order=90), ), nav_items=( - NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20), - NavItem(path="/templates", label="Templates", icon="layout-template", order=90), + NavItem( + path="/campaigns", + label="Campaigns", + icon="campaign", + required_any=CAMPAIGN_MODULE_REQUIRED_ANY, + order=20, + ), + NavItem( + path="/templates", label="Templates", icon="layout-template", order=90 + ), ), view_surfaces=( ViewSurface( @@ -384,21 +552,34 @@ manifest = ModuleManifest( body="Configure campaign-wide targets and optional per-row additions or replacements. Derived targets resolve a published Postbox template with organization unit, function, and optional context values, including values sourced from Campaign fields. Targets are frozen during build. Fallback crosses to the second channel only after a confirmed pre-acceptance rejection; accepted or outcome-unknown effects never trigger fallback.", layer="available", documentation_types=("user", "admin"), - audience=("campaign_manager", "campaign_reviewer", "campaign_sender", "campaign_operator"), + audience=( + "campaign_manager", + "campaign_reviewer", + "campaign_sender", + "campaign_operator", + ), order=45, conditions=( DocumentationCondition( required_modules=("campaigns", "postbox"), - any_scopes=("campaigns:recipient:write", "campaigns:campaign:send", "campaigns:campaign:reconcile"), + any_scopes=( + "campaigns:recipient:write", + "campaigns:campaign:send", + "campaigns:campaign:reconcile", + ), ), ), links=( DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), DocumentationLink(label="Postboxes", href="/postbox", kind="runtime"), - DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"), + DocumentationLink( + label="Campaign schema", href="/api/v1/campaigns/schema", kind="api" + ), ), related_modules=("postbox", "organizations", "idm", "mail", "audit"), - unlocks=("Audited direct, derived, combined, and pre-acceptance fallback delivery to Postboxes.",), + unlocks=( + "Audited direct, derived, combined, and pre-acceptance fallback delivery to Postboxes.", + ), metadata={ "kind": "workflow", "route": "/campaigns/{campaign_id}/global-settings", @@ -439,8 +620,16 @@ manifest = ModuleManifest( ), links=( DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), - DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"), - DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"), + DocumentationLink( + label="Mail profiles", + href="/settings?section=mail-profiles", + kind="runtime", + ), + DocumentationLink( + label="Campaign handbook", + href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", + kind="repository", + ), ), related_modules=("mail",), unlocks=("Profile-backed SMTP delivery and optional IMAP append-to-Sent.",), @@ -480,13 +669,27 @@ manifest = ModuleManifest( conditions=( DocumentationCondition( required_modules=("campaigns", "mail"), - any_scopes=("mail:profile:write", "admin:policies:read", "system:settings:read"), + any_scopes=( + "mail:profile:write", + "admin:policies:read", + "system:settings:read", + ), ), ), links=( - DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"), - DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"), - DocumentationLink(label="Mail profile boundary", href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", kind="repository"), + DocumentationLink( + label="Mail profiles", + href="/settings?section=mail-profiles", + kind="runtime", + ), + DocumentationLink( + label="Campaign schema", href="/api/v1/campaigns/schema", kind="api" + ), + DocumentationLink( + label="Mail profile boundary", + href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", + kind="repository", + ), ), related_modules=("mail", "access"), unlocks=("Auditable, reusable transport configuration across campaigns.",), @@ -514,13 +717,27 @@ manifest = ModuleManifest( conditions=( DocumentationCondition( required_modules=("campaigns", "mail"), - any_scopes=("campaigns:diagnostic:read", "campaigns:campaign:reconcile", "mail:profile:test"), + any_scopes=( + "campaigns:diagnostic:read", + "campaigns:campaign:reconcile", + "mail:profile:test", + ), ), ), links=( - DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"), - DocumentationLink(label="Campaign reports", href="/campaigns/reports", kind="runtime"), - DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"), + DocumentationLink( + label="Campaign operator queue", + href="/campaigns/queue", + kind="runtime", + ), + DocumentationLink( + label="Campaign reports", href="/campaigns/reports", kind="runtime" + ), + DocumentationLink( + label="Campaign delivery runbook", + href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", + kind="repository", + ), ), related_modules=("mail", "audit"), unlocks=("Fail-closed recovery without exposing Mail credentials.",), @@ -548,16 +765,30 @@ manifest = ModuleManifest( conditions=( DocumentationCondition( required_modules=("campaigns",), - required_scopes=("campaigns:campaign:update", "campaigns:campaign:validate", "campaigns:campaign:build"), + required_scopes=( + "campaigns:campaign:update", + "campaigns:campaign:validate", + "campaigns:campaign:build", + ), ), ), links=( DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), - DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"), - DocumentationLink(label="Recipient import guide", href="govoplan-campaign/docs/RECIPIENT_IMPORT_GUIDE.md", kind="repository"), + DocumentationLink( + label="Campaign handbook", + href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", + kind="repository", + ), + DocumentationLink( + label="Recipient import guide", + href="govoplan-campaign/docs/RECIPIENT_IMPORT_GUIDE.md", + kind="repository", + ), ), related_modules=("addresses", "files", "mail"), - unlocks=("A reviewable build whose exact recipient-specific effects can be inspected before delivery.",), + unlocks=( + "A reviewable build whose exact recipient-specific effects can be inspected before delivery.", + ), metadata={ "kind": "workflow", "route": "/campaigns/{campaign_id}/global-settings", @@ -608,15 +839,24 @@ manifest = ModuleManifest( conditions=( DocumentationCondition( required_modules=("campaigns",), - required_scopes=("campaigns:campaign:read", "campaigns:campaign:review"), + required_scopes=( + "campaigns:campaign:read", + "campaigns:campaign:review", + ), ), ), links=( DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), - DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"), + DocumentationLink( + label="Campaign handbook", + href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", + kind="repository", + ), ), related_modules=("files", "mail"), - unlocks=("An attributable review-completion record for the exact built messages inspected.",), + unlocks=( + "An attributable review-completion record for the exact built messages inspected.", + ), metadata={ "kind": "workflow", "route": "/campaigns/{campaign_id}/review", @@ -652,20 +892,38 @@ manifest = ModuleManifest( conditions=( DocumentationCondition( required_modules=("campaigns",), - any_scopes=("campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:diagnostic:read"), + any_scopes=( + "campaigns:campaign:retry", + "campaigns:campaign:reconcile", + "campaigns:diagnostic:read", + ), ), ), links=( - DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"), - DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"), + DocumentationLink( + label="Campaign operator queue", + href="/campaigns/queue", + kind="runtime", + ), + DocumentationLink( + label="Campaign delivery runbook", + href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", + kind="repository", + ), ), related_modules=("mail", "audit"), - unlocks=("Evidence-backed recovery without accidental duplicate external effects.",), + unlocks=( + "Evidence-backed recovery without accidental duplicate external effects.", + ), metadata={ "kind": "workflow", "route": "/campaigns/queue", "screen": "Campaign operator queue", - "help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"], + "help_contexts": [ + "campaign.review-send", + "campaign.report", + "campaign.audit", + ], "prerequisites": [ "You may perform the selected retry or reconciliation action.", "Provider, mailbox, worker, and campaign evidence has been preserved.", @@ -691,20 +949,40 @@ manifest = ModuleManifest( body="Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate.", layer="evidence", documentation_types=("admin",), - audience=("platform_operator", "security_reviewer", "release_reviewer", "integrator"), + audience=( + "platform_operator", + "security_reviewer", + "release_reviewer", + "integrator", + ), order=52, conditions=( DocumentationCondition( required_modules=("campaigns",), - any_scopes=("campaigns:diagnostic:read", "campaigns:report:read", "system:settings:read", "admin:modules:read"), + any_scopes=( + "campaigns:diagnostic:read", + "campaigns:report:read", + "system:settings:read", + "admin:modules:read", + ), ), ), links=( - DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"), - DocumentationLink(label="Reference examples and release checklist", href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", kind="repository"), + DocumentationLink( + label="Campaign handbook", + href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", + kind="repository", + ), + DocumentationLink( + label="Reference examples and release checklist", + href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", + kind="repository", + ), ), related_modules=("mail", "postbox", "files", "addresses", "audit"), - unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",), + unlocks=( + "A repeatable, supportable Campaign demonstration rather than an unverified module assembly.", + ), metadata={ "kind": "reference", "route": "/campaigns", @@ -735,8 +1013,16 @@ manifest = ModuleManifest( ), ), links=( - DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"), - DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"), + DocumentationLink( + label="Campaign operator queue", + href="/campaigns/queue", + kind="runtime", + ), + DocumentationLink( + label="Campaign delivery runbook", + href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", + kind="repository", + ), ), related_modules=("files", "ops", "mail"), metadata={ @@ -800,9 +1086,22 @@ manifest = ModuleManifest( maturity="vertical_slice", documentation_ref="docs/CAMPAIGN_HANDBOOK.md", test_ref="tests/test_execution_snapshot_integrity.py", - known_limits=("Target-environment recovery and accessibility evidence is incomplete for reference-ready maturity.",), - owned_concepts=("campaign", "campaign version", "recipient snapshot", "delivery job", "delivery report"), - non_owned_concepts=("mail transport", "postbox", "durable address directory", "file storage"), + known_limits=( + "Target-environment recovery and accessibility evidence is incomplete for reference-ready maturity.", + ), + owned_concepts=( + "campaign", + "campaign version", + "recipient snapshot", + "delivery job", + "delivery report", + ), + non_owned_concepts=( + "mail transport", + "postbox", + "durable address directory", + "file storage", + ), recovery_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",), security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",), operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",), diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index ceef032..c866fd1 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -33,7 +33,10 @@ from govoplan_campaign.backend.db.models import ( JobSendStatus, JobValidationStatus, ) -from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema +from govoplan_campaign.backend.campaign.loader import ( + load_campaign_json, + validate_against_schema, +) from govoplan_campaign.backend.campaign.mail_profile_boundary import ( assert_campaign_uses_mail_profile_reference, campaign_mail_profile_id, @@ -46,7 +49,10 @@ from govoplan_campaign.backend.campaign.postbox_targets import ( ) from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.models import MessageDraft -from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary +from govoplan_campaign.backend.sending.execution import ( + create_execution_snapshot, + profile_delivery_summary, +) from govoplan_campaign.backend.campaign.models import ( CampaignConfig, DeliveryChannelPolicy, @@ -73,8 +79,6 @@ class _StoredEmlArtifact: message_id_header: str | None - - def load_campaign_config_from_json( session: Session, *, @@ -116,7 +120,11 @@ def _campaign_reference_path(version: CampaignVersion) -> Path: """Return a path anchor without persisting a second configuration copy.""" source_base_path = str(version.source_base_path or "").strip() - base = Path(source_base_path).expanduser().resolve() if source_base_path else Path.cwd() + base = ( + Path(source_base_path).expanduser().resolve() + if source_base_path + else Path.cwd() + ) return base / "campaign.json" @@ -142,8 +150,7 @@ def _persist_built_eml_artifacts( path = Path(message.eml_path) if not path.is_file(): raise CampaignPersistenceError( - "Generated EML evidence is missing for entry " - f"{message.entry_index}" + f"Generated EML evidence is missing for entry {message.entry_index}" ) payload = path.read_bytes() digest = hashlib.sha256(payload).hexdigest() @@ -191,7 +198,11 @@ def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> None: def _next_version_number(session: Session, campaign_id: str) -> int: - current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar() + current = ( + session.query(func.max(CampaignVersion.version_number)) + .filter(CampaignVersion.campaign_id == campaign_id) + .scalar() + ) return int(current or 0) + 1 @@ -204,7 +215,9 @@ def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | No return str((base_path / path).resolve()) -def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]: +def normalize_campaign_paths( + raw_json: dict[str, Any], source_base_path: str | Path | None +) -> dict[str, Any]: """Resolve paths for an explicitly trusted, file-oriented import. The CLI naturally resolves relative paths against the campaign.json file. @@ -217,18 +230,28 @@ def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | P base = Path(source_base_path).expanduser().resolve() if source_base_path else None data = copy.deepcopy(raw_json) - template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None + template_source = ( + data.get("template", {}).get("source") + if isinstance(data.get("template"), dict) + else None + ) if isinstance(template_source, dict): for key in ("subject_path", "text_path", "html_path"): template_source[key] = _resolve_runtime_path(base, template_source.get(key)) - entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None + entries_source = ( + data.get("entries", {}).get("source") + if isinstance(data.get("entries"), dict) + else None + ) if isinstance(entries_source, dict): entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path")) attachments = data.get("attachments") if isinstance(attachments, dict): - attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "." + attachments["base_path"] = ( + _resolve_runtime_path(base, attachments.get("base_path")) or "." + ) return data @@ -251,15 +274,21 @@ def create_campaign_version_from_json( ) if source_base_path is None and source_filename: source_path = Path(source_filename).expanduser() - source_base_path = str(source_path.parent if source_path.suffix else source_path) + source_base_path = str( + source_path.parent if source_path.suffix else source_path + ) runtime_json = normalize_campaign_paths(raw_json, source_base_path) - config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id) + config = load_campaign_config_from_json( + session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id + ) campaign = ( session.query(Campaign) - .filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id) + .filter( + Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id + ) .one_or_none() ) if campaign is None: @@ -275,7 +304,11 @@ def create_campaign_version_from_json( session.add(campaign) session.flush() else: - current = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None + current = ( + session.get(CampaignVersion, campaign.current_version_id) + if campaign.current_version_id + else None + ) if current and not _version_is_audit_safe_snapshot(current): raise CampaignPersistenceError( f"Campaign already has active working version #{current.version_number}. " @@ -303,8 +336,6 @@ def create_campaign_version_from_json( return campaign, version - - def _version_user_lock_state(version: CampaignVersion) -> str | None: state = getattr(version, "user_lock_state", None) if state in {"temporary", "permanent"}: @@ -317,7 +348,9 @@ def _version_is_user_locked(version: CampaignVersion) -> bool: def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool: - return _version_user_lock_state(version) == "permanent" or version.workflow_state in { + return _version_user_lock_state( + version + ) == "permanent" or version.workflow_state in { CampaignVersionWorkflowState.QUEUED.value, CampaignVersionWorkflowState.SENDING.value, CampaignVersionWorkflowState.COMPLETED.value, @@ -329,7 +362,9 @@ def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool: } -def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersion, *, action: str) -> None: +def _ensure_current_campaign_version( + campaign: Campaign, version: CampaignVersion, *, action: str +) -> None: if campaign.current_version_id != version.id: raise CampaignPersistenceError( f"Historical campaign versions are read-only and cannot be used to {action}. " @@ -338,18 +373,33 @@ def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersio def _version_is_validated_and_locked(version: CampaignVersion) -> bool: - validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} - return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version)) + validation_summary = ( + version.validation_summary + if isinstance(version.validation_summary, dict) + else {} + ) + return bool( + version.locked_at + and validation_summary.get("ok") is True + and not _version_is_user_locked(version) + ) def _ensure_version_validated_and_locked(version: CampaignVersion) -> None: state = _version_user_lock_state(version) if state == "temporary": - raise CampaignPersistenceError("This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending.") + raise CampaignPersistenceError( + "This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending." + ) if state == "permanent": - raise CampaignPersistenceError("This version is permanently user-locked. Create an editable copy instead.") + raise CampaignPersistenceError( + "This version is permanently user-locked. Create an editable copy instead." + ) if not _version_is_validated_and_locked(version): - raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.") + raise CampaignPersistenceError( + "Campaign version must be validated and locked before building, queueing, dry-run or sending." + ) + def load_version_config(session: Session, version_id: str): version = session.get(CampaignVersion, version_id) @@ -364,7 +414,16 @@ def load_version_config(session: Session, version_id: str): managed_files_available=files_integration().available, ) path = _campaign_reference_path(version) - return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id) + return ( + version, + path, + load_campaign_config_from_json( + session, + tenant_id=campaign.tenant_id, + raw_json=raw_json, + campaign_id=campaign.id, + ), + ) def validate_campaign_version( @@ -379,7 +438,9 @@ def validate_campaign_version( version, snapshot_path, config = load_version_config(session, version_id) campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: - raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + raise CampaignPersistenceError( + "Campaign version is not accessible for this tenant" + ) _ensure_current_campaign_version(campaign, version, action="validate") if _version_is_user_locked(version) or version.workflow_state in { CampaignVersionWorkflowState.QUEUED.value, @@ -391,8 +452,14 @@ def validate_campaign_version( CampaignVersionWorkflowState.CANCELLED.value, CampaignVersionWorkflowState.ARCHIVED.value, }: - lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final" - raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.") + lock_label = ( + "temporarily user-locked" + if _version_user_lock_state(version) == "temporary" + else "permanently locked/final" + ) + raise CampaignPersistenceError( + f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead." + ) if check_files: files = files_integration() @@ -405,7 +472,12 @@ def validate_campaign_version( prefix="govoplan-managed-validate-", ) as prepared: managed_raw = load_campaign_json(prepared.path) - managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id) + managed_config = load_campaign_config_from_json( + session, + tenant_id=tenant_id, + raw_json=managed_raw, + campaign_id=campaign.id, + ) report = validate_campaign_config( managed_config, campaign_file=prepared.path, @@ -434,7 +506,10 @@ def validate_campaign_version( # Replace version-level semantic issues from previous validations. ( session.query(CampaignIssue) - .filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None)) + .filter( + CampaignIssue.campaign_version_id == version.id, + CampaignIssue.job_id.is_(None), + ) .delete(synchronize_session=False) ) for issue in report.issues: @@ -450,7 +525,11 @@ def validate_campaign_version( ) ) - campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value + campaign.status = ( + CampaignStatus.VALIDATED.value + if report.ok + else CampaignStatus.NEEDS_REVIEW.value + ) if report.ok: version.workflow_state = CampaignVersionWorkflowState.APPROVED.value version.is_complete = True @@ -477,7 +556,9 @@ def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]: if not path.exists(): return None, None payload = path.read_bytes() - message_id = BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID") + message_id = ( + BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID") + ) return hashlib.sha256(payload).hexdigest(), str(message_id) if message_id else None @@ -508,7 +589,9 @@ def _job_from_message( eml_local_path=message.eml_path if stored_eml is None else None, eml_size_bytes=stored_eml.size_bytes if stored_eml else message.eml_size_bytes, eml_sha256=eml_sha256, - build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status), + build_status=message.build_status.value + if hasattr(message.build_status, "value") + else str(message.build_status), validation_status=_job_validation_status(message.validation_status.value), queue_status=JobQueueStatus.DRAFT.value, send_status=( @@ -522,7 +605,9 @@ def _job_from_message( if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox else JobPostboxStatus.NOT_REQUESTED.value ), - imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value, + imap_status=message.imap_status.value + if hasattr(message.imap_status, "value") + else JobImapStatus.NOT_REQUESTED.value, resolved_recipients={ "from": message.from_.model_dump(mode="json") if message.from_ else None, "from_all": [item.model_dump(mode="json") for item in message.from_all], @@ -531,12 +616,21 @@ def _job_from_message( "bcc": [item.model_dump(mode="json") for item in message.bcc], "reply_to": [item.model_dump(mode="json") for item in message.reply_to], "bounce_to": [item.model_dump(mode="json") for item in message.bounce_to], - "disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to], + "disposition_notification_to": [ + item.model_dump(mode="json") + for item in message.disposition_notification_to + ], }, resolved_postbox_targets=resolved_postbox_targets or [], - resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments], + resolved_attachments=[ + files_integration().public_attachment_summary_payload(item) + for item in message.attachments + ], issues_snapshot=[item.model_dump(mode="json") for item in message.issues], - last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None, + last_error="; ".join( + issue.message for issue in message.issues if issue.severity == "error" + ) + or None, ) @@ -554,7 +648,9 @@ def _resolve_built_postbox_targets( continue entry = entries_by_index.get(built.draft.entry_index) if entry is None: - raise CampaignPersistenceError("Built recipient row is missing from the campaign input.") + raise CampaignPersistenceError( + "Built recipient row is missing from the campaign input." + ) resolved, issues, validation_status = resolve_entry_postbox_targets( session, tenant_id=tenant_id, @@ -571,22 +667,29 @@ def _resolve_built_postbox_targets( def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]: report_json = result.report.model_dump(mode="json", by_alias=True) - for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False): + for message_payload, message in zip( + report_json.get("messages", []), result.report.messages, strict=False + ): if isinstance(message_payload, dict): - message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments] - report_json.update({ - "built_at": datetime.now(UTC).isoformat(), - "build_token": uuid4().hex, - "built_count": result.report.built_count, - "build_failed_count": result.report.build_failed_count, - "ready_count": result.report.ready_count, - "warning_count": result.report.warning_count, - "needs_review_count": result.report.needs_review_count, - "blocked_count": result.report.blocked_count, - "excluded_count": result.report.excluded_count, - "inactive_count": result.report.inactive_count, - "queueable_count": result.report.queueable_count, - }) + message_payload["attachments"] = [ + files.public_attachment_summary_payload(item) + for item in message.attachments + ] + report_json.update( + { + "built_at": datetime.now(UTC).isoformat(), + "build_token": uuid4().hex, + "built_count": result.report.built_count, + "build_failed_count": result.report.build_failed_count, + "ready_count": result.report.ready_count, + "warning_count": result.report.warning_count, + "needs_review_count": result.report.needs_review_count, + "blocked_count": result.report.blocked_count, + "excluded_count": result.report.excluded_count, + "inactive_count": result.report.inactive_count, + "queueable_count": result.report.queueable_count, + } + ) return report_json @@ -614,7 +717,9 @@ def _replace_version_jobs( CampaignIssue.campaign_version_id == version_id, CampaignIssue.job_id.is_not(None), ).delete(synchronize_session=False) - session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).delete(synchronize_session=False) + session.query(CampaignJob).filter( + CampaignJob.campaign_version_id == version_id + ).delete(synchronize_session=False) session.flush() pairs: list[tuple[CampaignJob, MessageDraft]] = [] @@ -624,7 +729,9 @@ def _replace_version_jobs( campaign_id=campaign_id, version_id=version_id, message=built.draft, - resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []), + resolved_postbox_targets=postbox_targets_by_index.get( + built.draft.entry_index, [] + ), stored_eml=stored_eml_by_index.get(built.draft.entry_index), ) session.add(job) @@ -640,16 +747,26 @@ def _mail_execution_profile( config: CampaignConfig, jobs: list[CampaignJob], ) -> tuple[str | None, dict[str, Any]]: - if not any(DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs): + if not any( + DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs + ): return None, {} if not config.server.profile_capabilities.smtp_available: - raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created.") - profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {}) + raise CampaignPersistenceError( + "The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created." + ) + profile_id = campaign_mail_profile_id( + version.raw_json if isinstance(version.raw_json, dict) else {} + ) if profile_id is None: - raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages that use Mail.") + raise CampaignPersistenceError( + "Select an authorized Mail profile before building campaign messages that use Mail." + ) summary = profile_delivery_summary(session, version) if not summary.get("smtp_transport_revision"): - raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision.") + raise CampaignPersistenceError( + "The selected Mail profile has no SMTP transport revision." + ) return profile_id, summary @@ -661,7 +778,9 @@ def _store_execution_snapshot( jobs: list[CampaignJob], build_summary: dict[str, Any], ) -> None: - profile_id, profile = _mail_execution_profile(session, version=version, config=config, jobs=jobs) + profile_id, profile = _mail_execution_profile( + session, version=version, config=config, jobs=jobs + ) snapshot, snapshot_hash = create_execution_snapshot( version, mail_profile_id=profile_id, @@ -689,20 +808,22 @@ def _store_job_issues( job_build_pairs: list[tuple[CampaignJob, MessageDraft]], ) -> None: for job, message in job_build_pairs: - session.add_all([ - CampaignIssue( - tenant_id=tenant_id, - campaign_id=campaign_id, - campaign_version_id=version_id, - job_id=job.id, - severity=issue.severity, - code=issue.code, - message=issue.message, - source=issue.source, - behavior=issue.behavior, - ) - for issue in message.issues - ]) + session.add_all( + [ + CampaignIssue( + tenant_id=tenant_id, + campaign_id=campaign_id, + campaign_version_id=version_id, + job_id=job.id, + severity=issue.severity, + code=issue.code, + message=issue.message, + source=issue.source, + behavior=issue.behavior, + ) + for issue in message.issues + ] + ) def _apply_campaign_build_state( @@ -734,13 +855,21 @@ def build_campaign_version( version, snapshot_path, config = load_version_config(session, version_id) campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: - raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + raise CampaignPersistenceError( + "Campaign version is not accessible for this tenant" + ) _ensure_current_campaign_version(campaign, version, action="build") if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value: raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt") - validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} + validation_summary = ( + version.validation_summary + if isinstance(version.validation_summary, dict) + else {} + ) if not validation_summary.get("ok"): - raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built") + raise CampaignPersistenceError( + "Campaign version must be successfully validated before messages are built" + ) _ensure_version_validated_and_locked(version) files = files_integration() @@ -804,6 +933,7 @@ def build_campaign_version( version.build_summary = report_json editor_state = copy.deepcopy(version.editor_state or {}) editor_state.pop("review_send", None) + editor_state.pop("approval_gate", None) version.editor_state = editor_state job_build_pairs, old_storage_keys = _replace_version_jobs( diff --git a/src/govoplan_campaign/backend/persistence/versions.py b/src/govoplan_campaign/backend/persistence/versions.py index b857228..48aceaa 100644 --- a/src/govoplan_campaign/backend/persistence/versions.py +++ b/src/govoplan_campaign/backend/persistence/versions.py @@ -52,22 +52,30 @@ class _PartialValidationCollector: section: str | None issues: list[dict[str, Any]] = field(default_factory=list) - def issue(self, severity: str, section: str, field: str, code: str, message: str) -> None: + def issue( + self, severity: str, section: str, field: str, code: str, message: str + ) -> None: if self.section is None or self.section == section: - self.issues.append({ - "severity": severity, - "section": section, - "field": field, - "code": code, - "message": message, - }) + self.issues.append( + { + "severity": severity, + "section": section, + "field": field, + "code": code, + "message": message, + } + ) def result(self) -> dict[str, Any]: return { "ok": not any(item["severity"] == "error" for item in self.issues), "section": self.section, - "error_count": sum(1 for item in self.issues if item["severity"] == "error"), - "warning_count": sum(1 for item in self.issues if item["severity"] == "warning"), + "error_count": sum( + 1 for item in self.issues if item["severity"] == "error" + ), + "warning_count": sum( + 1 for item in self.issues if item["severity"] == "warning" + ), "info_count": sum(1 for item in self.issues if item["severity"] == "info"), "issues": self.issues, } @@ -101,7 +109,9 @@ def campaign_version_user_lock_state(version: CampaignVersion) -> str | None: return None -def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]: +def minimal_campaign_json( + *, external_id: str, name: str, description: str | None = None +) -> dict[str, Any]: """Return a WebUI-friendly starter campaign JSON. It is intentionally usable as an editable working copy. It contains the @@ -216,9 +226,15 @@ def create_minimal_campaign( current_step: str = "basics", commit: bool = True, ) -> tuple[Campaign, CampaignVersion]: - existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none() + existing = ( + session.query(Campaign) + .filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id) + .one_or_none() + ) if existing: - raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant") + raise CampaignPersistenceError( + f"Campaign with id '{external_id}' already exists for this tenant" + ) campaign = Campaign( tenant_id=tenant_id, @@ -235,7 +251,9 @@ def create_minimal_campaign( version = CampaignVersion( campaign_id=campaign.id, version_number=1, - raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description), + raw_json=minimal_campaign_json( + external_id=external_id, name=name, description=description + ), schema_version="1.0", workflow_state=CampaignVersionWorkflowState.EDITING.value, current_flow=current_flow, @@ -264,13 +282,16 @@ def get_campaign_version_for_tenant( ) -> CampaignVersion: campaign = session.get(Campaign, campaign_id) version = session.get(CampaignVersion, version_id) - if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id: + if ( + not campaign + or campaign.tenant_id != tenant_id + or not version + or version.campaign_id != campaign.id + ): raise CampaignPersistenceError("Campaign version not found") return version - - LOCKED_WORKFLOW_STATES = { CampaignVersionWorkflowState.APPROVED.value, CampaignVersionWorkflowState.BUILT.value, @@ -295,7 +316,9 @@ def is_version_locked(version: CampaignVersion) -> bool: ) -def ensure_current_working_version(campaign: Campaign, version: CampaignVersion, *, action: str = "modify") -> None: +def ensure_current_working_version( + campaign: Campaign, version: CampaignVersion, *, action: str = "modify" +) -> None: """Require the campaign's single active working version. Historical versions remain reviewable, but they never become writable in @@ -326,14 +349,18 @@ def campaign_has_active_working_version(session: Session, campaign: Campaign) -> def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None: - campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + campaign_meta = ( + raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + ) if campaign_meta: campaign.name = campaign_meta.get("name") or campaign.name campaign.description = campaign_meta.get("description", campaign.description) campaign.external_id = campaign_meta.get("id") or campaign.external_id -def _assert_fork_source_allowed(session: Session, *, campaign: Campaign, source: CampaignVersion) -> None: +def _assert_fork_source_allowed( + session: Session, *, campaign: Campaign, source: CampaignVersion +) -> None: if campaign_has_active_working_version(session, campaign): current = session.get(CampaignVersion, campaign.current_version_id) current_number = current.version_number if current else "current" @@ -375,7 +402,11 @@ def _fork_runtime_json( source_base_path=source_base_path, managed_files_available=files_integration().available, ) - runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json) + runtime_json = ( + normalize_campaign_paths(base_json, source_base_path) + if source_base_path + else copy.deepcopy(base_json) + ) assert_campaign_uses_mail_profile_reference(runtime_json) mail_integration().assert_campaign_mail_policy_allows_json( session, @@ -404,10 +435,16 @@ def _new_forked_campaign_version( version_number=_next_version_number(session, campaign.id), raw_json=runtime_json, schema_version=str(runtime_json.get("version", source.schema_version or "1.0")), - source_filename=source_filename if source_filename is not None else source.source_filename, - source_base_path=source_base_path if source_base_path is not None else source.source_base_path, + source_filename=source_filename + if source_filename is not None + else source.source_filename, + source_base_path=source_base_path + if source_base_path is not None + else source.source_base_path, workflow_state=CampaignVersionWorkflowState.EDITING.value, - current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value), + current_flow=current_flow + if current_flow is not None + else (source.current_flow or CampaignVersionFlow.MANUAL.value), current_step=current_step if current_step is not None else source.current_step, is_complete=False, editor_state=( @@ -461,7 +498,9 @@ def fork_campaign_version_for_edit( version is permanently user-locked or delivery-final. """ - source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + source = get_campaign_version_for_tenant( + session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id + ) campaign = _require_campaign(session, campaign_id) _assert_fork_source_allowed(session, campaign=campaign, source=source) @@ -487,11 +526,15 @@ def fork_campaign_version_for_edit( source_base_path=source_base_path, autosave=autosave, ) - _persist_forked_version(session, campaign=campaign, version=new_version, commit=commit) + _persist_forked_version( + session, campaign=campaign, version=new_version, commit=commit + ) return new_version -def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None: +def lock_validated_version( + version: CampaignVersion, *, user_id: str | None = None +) -> None: if version.locked_at is None: version.locked_at = datetime.now(UTC) version.locked_by_user_id = user_id @@ -535,7 +578,11 @@ def is_audit_safe_version(version: CampaignVersion) -> bool: def is_version_validated_and_locked(version: CampaignVersion) -> bool: """Return True when the version was successfully validated and locked as a review snapshot.""" - validation = version.validation_summary if isinstance(version.validation_summary, dict) else {} + validation = ( + version.validation_summary + if isinstance(version.validation_summary, dict) + else {} + ) return bool(version.locked_at and validation.get("ok") is True) @@ -554,28 +601,40 @@ def unlock_validated_campaign_version( be copied instead. """ - version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + version = get_campaign_version_for_tenant( + session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id + ) campaign = _require_campaign(session, campaign_id) ensure_current_working_version(campaign, version, action="unlock") if is_temporary_user_locked_version(version): - raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.") + raise LockedCampaignVersionError( + "This version has a temporary user lock. Remove that lock before unlocking validation." + ) if is_permanent_user_locked_version(version): - raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.") + raise LockedCampaignVersionError( + "This version is permanently locked and cannot be unlocked. Create an editable copy instead." + ) if is_version_final_locked(version): - raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.") + raise LockedCampaignVersionError( + "This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead." + ) # A version with sent jobs is final even if workflow_state was not updated for some reason. sent_jobs = ( session.query(CampaignJob) .filter( CampaignJob.campaign_version_id == version.id, - CampaignJob.send_status.in_([JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]), + CampaignJob.send_status.in_( + [JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value] + ), ) .count() ) if sent_jobs: - raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.") + raise LockedCampaignVersionError( + "This version has sent messages and cannot be unlocked. Create an editable copy instead." + ) version.locked_at = None version.locked_by_user_id = None @@ -584,12 +643,17 @@ def unlock_validated_campaign_version( clear_execution_snapshot(version) editor_state = copy.deepcopy(version.editor_state or {}) editor_state.pop("review_send", None) + editor_state.pop("approval_gate", None) version.editor_state = editor_state version.workflow_state = CampaignVersionWorkflowState.EDITING.value version.is_complete = False - 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) + 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.current_version_id = version.id campaign.status = CampaignStatus.DRAFT.value @@ -601,6 +665,7 @@ def unlock_validated_campaign_version( session.flush() return version + def _assert_update_paths_safe( raw_json: dict[str, Any] | None, *, @@ -627,8 +692,14 @@ def _updated_runtime_json( source_base_path: str | None, migrate_legacy_mail_settings: bool, ) -> dict[str, Any]: - runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json) - requires_migration = bool(campaign_mail_profile_boundary_violations(version.raw_json)) + runtime_json = ( + normalize_campaign_paths(raw_json, source_base_path) + if source_base_path + else copy.deepcopy(raw_json) + ) + requires_migration = bool( + campaign_mail_profile_boundary_violations(version.raw_json) + ) if requires_migration and not migrate_legacy_mail_settings: raise CampaignPersistenceError( "This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail " @@ -679,7 +750,9 @@ def _apply_version_field_updates( version.autosaved_at = datetime.now(UTC) -def _invalidate_version_content(session: Session, *, campaign: Campaign, version: CampaignVersion) -> None: +def _invalidate_version_content( + session: Session, *, campaign: Campaign, version: CampaignVersion +) -> None: version.validation_summary = None version.build_summary = None clear_execution_snapshot(version) @@ -688,7 +761,9 @@ def _invalidate_version_content(session: Session, *, campaign: Campaign, version if version.workflow_state != CampaignVersionWorkflowState.EDITING.value: version.workflow_state = CampaignVersionWorkflowState.EDITING.value campaign.status = CampaignStatus.DRAFT.value - session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) + session.query(CampaignIssue).filter( + CampaignIssue.campaign_version_id == version.id + ).delete(synchronize_session=False) def _persist_updated_version( @@ -726,22 +801,23 @@ def update_campaign_version( expected_revision: int | None = None, commit: bool = True, ) -> CampaignVersion: - _assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path) - version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + _assert_update_paths_safe( + raw_json, source_filename=source_filename, source_base_path=source_base_path + ) + version = get_campaign_version_for_tenant( + session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id + ) campaign = _require_campaign(session, campaign_id) ensure_current_working_version(campaign, version, action="edit") - if ( - expected_revision is not None - and version.edit_revision != int(expected_revision) + if expected_revision is not None and version.edit_revision != int( + expected_revision ): raise RevisionConflictError( resource_type="campaign_version", resource_id=version.id, current_revision=version.edit_revision, submitted_base_revision=int(expected_revision), - refresh_path=( - f"/api/v1/campaigns/{campaign.id}/versions/{version.id}" - ), + refresh_path=(f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"), current_etag=strong_resource_etag( "campaign_version", version.id, @@ -765,7 +841,9 @@ def update_campaign_version( migrate_legacy_mail_settings=migrate_legacy_mail_settings, ) version.raw_json = runtime_json - version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0")) + version.schema_version = str( + runtime_json.get("version", version.schema_version or "1.0") + ) _apply_campaign_metadata(campaign, runtime_json) _apply_version_field_updates( @@ -810,9 +888,7 @@ def update_campaign_version( if expected_revision is not None else version.edit_revision ), - refresh_path=( - f"/api/v1/campaigns/{campaign_id}/versions/{version_id}" - ), + refresh_path=(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"), current_etag=strong_resource_etag( "campaign_version", version_id, @@ -850,9 +926,15 @@ def update_campaign_review_state( campaign = _require_campaign(session, campaign_id) ensure_current_working_version(campaign, version, action="record review state for") if is_version_final_locked(version): - raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.") + raise LockedCampaignVersionError( + "Delivery has started; message review state can no longer be changed." + ) build_token = _campaign_review_build_token(version) - normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip())) + normalized_reviewed = list( + dict.fromkeys( + str(value) for value in reviewed_message_keys if str(value).strip() + ) + ) normalized_decisions: list[dict[str, Any]] = [] if inspection_complete: normalized_reviewed, normalized_decisions = _complete_campaign_review( @@ -880,10 +962,14 @@ def update_campaign_review_state( def _campaign_review_build_token(version: CampaignVersion) -> str: - build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} + build_summary = ( + version.build_summary if isinstance(version.build_summary, dict) else {} + ) if not build_summary: raise CampaignPersistenceError("Build messages before recording review state.") - build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip() + build_token = str( + build_summary.get("build_token") or build_summary.get("built_at") or "" + ).strip() if build_token: return build_token build_token = uuid4().hex @@ -908,13 +994,20 @@ def _complete_campaign_review( .order_by(CampaignJob.entry_index.asc()) .all() ) - blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"] + blocking = [ + job + for job in jobs + if job.build_status != "built" or job.validation_status == "blocked" + ] if blocking: - raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.") + raise CampaignPersistenceError( + "Blocked or failed messages must be resolved before review can be completed." + ) missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys)) if missing: raise CampaignPersistenceError( - "Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing) + "Messages requiring an explicit decision must be opened before review can be completed: " + + ", ".join(missing) ) decisions = _normalize_review_issue_decisions( jobs, @@ -924,9 +1017,7 @@ def _complete_campaign_review( ) return ( list( - dict.fromkeys( - [*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)] - ) + dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]) ), decisions, ) @@ -957,9 +1048,7 @@ def _normalize_review_issue_decisions( "Only one review decision may be recorded per built message." ) if str(item.get("decision") or "accept") != "accept": - raise CampaignPersistenceError( - "Unsupported campaign review decision." - ) + raise CampaignPersistenceError("Unsupported campaign review decision.") requested_by_job[job_id] = item timestamp = (decided_at or datetime.now(UTC)).isoformat() @@ -990,9 +1079,7 @@ def _normalize_review_issue_decisions( "Attachment exception decisions require a reason." ) evidence_issues = reviewable_issues or [ - issue - for issue in (job.issues_snapshot or []) - if isinstance(issue, dict) + issue for issue in (job.issues_snapshot or []) if isinstance(issue, dict) ] issue_payload = [ { @@ -1092,13 +1179,17 @@ def lock_campaign_version_temporarily( campaign = _require_campaign(session, campaign_id) ensure_current_working_version(campaign, version, action="lock") if is_version_final_locked(version): - raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.") + raise LockedCampaignVersionError( + "Delivery/final versions are permanently locked and cannot receive a temporary user lock." + ) if is_permanent_user_locked_version(version): raise LockedCampaignVersionError("This version is already permanently locked.") if is_temporary_user_locked_version(version): return version if version.locked_at: - raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.") + raise LockedCampaignVersionError( + "This version is already temporarily locked by validation. Unlock validation before applying a user lock." + ) version.user_lock_state = USER_LOCK_TEMPORARY version.user_locked_at = datetime.now(UTC) @@ -1131,11 +1222,17 @@ def unlock_user_locked_campaign_version( ensure_current_working_version(campaign, version, action="unlock") state = campaign_version_user_lock_state(version) if state == USER_LOCK_PERMANENT: - raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.") + raise LockedCampaignVersionError( + "Permanently locked versions cannot be unlocked. Create an editable copy instead." + ) if state != USER_LOCK_TEMPORARY: - raise LockedCampaignVersionError("This version does not have a temporary user lock.") + raise LockedCampaignVersionError( + "This version does not have a temporary user lock." + ) if is_version_final_locked(version): - raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.") + raise LockedCampaignVersionError( + "Delivery/final versions cannot be unlocked. Create an editable copy instead." + ) version.user_lock_state = None version.user_locked_at = None @@ -1172,7 +1269,9 @@ def permanently_lock_campaign_version( campaign = _require_campaign(session, campaign_id) ensure_current_working_version(campaign, version, action="lock permanently") if is_version_final_locked(version): - raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.") + raise LockedCampaignVersionError( + "This version is already permanently locked by its delivery/final state." + ) if is_permanent_user_locked_version(version): return version @@ -1211,7 +1310,9 @@ def publish_campaign_version( ) -def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]: +def validate_campaign_partial( + raw_json: dict[str, Any], *, section: str | None = None +) -> dict[str, Any]: """Lightweight UI-facing validation for incomplete campaign working copies. This is intentionally less strict than campaign.schema.json validation. It @@ -1235,20 +1336,44 @@ def _dict_value(value: dict[str, Any], key: str) -> dict[str, Any]: return candidate if isinstance(candidate, dict) else {} -def _validate_partial_basics(collector: _PartialValidationCollector, campaign: dict[str, Any]) -> None: +def _validate_partial_basics( + collector: _PartialValidationCollector, campaign: dict[str, Any] +) -> None: if not campaign.get("id"): - collector.issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.") + collector.issue( + "error", + "basics", + "campaign.id", + "missing_campaign_id", + "Campaign id is required.", + ) if not campaign.get("name"): - collector.issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.") + collector.issue( + "error", + "basics", + "campaign.name", + "missing_campaign_name", + "Campaign name is required.", + ) -def _validate_partial_sender(collector: _PartialValidationCollector, recipients: dict[str, Any]) -> None: +def _validate_partial_sender( + collector: _PartialValidationCollector, recipients: dict[str, Any] +) -> None: sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {} if not sender.get("email"): - collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.") + collector.issue( + "warning", + "sender", + "recipients.from.email", + "missing_sender_email", + "Sender email is not configured yet.", + ) -def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None: +def _validate_partial_mail_profile( + collector: _PartialValidationCollector, raw_json: dict[str, Any] +) -> None: violations = campaign_mail_profile_boundary_violations(raw_json) if violations: collector.issue( @@ -1268,13 +1393,27 @@ def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_j ) -def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None: +def _validate_partial_recipients( + collector: _PartialValidationCollector, entries: dict[str, Any] +) -> None: has_inline = bool(entries.get("inline")) has_source = isinstance(entries.get("source"), dict) if not has_inline and not has_source: - collector.issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.") + collector.issue( + "warning", + "recipients", + "entries", + "missing_recipients", + "No inline recipients or external recipient source configured yet.", + ) if has_source and not _entries_source_has_email_mapping(entries): - collector.issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.") + collector.issue( + "warning", + "recipients", + "entries.mapping", + "missing_email_mapping", + "No email field mapping is configured.", + ) def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool: @@ -1282,58 +1421,142 @@ def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool: return any(key in mapping for key in ("to.0.email", "to.email", "email")) -def _validate_partial_template(collector: _PartialValidationCollector, template: dict[str, Any]) -> None: - source_template = template.get("source") if isinstance(template.get("source"), dict) else {} +def _validate_partial_template( + collector: _PartialValidationCollector, template: dict[str, Any] +) -> None: + source_template = ( + template.get("source") if isinstance(template.get("source"), dict) else {} + ) if not template.get("subject") and not source_template.get("subject_path"): - collector.issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.") + collector.issue( + "warning", + "template", + "template.subject", + "missing_subject", + "Template subject is empty.", + ) body_state = _partial_template_body_state(template, source_template) _validate_partial_template_body(collector, body_state) -def _partial_template_body_state(template: dict[str, Any], source_template: dict[str, Any]) -> dict[str, Any]: +def _partial_template_body_state( + template: dict[str, Any], source_template: dict[str, Any] +) -> dict[str, Any]: return { - "mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None, - "has_text": bool(template.get("text")) or bool(source_template.get("text_path")), - "has_html": bool(template.get("html")) or bool(source_template.get("html_path")), + "mode": template.get("body_mode") + if template.get("body_mode") in {"text", "html", "both"} + else None, + "has_text": bool(template.get("text")) + or bool(source_template.get("text_path")), + "has_html": bool(template.get("html")) + or bool(source_template.get("html_path")), "has_source": bool(source_template), } -def _validate_partial_template_body(collector: _PartialValidationCollector, state: dict[str, Any]) -> None: +def _validate_partial_template_body( + collector: _PartialValidationCollector, state: dict[str, Any] +) -> None: mode = state["mode"] has_text = bool(state["has_text"]) has_html = bool(state["has_html"]) if mode == "text" and not has_text: - collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.") + collector.issue( + "warning", + "template", + "template.text", + "missing_template_text_body", + "Template body mode is text only, but no text body is configured.", + ) elif mode == "html" and not has_html: - collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.") + collector.issue( + "warning", + "template", + "template.html", + "missing_template_html_body", + "Template body mode is HTML only, but no HTML body is configured.", + ) elif mode == "both": - _validate_partial_dual_body_template(collector, has_text=has_text, has_html=has_html) + _validate_partial_dual_body_template( + collector, has_text=has_text, has_html=has_html + ) elif not has_text and not has_html and not state["has_source"]: - collector.issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.") + collector.issue( + "warning", + "template", + "template", + "missing_template_body", + "No text, HTML or file-based template body configured yet.", + ) -def _validate_partial_dual_body_template(collector: _PartialValidationCollector, *, has_text: bool, has_html: bool) -> None: +def _validate_partial_dual_body_template( + collector: _PartialValidationCollector, *, has_text: bool, has_html: bool +) -> None: if not has_text: - collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.") + collector.issue( + "warning", + "template", + "template.text", + "missing_template_text_body", + "Template body mode is both, but no text body is configured.", + ) if not has_html: - collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.") + collector.issue( + "warning", + "template", + "template.html", + "missing_template_html_body", + "Template body mode is both, but no HTML body is configured.", + ) -def _validate_partial_attachments(collector: _PartialValidationCollector, attachments: dict[str, Any]) -> None: - base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else [] - has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths) +def _validate_partial_attachments( + collector: _PartialValidationCollector, attachments: dict[str, Any] +) -> None: + base_paths = ( + attachments.get("base_paths") + if isinstance(attachments.get("base_paths"), list) + else [] + ) + has_named_base_path = any( + isinstance(item, dict) and item.get("path") for item in base_paths + ) if not has_named_base_path and not attachments.get("base_path"): - collector.issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.") + collector.issue( + "info", + "attachments", + "attachments.base_path", + "missing_attachment_base_path", + "Attachment base path is not configured yet.", + ) -def _validate_partial_delivery(collector: _PartialValidationCollector, delivery: dict[str, Any]) -> None: - rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {} +def _validate_partial_delivery( + collector: _PartialValidationCollector, delivery: dict[str, Any] +) -> None: + rate_limit = ( + delivery.get("rate_limit") + if isinstance(delivery.get("rate_limit"), dict) + else {} + ) messages_per_minute = rate_limit.get("messages_per_minute") if messages_per_minute is None: return try: if int(messages_per_minute) < 1: - collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.") + collector.issue( + "error", + "send", + "delivery.rate_limit.messages_per_minute", + "invalid_rate_limit", + "Messages per minute must be at least 1.", + ) except (TypeError, ValueError): - collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.") + collector.issue( + "error", + "send", + "delivery.rate_limit.messages_per_minute", + "invalid_rate_limit", + "Messages per minute must be a number.", + ) diff --git a/src/govoplan_campaign/backend/route_support.py b/src/govoplan_campaign/backend/route_support.py index 1c45bcd..e5bafe0 100644 --- a/src/govoplan_campaign/backend/route_support.py +++ b/src/govoplan_campaign/backend/route_support.py @@ -453,9 +453,7 @@ def _update_campaign_version_detail_response( "base_revision": payload.base_revision, "result_revision": version.edit_revision, "reconciliation_kind": payload.reconciliation_kind, - "resolved_conflict_path_count": len( - payload.resolved_conflict_paths - ), + "resolved_conflict_path_count": len(payload.resolved_conflict_paths), "resolved_conflict_sections": sorted( { path.strip("/").split("/", 1)[0][:80] @@ -630,6 +628,7 @@ def _clear_current_version_mail_profile_for_owner_transfer( editor_state = copy.deepcopy(version.editor_state or {}) editor_state.pop("review_send", None) + editor_state.pop("approval_gate", None) version.editor_state = editor_state session.query(CampaignIssue).filter( diff --git a/src/govoplan_campaign/backend/routes/delivery.py b/src/govoplan_campaign/backend/routes/delivery.py index f1672c3..c791e8a 100644 --- a/src/govoplan_campaign/backend/routes/delivery.py +++ b/src/govoplan_campaign/backend/routes/delivery.py @@ -20,6 +20,12 @@ from govoplan_campaign.backend.schemas import ( SendCampaignNowRequest, SendCampaignNowResponse, ) +from govoplan_campaign.backend.approval_gate import ( + CampaignApprovalGateError, + campaign_approval_status, + request_campaign_approval, +) +from govoplan_campaign.backend.approval_schemas import CampaignApprovalRequestInput 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 ( @@ -92,14 +98,21 @@ def campaign_delivery_options( _get_campaign_for_principal(session, campaign_id, principal, write=True) _require_permission(principal, "campaigns:recipient:read") try: + options = synchronous_send_options( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + version = _get_version_for_tenant( + session, str(options["version_id"]), principal.tenant_id + ) return CampaignDeliveryOptionsResponse( - **synchronous_send_options( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - ), + **options, postbox_available=postbox_integration().available, + approval_gate=campaign_approval_status( + session, tenant_id=principal.tenant_id, version=version + ), ) except QueueingError as exc: raise HTTPException( @@ -107,6 +120,77 @@ def campaign_delivery_options( ) from exc +@router.post( + "/{campaign_id}/versions/{version_id}/approval-request", + response_model=dict[str, object], + status_code=status.HTTP_201_CREATED, +) +def create_campaign_approval_request( + campaign_id: str, + version_id: str, + payload: CampaignApprovalRequestInput, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")), +) -> dict[str, object]: + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + if campaign.current_version_id != version_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Approval can only be requested for the current Campaign version.", + ) + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if not version.build_summary: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Build Campaign messages before requesting approval.", + ) + try: + request = request_campaign_approval( + session, + principal, + campaign=campaign, + version=version, + title=payload.title, + description=payload.description, + steps=tuple(step.to_definition() for step in payload.steps), + idempotency_key=payload.idempotency_key, + template_id=payload.template_id, + template_revision=payload.template_revision, + unique_actors_across_steps=payload.unique_actors_across_steps, + expires_at=payload.expires_at, + policy_refs=tuple(payload.policy_refs), + ) + audit_from_principal( + session, + principal, + action="campaign.approval_requested", + object_type="campaign_version", + object_id=version.id, + details={ + "campaign_id": campaign.id, + "approval_request_id": request.id, + "approval_request_revision": request.revision, + "execution_snapshot_hash": version.execution_snapshot_hash, + }, + commit=True, + ) + return { + "request_id": request.id, + "request_revision": request.revision, + "request_state": request.state, + "approval_gate": campaign_approval_status( + session, tenant_id=principal.tenant_id, version=version + ), + } + except (CampaignApprovalGateError, ExecutionSnapshotError) as exc: + session.rollback() + 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, diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index c4b240a..774000b 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -3,7 +3,15 @@ from __future__ import annotations from datetime import datetime from typing import Annotated, Any, Literal -from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, ValidationInfo, field_validator, model_validator +from pydantic import ( + BaseModel, + BeforeValidator, + ConfigDict, + Field, + ValidationInfo, + field_validator, + model_validator, +) from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_campaign.backend.campaign.mail_profile_boundary import ( @@ -25,8 +33,6 @@ class CampaignCreateRequest(BaseModel): source_base_path: str | None = None - - class CampaignUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -64,7 +70,9 @@ class CampaignVersionUpdateRequest(BaseModel): @field_validator("editor_state") @classmethod - def validate_editor_state(cls, value: dict[str, Any] | None) -> dict[str, Any] | None: + def validate_editor_state( + cls, value: dict[str, Any] | None + ) -> dict[str, Any] | None: return validate_campaign_editor_state(value) if value is not None else None @@ -129,12 +137,16 @@ class CampaignVersionResponse(BaseModel): build_summary: dict[str, Any] | None = None execution_snapshot_hash: str | None = None execution_snapshot_at: datetime | None = None - delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = None + delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = ( + None + ) delivery_mode_selected_at: datetime | None = None @field_validator("editor_state", mode="before") @classmethod - def remove_unsupported_editor_state(cls, value: Any, info: ValidationInfo) -> dict[str, Any]: + def remove_unsupported_editor_state( + cls, value: Any, info: ValidationInfo + ) -> dict[str, Any]: return public_campaign_editor_state( value, include_diagnostics=bool((info.context or {}).get("include_diagnostics")), @@ -291,7 +303,9 @@ class RecipientImportColumnMappingPayload(BaseModel): column_index: int = Field(ge=0, alias="columnIndex") kind: RecipientImportColumnKind field_name: str | None = Field(default=None, max_length=255, alias="fieldName") - new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName") + new_field_name: str | None = Field( + default=None, max_length=255, alias="newFieldName" + ) class RecipientImportMappingProfilePayload(BaseModel): @@ -300,14 +314,22 @@ class RecipientImportMappingProfilePayload(BaseModel): name: str = Field(min_length=1, max_length=255) column_count: int = Field(ge=0, le=500, alias="columnCount") headers: list[str] = Field(default_factory=list, max_length=500) - normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders") - ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint") - unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint") + normalized_headers: list[str] = Field( + default_factory=list, max_length=500, alias="normalizedHeaders" + ) + ordered_header_fingerprint: str = Field( + min_length=1, max_length=64, alias="orderedHeaderFingerprint" + ) + unordered_header_fingerprint: str = Field( + min_length=1, max_length=64, alias="unorderedHeaderFingerprint" + ) delimiter: Literal[",", ";", "\t"] header_rows: int = Field(ge=0, le=10, alias="headerRows") quoted: bool = True value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators") - mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500) + mappings: list[RecipientImportColumnMappingPayload] = Field( + default_factory=list, max_length=500 + ) @model_validator(mode="after") def validate_column_shape(self) -> "RecipientImportMappingProfilePayload": @@ -583,6 +605,7 @@ class CampaignDeliveryOptionsResponse(BaseModel): version_id: str worker_queue_available: bool postbox_available: bool = False + approval_gate: dict[str, Any] = Field(default_factory=dict) synchronous_send: dict[str, Any] = Field(default_factory=dict) @@ -633,14 +656,17 @@ def _normalize_report_recipient(value: Any) -> str: if len(recipient) > 320: raise ValueError("report recipient addresses must be at most 320 characters") if any(ord(character) < 32 or ord(character) == 127 for character in recipient): - raise ValueError("report recipient addresses must not contain control characters") + raise ValueError( + "report recipient addresses must not contain control characters" + ) if recipient.count("@") != 1: raise ValueError("report recipients must be email addresses") local, domain = recipient.split("@", 1) - invalid_local = not local or local.startswith(".") or local.endswith(".") or ".." in local - invalid_address = ( - any(character.isspace() for character in recipient) - or any(character in ',;:<>[]()\\"' for character in recipient) + invalid_local = ( + not local or local.startswith(".") or local.endswith(".") or ".." in local + ) + invalid_address = any(character.isspace() for character in recipient) or any( + character in ',;:<>[]()\\"' for character in recipient ) if invalid_local or invalid_address or not _valid_report_email_domain(domain): raise ValueError("report recipients must be email addresses") diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index f53531f..d809575 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -15,7 +15,10 @@ from sqlalchemy import func from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session -from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider +from govoplan_core.core.notifications import ( + NotificationDispatchRequest, + notification_dispatch_provider, +) from govoplan_core.core.object_storage import ( StorageBackendError, configured_storage_backend, @@ -42,6 +45,10 @@ from govoplan_campaign.backend.db.models import ( SendAttempt, ) from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy +from govoplan_campaign.backend.approval_gate import ( + CampaignApprovalGateError, + assert_campaign_approval, +) from govoplan_campaign.backend.delivery_policy import ( CampaignDeliveryPolicyError, SynchronousSendPolicy, @@ -96,7 +103,9 @@ class SynchronousSendRejected(QueueingError): "delivery_mode": "synchronous", "rejection_reason": self.reason, "eligible_recipient_job_count": self.eligible_count, - "synchronous_send_policy": self.policy.as_dict() if self.policy is not None else None, + "synchronous_send_policy": self.policy.as_dict() + if self.policy is not None + else None, } @@ -282,7 +291,10 @@ DELIVERY_MODES = { DELIVERY_MODE_DATABASE_QUEUE, } AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value} -EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value} +EXPLICIT_RETRY_STATUSES = { + JobSendStatus.FAILED_TEMPORARY.value, + JobSendStatus.FAILED_PERMANENT.value, +} INITIAL_QUEUE_SKIPPED_SEND_STATUSES = DELIVERY_ACCEPTED_STATUSES | { JobSendStatus.SKIPPED.value, JobSendStatus.CLAIMED.value, @@ -301,8 +313,16 @@ CAMPAIGN_STATUS_NOTIFICATION_EVENTS = { CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1), CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1), CampaignStatus.SENT.value: ("campaign.sent", "Campaign sent", 1), - CampaignStatus.PARTIALLY_COMPLETED.value: ("campaign.partially_completed", "Campaign partially completed", 4), - CampaignStatus.OUTCOME_UNKNOWN.value: ("campaign.outcome_unknown", "Campaign requires review", 5), + CampaignStatus.PARTIALLY_COMPLETED.value: ( + "campaign.partially_completed", + "Campaign partially completed", + 4, + ), + CampaignStatus.OUTCOME_UNKNOWN.value: ( + "campaign.outcome_unknown", + "Campaign requires review", + 5, + ), CampaignStatus.FAILED.value: ("campaign.failed", "Campaign failed", 5), CampaignStatus.CANCELLED.value: ("campaign.cancelled", "Campaign cancelled", 2), } @@ -320,30 +340,58 @@ def _version_is_user_locked(version: CampaignVersion) -> bool: def _version_is_validated_and_locked(version: CampaignVersion) -> bool: - validation = version.validation_summary if isinstance(version.validation_summary, dict) else {} - return bool(version.locked_at and validation.get("ok") is True and not _version_is_user_locked(version)) + validation = ( + version.validation_summary + if isinstance(version.validation_summary, dict) + else {} + ) + return bool( + version.locked_at + and validation.get("ok") is True + and not _version_is_user_locked(version) + ) def _ensure_version_validated_and_locked(version: CampaignVersion) -> None: state = _version_user_lock_state(version) if state == "temporary": - raise QueueingError("This version has a temporary user lock. Unlock it before queueing, dry-run or sending.") + raise QueueingError( + "This version has a temporary user lock. Unlock it before queueing, dry-run or sending." + ) if state == "permanent": - raise QueueingError("This version is permanently user-locked. Create an editable copy instead.") + raise QueueingError( + "This version is permanently user-locked. Create an editable copy instead." + ) if not _version_is_validated_and_locked(version): - raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.") + raise QueueingError( + "Campaign version must be validated and locked before building, queueing, dry-run or sending." + ) def _reviewed_needs_review_keys(version: CampaignVersion) -> set[str]: - 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 {} + 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 {} + ) if not build_token or str(review_state.get("build_token") or "") != build_token: return set() if review_state.get("inspection_complete") is not True: return set() - return {str(value) for value in (review_state.get("reviewed_message_keys") or []) if str(value).strip()} + return { + str(value) + for value in (review_state.get("reviewed_message_keys") or []) + if str(value).strip() + } def _job_review_key(job: CampaignJob) -> str: @@ -361,8 +409,14 @@ def _set_version_delivery_mode(version: CampaignVersion, mode: str) -> None: version.delivery_mode_selected_at = _utcnow() -def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign: - campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() +def _get_campaign_for_tenant( + session: Session, *, campaign_id: str, tenant_id: str +) -> Campaign: + campaign = ( + session.query(Campaign) + .filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id) + .one_or_none() + ) if not campaign: raise QueueingError(f"Campaign not found or not accessible: {campaign_id}") return campaign @@ -378,11 +432,15 @@ def _get_version_for_campaign( raise QueueingError("Campaign has no selected version") version = session.get(CampaignVersion, wanted) if not version or version.campaign_id != campaign.id: - raise QueueingError(f"Campaign version not found or not part of campaign: {wanted}") + raise QueueingError( + f"Campaign version not found or not part of campaign: {wanted}" + ) return version -def _get_current_version(session: Session, campaign: Campaign, version_id: str | None = None) -> CampaignVersion: +def _get_current_version( + session: Session, campaign: Campaign, version_id: str | None = None +) -> CampaignVersion: version = _get_version_for_campaign(session, campaign, version_id) if campaign.current_version_id != version.id: raise QueueingError( @@ -400,7 +458,11 @@ def _should_enqueue_celery(enqueue_celery: bool) -> bool: def _asynchronous_delivery_mode(enqueue_celery: bool) -> str: - return DELIVERY_MODE_WORKER_QUEUE if _should_enqueue_celery(enqueue_celery) else DELIVERY_MODE_DATABASE_QUEUE + return ( + DELIVERY_MODE_WORKER_QUEUE + if _should_enqueue_celery(enqueue_celery) + else DELIVERY_MODE_DATABASE_QUEUE + ) def _campaign_notification_body(campaign: Campaign, status: str) -> str: @@ -451,7 +513,10 @@ def _emit_campaign_status_notification( "status": status, "previous_status": previous_status, }, - metadata={"campaign_id": campaign.id, "version_id": version_id or campaign.current_version_id}, + metadata={ + "campaign_id": campaign.id, + "version_id": version_id or campaign.current_version_id, + }, ), enqueue_delivery=False, ) @@ -468,7 +533,9 @@ def _celery_enqueue_send_job(job_id: str) -> None: def _celery_enqueue_append_sent_job(job_id: str) -> None: from govoplan_core.celery_app import celery - celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent") + celery.send_task( + "govoplan.campaigns.append_sent", args=[job_id], queue="append_sent" + ) def _ensure_campaign_execution_snapshot( @@ -481,6 +548,22 @@ def _ensure_campaign_execution_snapshot( raise QueueingError(str(exc)) from exc +def _ensure_campaign_approval_gate( + session: Session, + *, + tenant_id: str, + version: CampaignVersion, +) -> None: + try: + assert_campaign_approval( + session, + tenant_id=tenant_id, + version=version, + ) + except CampaignApprovalGateError as exc: + raise QueueingError(str(exc)) from exc + + def _queue_validation_statuses(*, include_warnings: bool) -> set[str]: allowed_validation = {JobValidationStatus.READY.value} if include_warnings: @@ -628,10 +711,16 @@ def synchronous_send_options( ) -> dict[str, Any]: """Describe the actor-independent effective delivery-mode constraints.""" - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) version = _get_version_for_campaign(session, campaign, version_id=version_id) - jobs = _campaign_jobs_for_version(session, tenant_id=tenant_id, version_id=version.id) - candidates = synchronous_send_candidate_jobs(version, jobs, include_warnings=include_warnings) + jobs = _campaign_jobs_for_version( + session, tenant_id=tenant_id, version_id=version.id + ) + candidates = synchronous_send_candidate_jobs( + version, jobs, include_warnings=include_warnings + ) try: policy = effective_synchronous_send_policy(session, tenant_id=tenant_id) except CampaignDeliveryPolicyError as exc: @@ -739,16 +828,22 @@ def queue_campaign_jobs( ) -> QueueCampaignResult: """Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks.""" - selected_delivery_mode = delivery_mode or _asynchronous_delivery_mode(enqueue_celery) + selected_delivery_mode = delivery_mode or _asynchronous_delivery_mode( + enqueue_celery + ) if selected_delivery_mode not in DELIVERY_MODES: - raise QueueingError(f"Unsupported Campaign delivery mode: {selected_delivery_mode}") - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + raise QueueingError( + f"Unsupported Campaign delivery mode: {selected_delivery_mode}" + ) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) version = _get_current_version(session, campaign, version_id=version_id) _ensure_version_validated_and_locked(version) _ensure_campaign_execution_snapshot(session, version) - allowed_validation = _queue_validation_statuses( - include_warnings=include_warnings - ) + if not dry_run: + _ensure_campaign_approval_gate(session, tenant_id=tenant_id, version=version) + allowed_validation = _queue_validation_statuses(include_warnings=include_warnings) jobs = _campaign_jobs_for_queue( session, tenant_id=tenant_id, @@ -807,12 +902,16 @@ def send_campaign_now( and transport revision is preflighted before the first SMTP effect starts. """ - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) version = _get_current_version(session, campaign, version_id=version_id) _ensure_version_validated_and_locked(version) _ensure_campaign_execution_snapshot(session, version) try: - synchronous_policy = effective_synchronous_send_policy(session, tenant_id=tenant_id) + synchronous_policy = effective_synchronous_send_policy( + session, tenant_id=tenant_id + ) except CampaignDeliveryPolicyError as exc: raise SynchronousSendRejected( f"Synchronous Campaign delivery is disabled by an invalid policy configuration: {exc}", @@ -924,7 +1023,9 @@ def send_campaign_now( sent_count=sent_count, failed_count=failed_count, outcome_unknown_count=outcome_unknown_count, - skipped_count=queue_result.skipped_count + queue_result.blocked_count + skipped_after_queue, + skipped_count=queue_result.skipped_count + + queue_result.blocked_count + + skipped_after_queue, preflight_count=len(delivery_contexts), synchronous_send_policy=synchronous_policy.as_dict(), dry_run=False, @@ -986,7 +1087,13 @@ def _preflight_synchronous_send_batch( raise SendJobError( "The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery." ) - except (ExecutionSnapshotError, MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc: + except ( + ExecutionSnapshotError, + MailProfileError, + SmtpConfigurationError, + SendJobError, + OSError, + ) as exc: raise SynchronousSendRejected( ( "Synchronous preflight stopped before contacting SMTP because one or more frozen message " @@ -1000,17 +1107,23 @@ def _preflight_synchronous_send_batch( return contexts -def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int: +def enqueue_existing_queued_jobs( + session: Session, *, tenant_id: str, campaign_id: str +) -> int: if not _celery_enabled(): return 0 - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) jobs = ( session.query(CampaignJob) .filter( CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id, CampaignJob.queue_status == JobQueueStatus.QUEUED.value, - CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]), + CampaignJob.send_status.in_( + [JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value] + ), ) .order_by(CampaignJob.entry_index.asc()) .all() @@ -1020,8 +1133,12 @@ def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_i return len(jobs) -def pause_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]: - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) +def pause_campaign_jobs( + session: Session, *, tenant_id: str, campaign_id: str +) -> dict[str, Any]: + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) changed = ( session.query(CampaignJob) .filter( @@ -1029,7 +1146,10 @@ def pause_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) - CampaignJob.campaign_id == campaign.id, CampaignJob.queue_status == JobQueueStatus.QUEUED.value, ) - .update({CampaignJob.queue_status: JobQueueStatus.PAUSED.value}, synchronize_session=False) + .update( + {CampaignJob.queue_status: JobQueueStatus.PAUSED.value}, + synchronize_session=False, + ) ) if changed: campaign.status = CampaignStatus.READY_TO_QUEUE.value @@ -1038,8 +1158,12 @@ def pause_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) - return {"campaign_id": campaign.id, "paused_count": int(changed)} -def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True) -> dict[str, Any]: - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) +def resume_campaign_jobs( + session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True +) -> dict[str, Any]: + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) jobs = ( session.query(CampaignJob) .filter( @@ -1078,14 +1202,24 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str, for job in jobs: _celery_enqueue_send_job(job.id) enqueued_count += 1 - return {"campaign_id": campaign.id, "resumed_count": len(jobs), "enqueued_count": enqueued_count} + return { + "campaign_id": campaign.id, + "resumed_count": len(jobs), + "enqueued_count": enqueued_count, + } -def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]: - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) +def cancel_campaign_jobs( + session: Session, *, tenant_id: str, campaign_id: str +) -> dict[str, Any]: + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) jobs = ( session.query(CampaignJob) - .filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id) + .filter( + CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id + ) .all() ) cancelled_count = 0 @@ -1159,7 +1293,9 @@ def queue_failed_jobs_for_retry( excluded until an operator reconciles them. """ - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) version = _get_version_for_campaign(session, campaign, version_id=version_id) _ensure_version_validated_and_locked(version) snapshot = ensure_execution_snapshot(session, version) @@ -1180,7 +1316,12 @@ def queue_failed_jobs_for_retry( job_ids=job_ids, ): if job.send_status not in allowed: - skipped.append({"job_id": job.id, "reason": f"status {job.send_status} is not an explicit retry candidate"}) + skipped.append( + { + "job_id": job.id, + "reason": f"status {job.send_status} is not an explicit retry candidate", + } + ) continue delivery_rounds = max( job.attempt_count, @@ -1195,7 +1336,9 @@ def queue_failed_jobs_for_retry( not force_max_attempts and delivery_rounds >= snapshot.delivery.retry.max_attempts ): - skipped.append({"job_id": job.id, "reason": "configured maximum attempts reached"}) + skipped.append( + {"job_id": job.id, "reason": "configured maximum attempts reached"} + ) continue selected.append(job) if not dry_run: @@ -1248,10 +1391,14 @@ def queue_unattempted_jobs( ) -> dict[str, Any]: """Explicitly queue built jobs that have never started an SMTP attempt.""" - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) version = _get_version_for_campaign(session, campaign, version_id=version_id) _ensure_version_validated_and_locked(version) ensure_execution_snapshot(session, version) + if not dry_run: + _ensure_campaign_approval_gate(session, tenant_id=tenant_id, version=version) selected: list[CampaignJob] = [] skipped: list[dict[str, str]] = [] for job in _eligible_jobs_for_explicit_action( @@ -1264,12 +1411,18 @@ def queue_unattempted_jobs( eligible = ( job.attempt_count == 0 and job.postbox_attempt_count == 0 - and job.send_status in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value} + and job.send_status + in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value} and job.build_status == JobBuildStatus.BUILT.value and job.validation_status in QUEUEABLE_VALIDATION_STATUSES ) if not eligible: - skipped.append({"job_id": job.id, "reason": "job is not an unattempted queueable message"}) + skipped.append( + { + "job_id": job.id, + "reason": "job is not an unattempted queueable message", + } + ) continue selected.append(job) if not dry_run: @@ -1327,7 +1480,9 @@ def send_single_campaign_job( ) -> dict[str, Any]: """Perform one explicit, idempotent action for one immutable built message.""" - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) job = session.get(CampaignJob, job_id) if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id: raise QueueingError("Campaign job not found or not accessible") @@ -1354,6 +1509,8 @@ def send_single_campaign_job( raise QueueingError( "An initial official send is only available for the current campaign version." ) + if normalized_kind != "test": + _ensure_campaign_approval_gate(session, tenant_id=tenant_id, version=version) recipients = _recipients_from_job(job) recipient_manifest_sha256 = hashlib.sha256( json.dumps( @@ -1528,9 +1685,7 @@ def _bounded_single_action_context( continue if isinstance(raw_value, (str, int, float, bool)) or raw_value is None: result[key] = ( - str(raw_value)[:500] - if isinstance(raw_value, str) - else raw_value + str(raw_value)[:500] if isinstance(raw_value, str) else raw_value ) redacted = redact_secret_values(result) return redacted if isinstance(redacted, dict) else {} @@ -1758,15 +1913,12 @@ def _finish_single_message_action( action.refused_count = len(refusals) action.refusal_summary = dict( Counter( - str(item.get("classification") or "unknown") - for item in refusals.values() + str(item.get("classification") or "unknown") for item in refusals.values() ) ) action.error_type = str(error_type)[:120] if error_type else None action.error_message = ( - " ".join(str(error_message).split())[:500] - if error_message - else None + " ".join(str(error_message).split())[:500] if error_message else None ) action.final_send_status = final_send_status action.completed_at = now @@ -1956,8 +2108,7 @@ def _send_single_message_direct( accepted_count = result.accepted_count if accepted_count <= 0: classifications = { - str(item.get("classification") or "unknown") - for item in refusals.values() + str(item.get("classification") or "unknown") for item in refusals.values() } if classifications and classifications <= {"temporary"}: status = "failed_temporary" @@ -2010,14 +2161,10 @@ def _send_single_message_direct( accepted_count=accepted_count, refused_recipients=refusals, error_type=( - None - if accepted_count > 0 - else f"Smtp{status.title().replace('_', '')}" + None if accepted_count > 0 else f"Smtp{status.title().replace('_', '')}" ), error_message=( - None - if accepted_count > 0 - else "SMTP did not accept an envelope recipient." + None if accepted_count > 0 else "SMTP did not accept an envelope recipient." ), final_send_status=job.send_status, ) @@ -2032,17 +2179,31 @@ def _prepare_single_job_for_send( include_warnings: bool, dry_run: bool, ) -> str: - if job.queue_status == JobQueueStatus.QUEUED.value and job.send_status == JobSendStatus.QUEUED.value: + if ( + job.queue_status == JobQueueStatus.QUEUED.value + and job.send_status == JobSendStatus.QUEUED.value + ): return "already_queued" if job.send_status in DELIVERY_ACCEPTED_STATUSES: raise QueueingError( "This message has already been accepted by a delivery channel and " "cannot be sent again from preview." ) - if job.send_status in {JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value, JobSendStatus.OUTCOME_UNKNOWN.value}: - raise QueueingError(f"This message is in delivery state {job.send_status}; reconcile or wait before sending it again.") - if job.send_status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}: - raise QueueingError("This message has failed before. Use the explicit retry action so the retry is visible in the delivery protocol.") + if job.send_status in { + JobSendStatus.CLAIMED.value, + JobSendStatus.SENDING.value, + JobSendStatus.OUTCOME_UNKNOWN.value, + }: + raise QueueingError( + f"This message is in delivery state {job.send_status}; reconcile or wait before sending it again." + ) + if job.send_status in { + JobSendStatus.FAILED_TEMPORARY.value, + JobSendStatus.FAILED_PERMANENT.value, + }: + raise QueueingError( + "This message has failed before. Use the explicit retry action so the retry is visible in the delivery protocol." + ) if job.attempt_count > 0 or job.postbox_attempt_count > 0: raise QueueingError( "This message already has delivery attempts. Use retry or " @@ -2050,12 +2211,23 @@ def _prepare_single_job_for_send( ) if job.build_status != JobBuildStatus.BUILT.value: raise QueueingError("This message has not been built yet.") - if not _single_job_validation_allowed(version, job, include_warnings=include_warnings): - raise QueueingError(f"This message cannot be sent while validation status is {job.validation_status}.") + if not _single_job_validation_allowed( + version, job, include_warnings=include_warnings + ): + raise QueueingError( + f"This message cannot be sent while validation status is {job.validation_status}." + ) if not job.eml_local_path and not job.eml_storage_key: - raise QueueingError("This message has no generated EML evidence. Rebuild the campaign before sending.") - if job.queue_status not in {JobQueueStatus.DRAFT.value, JobQueueStatus.CANCELLED.value}: - raise QueueingError(f"This message cannot be sent from queue state {job.queue_status}.") + raise QueueingError( + "This message has no generated EML evidence. Rebuild the campaign before sending." + ) + if job.queue_status not in { + JobQueueStatus.DRAFT.value, + JobQueueStatus.CANCELLED.value, + }: + raise QueueingError( + f"This message cannot be sent from queue state {job.queue_status}." + ) if dry_run: return "would_queue" @@ -2107,7 +2279,9 @@ def reconcile_job_outcome( only the evidence-backed ``imap_not_appended`` decision makes it retryable. """ - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) job = session.get(CampaignJob, job_id) if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id: raise QueueingError("Campaign job not found or not accessible") @@ -2134,8 +2308,12 @@ def reconcile_job_outcome( if not evidence_note: raise QueueingError("SMTP reconciliation requires an evidence note") if job.send_status != JobSendStatus.OUTCOME_UNKNOWN.value: - raise QueueingError(f"Job status {job.send_status} does not require reconciliation") - version = _get_version_for_campaign(session, campaign, version_id=job.campaign_version_id) + raise QueueingError( + f"Job status {job.send_status} does not require reconciliation" + ) + version = _get_version_for_campaign( + session, campaign, version_id=job.campaign_version_id + ) snapshot = ensure_execution_snapshot(session, version) now = _utcnow() attempt = _unfinished_attempt(session, job) @@ -2146,7 +2324,11 @@ def reconcile_job_outcome( job.outcome_unknown_at = None job.claim_token = None job.last_error = evidence_note - job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value + job.imap_status = ( + JobImapStatus.PENDING.value + if snapshot.delivery.imap_append_sent.enabled + else JobImapStatus.NOT_REQUESTED.value + ) files_integration().mark_job_attachment_uses_sent(session, job) attempt_status = "reconciled_smtp_accepted" elif decision == "not_sent": @@ -2238,9 +2420,7 @@ def _reconcile_postbox_outcome( ), ) if attempt_id: - unknown_query = unknown_query.filter( - PostboxDeliveryAttempt.id == attempt_id - ) + unknown_query = unknown_query.filter(PostboxDeliveryAttempt.id == attempt_id) unknown_attempts = unknown_query.order_by( PostboxDeliveryAttempt.target_index.asc(), PostboxDeliveryAttempt.attempt_number.desc(), @@ -2325,9 +2505,13 @@ def _reconcile_imap_append_outcome( job.id, send_status=job.send_status, ): - raise QueueingError("Only an SMTP-accepted job can reconcile a Sent-folder append") + raise QueueingError( + "Only an SMTP-accepted job can reconcile a Sent-folder append" + ) if job.imap_status != JobImapStatus.OUTCOME_UNKNOWN.value: - raise QueueingError(f"IMAP status {job.imap_status} does not require reconciliation") + raise QueueingError( + f"IMAP status {job.imap_status} does not require reconciliation" + ) attempt = ( session.query(ImapAppendAttempt) @@ -2345,7 +2529,9 @@ def _reconcile_imap_append_outcome( job.imap_status = JobImapStatus.FAILED.value attempt_status = "reconciled_imap_not_appended" else: # Kept defensive for non-HTTP callers. - raise QueueingError("IMAP decision must be 'imap_appended' or 'imap_not_appended'") + raise QueueingError( + "IMAP decision must be 'imap_appended' or 'imap_not_appended'" + ) job.imap_claimed_at = None job.imap_claim_token = None @@ -2421,7 +2607,11 @@ def _load_eml_for_job(job: CampaignJob): def _addresses_from_job(job: CampaignJob, field: str) -> list[str]: data = job.resolved_recipients or {} values = data.get(field) or [] - return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")] + return [ + item.get("email") + for item in values + if isinstance(item, dict) and item.get("email") + ] def _sender_from_job(job: CampaignJob) -> str: @@ -2486,7 +2676,9 @@ def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None: return claim_token if changed == 1 else None -def _record_attempt_start(session: Session, job: CampaignJob, claim_token: str) -> SendAttempt: +def _record_attempt_start( + session: Session, job: CampaignJob, claim_token: str +) -> SendAttempt: now = _utcnow() attempt = SendAttempt( job_id=job.id, @@ -2516,7 +2708,9 @@ def _unfinished_attempt(session: Session, job: CampaignJob) -> SendAttempt | Non ) -def mark_job_outcome_unknown(session: Session, job: CampaignJob, *, reason: str) -> SendJobResult: +def mark_job_outcome_unknown( + session: Session, job: CampaignJob, *, reason: str +) -> SendJobResult: """Freeze an uncertain SMTP result and prohibit automatic redelivery.""" now = _utcnow() @@ -2542,14 +2736,18 @@ def mark_job_outcome_unknown(session: Session, job: CampaignJob, *, reason: str) ) -def _update_campaign_after_job(session: Session, campaign_id: str, version_id: str | None = None) -> None: +def _update_campaign_after_job( + session: Session, campaign_id: str, version_id: str | None = None +) -> None: session.flush() campaign = session.get(Campaign, campaign_id) if not campaign: return previous_status = campaign.status version = session.get(CampaignVersion, version_id) if version_id else None - counts = _campaign_delivery_counts(session, campaign_id=campaign_id, version_id=version_id) + counts = _campaign_delivery_counts( + session, campaign_id=campaign_id, version_id=version_id + ) _apply_campaign_delivery_status(campaign, version, counts) if version and (counts.accepted or counts.unknown): if version.locked_at is None: @@ -2566,20 +2764,24 @@ def _update_campaign_after_job(session: Session, campaign_id: str, version_id: s ) -def _campaign_delivery_counts(session: Session, *, campaign_id: str, version_id: str | None) -> _CampaignDeliveryCounts: - base_filters = _campaign_delivery_base_filters(campaign_id=campaign_id, version_id=version_id) +def _campaign_delivery_counts( + session: Session, *, campaign_id: str, version_id: str | None +) -> _CampaignDeliveryCounts: + base_filters = _campaign_delivery_base_filters( + campaign_id=campaign_id, version_id=version_id + ) counts = { - status: session.query(CampaignJob).filter(*base_filters, CampaignJob.send_status == status).count() + status: session.query(CampaignJob) + .filter(*base_filters, CampaignJob.send_status == status) + .count() for status in {item.value for item in JobSendStatus} } return _CampaignDeliveryCounts( - accepted=sum( - counts.get(status, 0) - for status in DELIVERY_ACCEPTED_STATUSES - ), + accepted=sum(counts.get(status, 0) for status in DELIVERY_ACCEPTED_STATUSES), partial=counts.get(JobSendStatus.PARTIALLY_ACCEPTED.value, 0), unknown=counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0), - failed=counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0), + failed=counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0), active=( counts.get(JobSendStatus.QUEUED.value, 0) + counts.get(JobSendStatus.CLAIMED.value, 0) @@ -2590,14 +2792,18 @@ def _campaign_delivery_counts(session: Session, *, campaign_id: str, version_id: ) -def _campaign_delivery_base_filters(*, campaign_id: str, version_id: str | None) -> list[object]: +def _campaign_delivery_base_filters( + *, campaign_id: str, version_id: str | None +) -> list[object]: base_filters: list[object] = [CampaignJob.campaign_id == campaign_id] if version_id: base_filters.append(CampaignJob.campaign_version_id == version_id) return base_filters -def _queueable_not_started_job_count(session: Session, base_filters: list[object]) -> int: +def _queueable_not_started_job_count( + session: Session, base_filters: list[object] +) -> int: return ( session.query(CampaignJob) .filter( @@ -2624,7 +2830,9 @@ def _apply_campaign_delivery_status( version.workflow_state = workflow_state -def _campaign_delivery_status_pair(counts: _CampaignDeliveryCounts) -> tuple[str, str] | None: +def _campaign_delivery_status_pair( + counts: _CampaignDeliveryCounts, +) -> tuple[str, str] | None: if counts.active: return CampaignStatus.SENDING.value, CampaignVersionWorkflowState.SENDING.value if counts.accepted and ( @@ -2634,15 +2842,24 @@ def _campaign_delivery_status_pair(counts: _CampaignDeliveryCounts) -> tuple[str or counts.cancelled or counts.not_started ): - return CampaignStatus.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value + return ( + CampaignStatus.PARTIALLY_COMPLETED.value, + CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value, + ) if counts.unknown: - return CampaignStatus.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value + return ( + CampaignStatus.OUTCOME_UNKNOWN.value, + CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value, + ) if counts.failed: return CampaignStatus.FAILED.value, CampaignVersionWorkflowState.FAILED.value if counts.accepted: return CampaignStatus.SENT.value, CampaignVersionWorkflowState.COMPLETED.value if counts.cancelled: - return CampaignStatus.CANCELLED.value, CampaignVersionWorkflowState.CANCELLED.value + return ( + CampaignStatus.CANCELLED.value, + CampaignVersionWorkflowState.CANCELLED.value, + ) return None @@ -2701,12 +2918,30 @@ def _preflight_send_campaign_job( *, dry_run: bool, ) -> SendJobResult | None: - if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value: - return SendJobResult(job_id=job.id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run) + if ( + job.queue_status == JobQueueStatus.CANCELLED.value + or job.send_status == JobSendStatus.CANCELLED.value + ): + return SendJobResult( + job_id=job.id, + status="cancelled", + attempt_number=job.attempt_count, + dry_run=dry_run, + ) if job.queue_status == JobQueueStatus.PAUSED.value: - return SendJobResult(job_id=job.id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run) + return SendJobResult( + job_id=job.id, + status="paused", + attempt_number=job.attempt_count, + dry_run=dry_run, + ) if job.send_status in FULLY_ACCEPTED_STATUSES: - return SendJobResult(job_id=job.id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run) + return SendJobResult( + job_id=job.id, + status="already_accepted", + attempt_number=job.attempt_count, + dry_run=dry_run, + ) if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value: return SendJobResult( job_id=job.id, @@ -2729,12 +2964,19 @@ def _preflight_send_campaign_job( dry_run=dry_run, message="Another worker has claimed this job, or a stale pre-SMTP claim requires operator review.", ) - if job.queue_status != JobQueueStatus.QUEUED.value or job.send_status != JobSendStatus.QUEUED.value: - raise SendJobError(f"Job is not explicitly queued for delivery: queue={job.queue_status}, send={job.send_status}") + if ( + job.queue_status != JobQueueStatus.QUEUED.value + or job.send_status != JobSendStatus.QUEUED.value + ): + raise SendJobError( + f"Job is not explicitly queued for delivery: queue={job.queue_status}, send={job.send_status}" + ) return None -def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDeliveryContext: +def _send_job_delivery_context( + session: Session, job: CampaignJob +) -> _SendJobDeliveryContext: version = session.get(CampaignVersion, job.campaign_version_id) if not version: raise SendJobError("Campaign version not found") @@ -2753,9 +2995,7 @@ def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDe envelope_from = _sender_from_job(job) envelope_recipients = _recipients_from_job(job) if not envelope_recipients: - raise SmtpConfigurationError( - "No envelope recipients could be determined" - ) + raise SmtpConfigurationError("No envelope recipients could be determined") return _SendJobDeliveryContext( version=version, snapshot=snapshot, @@ -2858,14 +3098,19 @@ def _send_claimed_mail_only_job( envelope_from=context.envelope_from, envelope_recipients=context.envelope_recipients, from_header=_from_header_from_job(job), - expected_smtp_transport_revision=context.snapshot.smtp_transport_revision or "", + expected_smtp_transport_revision=context.snapshot.smtp_transport_revision + or "", smtp_server_id=context.snapshot.smtp_server_id, smtp_credential_id=context.snapshot.smtp_credential_id, ) if result.accepted_count <= 0: - raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False) + raise SmtpSendError( + "SMTP did not accept any envelope recipients", temporary=False + ) except SmtpSendError as exc: - outcome_unknown = _record_smtp_send_error(session, job=job, attempt=attempt, exc=exc) + outcome_unknown = _record_smtp_send_error( + session, job=job, attempt=attempt, exc=exc + ) if outcome_unknown is not None: return outcome_unknown raise @@ -2884,7 +3129,9 @@ def _send_claimed_mail_only_job( session.rollback() current = session.get(CampaignJob, job.id) if current is None: - raise SendJobError("SMTP accepted the message, but its durable Campaign job record is unavailable.") from None + raise SendJobError( + "SMTP accepted the message, but its durable Campaign job record is unavailable." + ) from None return mark_job_outcome_unknown( session, current, @@ -2893,7 +3140,11 @@ def _send_claimed_mail_only_job( "Automatic retry is stopped until an operator reconciles the job." ), ) - if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value: + if ( + enqueue_imap_task + and _celery_enabled() + and job.imap_status == JobImapStatus.PENDING.value + ): try: _celery_enqueue_append_sent_job(job.id) except Exception: @@ -3021,8 +3272,14 @@ def _final_multichannel_status( if outcome.outcome_unknown: return JobSendStatus.OUTCOME_UNKNOWN.value if not outcome.accepted_count: - return JobSendStatus.FAILED_TEMPORARY.value if outcome.temporary_rejection else JobSendStatus.FAILED_PERMANENT.value - classifier = _ACCEPTED_DELIVERY_CLASSIFIERS.get(channel_policy, _classify_fallback_delivery) + return ( + JobSendStatus.FAILED_TEMPORARY.value + if outcome.temporary_rejection + else JobSendStatus.FAILED_PERMANENT.value + ) + classifier = _ACCEPTED_DELIVERY_CLASSIFIERS.get( + channel_policy, _classify_fallback_delivery + ) return classifier(outcome) @@ -3035,24 +3292,45 @@ def _delivery_outcome_summary( mail_accepted=bool(mail and mail.accepted), postbox_accepted=int(postbox.accepted_count if postbox else 0), postbox_rejected=int(postbox.rejected_count if postbox else 0), - outcome_unknown=bool((mail and mail.outcome_unknown) or (postbox and postbox.outcome_unknown)), - temporary_rejection=bool((mail and mail.rejected_temporary) or (postbox and postbox.rejected_temporary)), + outcome_unknown=bool( + (mail and mail.outcome_unknown) or (postbox and postbox.outcome_unknown) + ), + temporary_rejection=bool( + (mail and mail.rejected_temporary) + or (postbox and postbox.rejected_temporary) + ), ) def _classify_postbox_delivery(outcome: _DeliveryOutcomeSummary) -> str: - return JobSendStatus.PARTIALLY_ACCEPTED.value if outcome.postbox_rejected else JobSendStatus.POSTBOX_ACCEPTED.value + return ( + JobSendStatus.PARTIALLY_ACCEPTED.value + if outcome.postbox_rejected + else JobSendStatus.POSTBOX_ACCEPTED.value + ) def _classify_dual_delivery(outcome: _DeliveryOutcomeSummary) -> str: - fully_delivered = outcome.mail_accepted and outcome.postbox_accepted > 0 and not outcome.postbox_rejected - return JobSendStatus.DELIVERED.value if fully_delivered else JobSendStatus.PARTIALLY_ACCEPTED.value + fully_delivered = ( + outcome.mail_accepted + and outcome.postbox_accepted > 0 + and not outcome.postbox_rejected + ) + return ( + JobSendStatus.DELIVERED.value + if fully_delivered + else JobSendStatus.PARTIALLY_ACCEPTED.value + ) def _classify_fallback_delivery(outcome: _DeliveryOutcomeSummary) -> str: if outcome.postbox_rejected: return JobSendStatus.PARTIALLY_ACCEPTED.value - return JobSendStatus.SMTP_ACCEPTED.value if outcome.mail_accepted else JobSendStatus.POSTBOX_ACCEPTED.value + return ( + JobSendStatus.SMTP_ACCEPTED.value + if outcome.mail_accepted + else JobSendStatus.POSTBOX_ACCEPTED.value + ) _ACCEPTED_DELIVERY_CLASSIFIERS = { @@ -3069,11 +3347,7 @@ def _multichannel_messages( if mail and mail.message: values.append(f"Mail: {mail.message}") if postbox: - values.extend( - f"Postbox: {message}" - for message in postbox.messages - if message - ) + values.extend(f"Postbox: {message}" for message in postbox.messages if message) return values @@ -3088,9 +3362,7 @@ def _finalize_multichannel_job( ) -> SendJobResult: job = session.get(CampaignJob, job_id) if job is None: - raise SendJobError( - "Campaign job disappeared while finalizing delivery." - ) + raise SendJobError("Campaign job disappeared while finalizing delivery.") status = _final_multichannel_status( channel_policy=channel_policy, mail=mail, @@ -3104,7 +3376,10 @@ def _finalize_multichannel_job( job.claim_token = None job.last_error = "\n".join(messages) or None job.outcome_unknown_at = _utcnow() if unknown else None - if accepted or (unknown and bool((mail and mail.accepted) or (postbox and postbox.accepted_count))): + if accepted or ( + unknown + and bool((mail and mail.accepted) or (postbox and postbox.accepted_count)) + ): job.sent_at = job.sent_at or _utcnow() files_integration().mark_job_attachment_uses_sent(session, job) if not (mail and mail.accepted): @@ -3195,9 +3470,7 @@ def _send_claimed_multichannel_job( if should_deliver_mail: current = session.get(CampaignJob, job.id) if current is None: - raise SendJobError( - "Campaign job disappeared before Mail delivery." - ) + raise SendJobError("Campaign job disappeared before Mail delivery.") mail_outcome = _deliver_mail_channel( session, job=current, @@ -3226,14 +3499,22 @@ def _record_smtp_send_success( ) -> SendJobResult: refused_warning = _refused_recipient_warning(result) attempt.finished_at = _utcnow() - attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value + attempt.status = ( + "smtp_accepted_with_refusals" + if refused_warning + else JobSendStatus.SMTP_ACCEPTED.value + ) attempt.smtp_response = json.dumps(asdict(result), default=str) job.queue_status = JobQueueStatus.DRAFT.value job.send_status = JobSendStatus.SMTP_ACCEPTED.value job.sent_at = _utcnow() job.claim_token = None job.outcome_unknown_at = None - job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value + job.imap_status = ( + JobImapStatus.PENDING.value + if snapshot.delivery.imap_append_sent.enabled + else JobImapStatus.NOT_REQUESTED.value + ) job.last_error = refused_warning files_integration().mark_job_attachment_uses_sent(session, job) session.add(attempt) @@ -3281,7 +3562,11 @@ def _record_smtp_send_error( retryable = bool(getattr(exc, "temporary", False)) job.last_error = str(exc) job.queue_status = JobQueueStatus.DRAFT.value - job.send_status = JobSendStatus.FAILED_TEMPORARY.value if retryable else JobSendStatus.FAILED_PERMANENT.value + job.send_status = ( + JobSendStatus.FAILED_TEMPORARY.value + if retryable + else JobSendStatus.FAILED_PERMANENT.value + ) job.claim_token = None attempt.status = job.send_status session.add(attempt) @@ -3313,7 +3598,11 @@ def _record_permanent_send_error( def _imap_attempt_count(session: Session, job_id: str) -> int: - return session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job_id).count() + return ( + session.query(ImapAppendAttempt) + .filter(ImapAppendAttempt.job_id == job_id) + .count() + ) def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None: @@ -3334,7 +3623,9 @@ def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None session.query(CampaignJob) .filter( CampaignJob.id == job.id, - CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]), + CampaignJob.imap_status.in_( + [JobImapStatus.PENDING.value, JobImapStatus.FAILED.value] + ), CampaignJob.imap_claim_token.is_(None), ) .update( @@ -3361,8 +3652,14 @@ def _record_imap_attempt_start( job.imap_status != JobImapStatus.APPENDING.value or job.imap_claim_token != claim_token ): - raise SendJobError("The IMAP append claim was lost before the provider effect started") - existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + raise SendJobError( + "The IMAP append claim was lost before the provider effect started" + ) + existing_count = ( + session.query(ImapAppendAttempt) + .filter(ImapAppendAttempt.job_id == job.id) + .count() + ) attempt = ImapAppendAttempt( job_id=job.id, attempt_number=existing_count + 1, @@ -3392,11 +3689,23 @@ def _imap_blocked_result( return None attempts = _imap_attempt_count(session, job.id) if job.imap_status == JobImapStatus.NOT_REQUESTED.value: - return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=attempts, dry_run=dry_run) + return AppendSentResult( + job_id=job.id, + status="not_requested", + attempt_number=attempts, + dry_run=dry_run, + ) if job.imap_status == JobImapStatus.APPENDED.value: - return AppendSentResult(job_id=job.id, status="already_appended", attempt_number=attempts, dry_run=dry_run) + return AppendSentResult( + job_id=job.id, + status="already_appended", + attempt_number=attempts, + dry_run=dry_run, + ) if job.imap_status == JobImapStatus.SKIPPED.value: - return AppendSentResult(job_id=job.id, status="skipped", attempt_number=attempts, dry_run=dry_run) + return AppendSentResult( + job_id=job.id, status="skipped", attempt_number=attempts, dry_run=dry_run + ) if job.imap_status == JobImapStatus.OUTCOME_UNKNOWN.value: return AppendSentResult( job_id=job.id, @@ -3426,10 +3735,10 @@ def _imap_result_after_lost_claim( session.rollback() current_attempt = session.get(ImapAppendAttempt, attempt.id) if current_attempt is not None: - current_attempt.status = "late_ack_ignored" if provider_succeeded else "claim_lost" - current_attempt.error_message = ( - "The provider returned after this attempt no longer owned the durable IMAP claim." + current_attempt.status = ( + "late_ack_ignored" if provider_succeeded else "claim_lost" ) + current_attempt.error_message = "The provider returned after this attempt no longer owned the durable IMAP claim." session.add(current_attempt) if provider_succeeded: # If no other worker is active, freeze any retryable state. A provider @@ -3458,7 +3767,9 @@ def _imap_result_after_lost_claim( session.expire_all() current = session.get(CampaignJob, job_id) if current is None: - raise SendJobError("Campaign job disappeared while reconciling a lost IMAP claim") + raise SendJobError( + "Campaign job disappeared while reconciling a lost IMAP claim" + ) blocked = _imap_blocked_result(session, current, dry_run=False) if blocked is not None: return blocked @@ -3596,7 +3907,9 @@ def _mark_imap_append_outcome_unknown_after_effect( session.rollback() current_attempt = session.get(ImapAppendAttempt, attempt_id) if current_attempt is None: - raise SendJobError("IMAP append outcome is unknown and its attempt record is unavailable") + raise SendJobError( + "IMAP append outcome is unknown and its attempt record is unavailable" + ) return _imap_result_after_lost_claim( session, job_id=job_id, @@ -3647,13 +3960,21 @@ def _prepare_imap_append( job.imap_status = JobImapStatus.NOT_REQUESTED.value session.add(job) session.commit() - return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run) + return AppendSentResult( + job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run + ) if not snapshot.imap_transport_revision: job.imap_status = JobImapStatus.SKIPPED.value job.last_error = "IMAP append requested, but the selected Mail profile has no IMAP configuration" session.add(job) session.commit() - return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error) + return AppendSentResult( + job_id=job.id, + status="skipped", + attempt_number=0, + dry_run=dry_run, + message=job.last_error, + ) return _ImapAppendContext( snapshot=snapshot, message_bytes=_load_eml_bytes_for_job(job), @@ -3661,7 +3982,9 @@ def _prepare_imap_append( ) -def _imap_append_dry_run(session: Session, job: CampaignJob, context: _ImapAppendContext) -> AppendSentResult: +def _imap_append_dry_run( + session: Session, job: CampaignJob, context: _ImapAppendContext +) -> AppendSentResult: return AppendSentResult( job_id=job.id, status="dry_run", @@ -3672,7 +3995,9 @@ def _imap_append_dry_run(session: Session, job: CampaignJob, context: _ImapAppen ) -def _claim_imap_append(session: Session, job: CampaignJob) -> _ClaimedImapAppend | AppendSentResult: +def _claim_imap_append( + session: Session, job: CampaignJob +) -> _ClaimedImapAppend | AppendSentResult: claim_token = _claim_job_for_imap_append(session, job) if claim_token is None: current = session.get(CampaignJob, job.id) @@ -3723,7 +4048,9 @@ def _record_imap_provider_error( ) -def _invoke_imap_append(session: Session, claimed: _ClaimedImapAppend, context: _ImapAppendContext): +def _invoke_imap_append( + session: Session, claimed: _ClaimedImapAppend, context: _ImapAppendContext +): snapshot = context.snapshot job = claimed.job return mail_integration().append_campaign_message_to_sent( @@ -3763,7 +4090,13 @@ def _perform_imap_append( "The Sent-folder append outcome is unknown after an unexpected provider failure; " "inspect and reconcile the mailbox before retrying." ) - _record_imap_provider_error(session, claimed, folder=context.folder, message=reason, outcome_unknown=True) + _record_imap_provider_error( + session, + claimed, + folder=context.folder, + message=reason, + outcome_unknown=True, + ) raise ImapAppendError(reason, outcome_unknown=True) from None try: return _record_imap_append_success( @@ -3786,7 +4119,9 @@ def _perform_imap_append( ) -def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult: +def append_sent_for_job( + session: Session, *, job_id: str, dry_run: bool = False +) -> AppendSentResult: """Append one successfully sent job's exact EML to the configured IMAP Sent folder.""" job = session.get(CampaignJob, job_id) @@ -3815,13 +4150,17 @@ def enqueue_pending_imap_appends( run_inline: bool = False, dry_run: bool = False, ) -> dict[str, Any]: - campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + campaign = _get_campaign_for_tenant( + session, campaign_id=campaign_id, tenant_id=tenant_id + ) jobs = ( session.query(CampaignJob) .filter( CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id, - CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]), + CampaignJob.imap_status.in_( + [JobImapStatus.PENDING.value, JobImapStatus.FAILED.value] + ), ) .order_by(CampaignJob.entry_index.asc()) .all() @@ -3835,7 +4174,9 @@ def enqueue_pending_imap_appends( send_status=job.send_status, ) ] - should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline + should_enqueue = ( + _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline + ) results: list[dict[str, Any]] = [] appended_count = 0 failed_count = 0 @@ -3848,11 +4189,21 @@ def enqueue_pending_imap_appends( results.append(payload) if result.status == JobImapStatus.APPENDED.value: appended_count += 1 - elif result.status in {"skipped", "not_requested", "not_sent", "already_appended", "dry_run"}: + elif result.status in { + "skipped", + "not_requested", + "not_sent", + "already_appended", + "dry_run", + }: skipped_count += 1 - except Exception as exc: # keep processing later jobs and expose per-job details + except ( + Exception + ) as exc: # keep processing later jobs and expose per-job details failed_count += 1 - results.append({"job_id": job.id, "status": "failed", "message": str(exc)}) + results.append( + {"job_id": job.id, "status": "failed", "message": str(exc)} + ) elif should_enqueue: for job in jobs: _celery_enqueue_append_sent_job(job.id) @@ -3870,6 +4221,7 @@ def enqueue_pending_imap_appends( "results": results, } + def next_retry_delay(snapshot: ExecutionSnapshot, attempt_count: int) -> int: delays = snapshot.delivery.retry.backoff_seconds or [60] index = max(0, min(attempt_count - 1, len(delays) - 1)) diff --git a/tests/test_approval_gate.py b/tests/test_approval_gate.py new file mode 100644 index 0000000..c133b39 --- /dev/null +++ b/tests/test_approval_gate.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from govoplan_core.core.approvals import ( + ApprovalActorSelector, + ApprovalCheck, + ApprovalRequestRef, + ApprovalStepDefinition, +) +from govoplan_campaign.backend.approval_gate import ( + CampaignApprovalGateError, + assert_campaign_approval, + request_campaign_approval, +) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "requester" + + +class Session: + def add(self, _value: object) -> None: + return None + + def flush(self) -> None: + return None + + +class ApprovalStub: + available = True + + def __init__(self, *, state: str = "pending") -> None: + self.state = state + self.command = None + + def create_request(self, _session, _principal, *, command, idempotency_key): + assert idempotency_key == "request-1" + self.command = command + return ApprovalRequestRef("approval-1", 1, "pending", "release") + + def check_approved(self, _session, _principal, **kwargs): + return ApprovalCheck( + request_id=str(kwargs["request_id"]), + revision=2, + state=self.state, + approved=self.state == "approved", + subject_module=str(kwargs["subject_module"]), + subject_type=str(kwargs["subject_type"]), + subject_id=str(kwargs["subject_id"]), + subject_version=kwargs["subject_version"], + subject_digest=str(kwargs["subject_digest"]), + ) + + +def _objects(): + campaign = SimpleNamespace( + id="campaign-1", + tenant_id="tenant-1", + created_by_user_id="author", + owner_user_id="owner", + ) + version = SimpleNamespace( + id="version-1", + campaign_id="campaign-1", + version_number=7, + execution_snapshot_hash="a" * 64, + editor_state={"review_send": {"updated_by_user_id": "reviewer"}}, + validation_summary={"validated_by_user_id": "validator"}, + build_summary={"built_by_user_id": "builder"}, + locked_by_user_id="validator", + ) + return campaign, version + + +def test_request_binds_snapshot_and_action_evidence() -> None: + campaign, version = _objects() + provider = ApprovalStub() + snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7") + with ( + patch( + "govoplan_campaign.backend.approval_gate.ensure_execution_snapshot", + return_value=snapshot, + ), + patch( + "govoplan_campaign.backend.approval_gate.approvals_integration", + return_value=provider, + ), + ): + request_campaign_approval( + Session(), + Principal(), + campaign=campaign, + version=version, + title="Release", + description=None, + steps=( + ApprovalStepDefinition( + "release", + "Release", + (ApprovalActorSelector("role", "sender"),), + forbidden_evidence_roles=("builder", "reviewer"), + ), + ), + idempotency_key="request-1", + ) + assert provider.command.subject_digest == "a" * 64 + assert provider.command.evidence_actors["builder"] == ("builder",) + assert provider.command.evidence_actors["reviewer"] == ("reviewer",) + assert version.editor_state["approval_gate"]["request_id"] == "approval-1" + + +def test_gate_fails_closed_until_exact_request_is_approved() -> None: + campaign, version = _objects() + version.editor_state["approval_gate"] = { + "request_id": "approval-1", + "request_revision": 1, + "subject_version": "build-7", + "subject_digest": "a" * 64, + "requested_at": "2026-08-01T00:00:00+00:00", + "requested_by_user_id": "requester", + } + snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7") + provider = ApprovalStub(state="pending") + with ( + patch( + "govoplan_campaign.backend.approval_gate.ensure_execution_snapshot", + return_value=snapshot, + ), + patch( + "govoplan_campaign.backend.approval_gate.approvals_integration", + return_value=provider, + ), + ): + with pytest.raises(CampaignApprovalGateError, match="pending"): + assert_campaign_approval(Session(), tenant_id="tenant-1", version=version) + provider.state = "approved" + assert_campaign_approval(Session(), tenant_id="tenant-1", version=version) + version.execution_snapshot_hash = "b" * 64 + with pytest.raises(CampaignApprovalGateError, match="changed"): + assert_campaign_approval(Session(), tenant_id="tenant-1", version=version) diff --git a/tests/test_route_registration.py b/tests/test_route_registration.py index 5b77c8f..4e8c3c7 100644 --- a/tests/test_route_registration.py +++ b/tests/test_route_registration.py @@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None: actual = _operation_keys(router) assert actual == expected - assert len(actual) == 64 + assert len(actual) == 65 assert not [operation for operation, count in Counter(actual).items() if count > 1] @@ -50,6 +50,13 @@ def test_key_routes_are_owned_by_their_focused_router() -> None: (reports_router, ("GET", "/campaigns/{campaign_id}/report")), (sharing_router, ("POST", "/campaigns/{campaign_id}/shares")), (delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")), + ( + delivery_router, + ( + "POST", + "/campaigns/{campaign_id}/versions/{version_id}/approval-request", + ), + ), ( attachments_router, ( diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 638b9c4..c4e063d 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -406,6 +406,16 @@ export type CampaignDeliveryOptions = { version_id: string; worker_queue_available: boolean; postbox_available: boolean; + approval_gate: { + configured?: boolean; + available?: boolean; + approved?: boolean; + state?: string; + explanation?: string | null; + request_id?: string; + request_revision?: number; + subject_digest?: string; + }; synchronous_send: { allowed?: boolean; reason?: string | null; @@ -420,6 +430,26 @@ export type CampaignDeliveryOptions = { }; }; +export type CampaignApprovalRequestPayload = { + title: string; + description?: string | null; + steps: Array<{ + key: string; + label: string; + selectors: Array<{ + kind: "account" | "group" | "role" | "function_assignment" | "any_account"; + value: string; + }>; + required_approvals: number; + rejection_policy: "fail_fast" | "collect"; + signature_required: boolean; + forbidden_evidence_roles: Array<"author" | "owner" | "validator" | "builder" | "reviewer">; + }>; + unique_actors_across_steps: boolean; + policy_refs: string[]; + idempotency_key: string; +}; + export type CampaignSendNowPayload = { version_id?: string | null; include_warnings?: boolean; @@ -1148,6 +1178,19 @@ versionId?: string | null) return apiFetch(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`); } +export async function requestCampaignApproval( +settings: ApiSettings, +campaignId: string, +versionId: string, +payload: CampaignApprovalRequestPayload) +: Promise> { + return apiFetch>( + settings, + `/api/v1/campaigns/${campaignId}/versions/${versionId}/approval-request`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + export async function sendCampaignNow( settings: ApiSettings, campaignId: string, diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index 32ece6e..6a3877b 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -22,6 +22,7 @@ import { pauseCampaign, previewCampaignAttachments, queueCampaign, + requestCampaignApproval, resumeCampaign, retryCampaignJobs, sendCampaignJob, @@ -35,7 +36,7 @@ import { type CampaignSummary } from "../../api/campaigns"; import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail"; -import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; +import { Button, Dialog, FormField, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui"; import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { ConfirmDialog } from "@govoplan/core-webui"; @@ -106,7 +107,7 @@ import { synchronousSendReason } from "./review/reviewPresentation"; -type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | ""; +type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "approval" | "send" | "queue" | "control" | "retry" | "imap" | ""; const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"]; @@ -138,6 +139,7 @@ export default function ReviewSendPage({ const canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue"); const canControlDelivery = hasScope(auth, "campaigns:campaign:control"); const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry"); + const canRequestApproval = hasScope(auth, "campaigns:campaign:review"); const [deliveryOptions, setDeliveryOptions] = useState(null); const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false); const campaignJson = useMemo(() => getCampaignJson(version), [version]); @@ -186,6 +188,12 @@ export default function ReviewSendPage({ const [attachmentLinking, setAttachmentLinking] = useState(false); const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false); const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false); + const [approvalDialogOpen, setApprovalDialogOpen] = useState(false); + const [approvalSelectorKind, setApprovalSelectorKind] = useState<"account" | "group" | "role" | "function_assignment" | "any_account">("role"); + const [approvalSelectorValue, setApprovalSelectorValue] = useState(""); + const [approvalRequiredCount, setApprovalRequiredCount] = useState(1); + const [approvalSignatureRequired, setApprovalSignatureRequired] = useState(false); + const [approvalExcludedRoles, setApprovalExcludedRoles] = useState>(() => new Set()); const [dryRun, setDryRun] = useState(false); const [sendConfirmOpen, setSendConfirmOpen] = useState(false); const [queueConfirmOpen, setQueueConfirmOpen] = useState(false); @@ -215,6 +223,7 @@ export default function ReviewSendPage({ setAttachmentPreview(null); setAttachmentPreviewError(""); setAttachmentLockConfirmOpen(false); + setApprovalDialogOpen(false); setSendResult(null); setQueueResult(null); setImapAppendResult(null); @@ -343,6 +352,11 @@ export default function ReviewSendPage({ const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]); const synchronousSendAllowed = synchronousSendOption.allowed === true; const workerQueueAvailable = deliveryOptions?.worker_queue_available === true; + const approvalGate = asRecord(deliveryOptions?.approval_gate); + const approvalGateConfigured = approvalGate.configured === true; + const approvalGateAvailable = approvalGate.available === true; + const approvalGateApproved = approvalGate.approved === true; + const approvalGateBlocksLiveDelivery = approvalGateConfigured && !approvalGateApproved; const persistedDeliveryMode = version?.delivery_mode ?? null; const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null; const selectedDryRun = dryRun && !directQueuedSendAllowed; @@ -523,7 +537,7 @@ export default function ReviewSendPage({ const mockStateDisplayLabel = !mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : !mockVerificationRequired ? "i18n:govoplan-campaign.optional.0c6c4102" : stateLabel(mockState); const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || mockPartial || downstreamDeliveryActivity); const singleMessageSendWorkflowBlocked = ["archived", "cancelled"].includes(currentWorkflowState); - const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !singleMessageSendWorkflowBlocked); + const canStartSingleMessageSend = Boolean(version && !historicalVersion && !userLockedVersion && readyForDelivery && hasBuild && mockGateSatisfied && !approvalGateBlocksLiveDelivery && !singleMessageSendWorkflowBlocked); const sendLockReason = !inspectionSatisfied ? "i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" : mockWorkflowRequired ? @@ -837,9 +851,43 @@ export default function ReviewSendPage({ } } + async function createApprovalGate() { + if (!version || busy || !canRequestApproval || !approvalGateAvailable || approvalGateConfigured || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim()) return; + setBusy("approval"); + setError(""); + setMessage("Requesting approval for the exact built execution..."); + try { + await requestCampaignApproval(settings, campaignId, version.id, { + title: `Approve delivery of ${data.campaign?.name ?? "Campaign"}`, + description: "Approval is bound to this version's immutable execution snapshot.", + steps: [{ + key: "release", + label: "Approve delivery", + selectors: [{ kind: approvalSelectorKind, value: approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue.trim() }], + required_approvals: approvalRequiredCount, + rejection_policy: "fail_fast", + signature_required: approvalSignatureRequired, + forbidden_evidence_roles: [...approvalExcludedRoles] + }], + unique_actors_across_steps: true, + policy_refs: [], + idempotency_key: crypto.randomUUID() + }); + setApprovalDialogOpen(false); + setMessage("Approval requested. Real delivery remains unavailable until the request is approved."); + await reload(); + await refreshDeliveryOptions(false); + } catch (err) { + setMessage(""); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(""); + } + } + async function runSendNow() { - if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return; const effectiveDryRun = dryRun && !directQueuedSendAllowed; + if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted || !effectiveDryRun && approvalGateBlocksLiveDelivery) return; setBusy("send"); setMessage(effectiveDryRun ? "i18n:govoplan-campaign.checking_the_built_queue_without_sending.e15bb876" : @@ -881,7 +929,7 @@ export default function ReviewSendPage({ } async function runQueueForWorkers() { - if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return; + if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return; setBusy("queue"); setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2"); setQueueResult(null); @@ -1259,6 +1307,17 @@ export default function ReviewSendPage({ detail: messagesPerMinute > 0 ? `${messagesPerMinute} message(s) per minute; estimated minimum duration ${estimatedMinutes ?? "unknown"} minute(s).` : "No explicit rate limit is configured for this execution.", state: messagesPerMinute > 0 ? "ready" : "warning" }, + { + label: "Approval", + detail: approvalGateConfigured ? + approvalGateApproved ? + `Approval ${String(approvalGate.request_id ?? "")} covers this exact execution snapshot.` : + String(approvalGate.explanation ?? "The configured approval request is not approved yet.") : + approvalGateAvailable ? + "No approval gate is configured. Add one when this delivery requires independent release authority." : + "The optional Approvals module is not active; no Campaign approval gate is configured.", + state: approvalGateConfigured ? approvalGateApproved ? "ready" : "blocked" : "info" + }, { label: "i18n:govoplan-campaign.delivery_mode.109ed9d1", detail: deliveryOptionsLoading ? @@ -1527,6 +1586,18 @@ export default function ReviewSendPage({
i18n:govoplan-campaign.version.2da600bf{version ? `v${version.version_number}` : "—"}
+
+ Approval gate + + {!approvalGateConfigured && approvalGateAvailable && canRequestApproval && + } + {approvalGateConfigured && + } +
@@ -1549,12 +1620,12 @@ export default function ReviewSendPage({ + + }> +
+

The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.

+ + + + + setApprovalSelectorValue(event.target.value)} /> + + + setApprovalRequiredCount(Math.max(1, Number(event.target.value) || 1))} /> + + +
+ Actors excluded by four-eyes policy +
+ {(["author", "owner", "validator", "builder", "reviewer"] as const).map((role) => + setApprovalExcludedRoles((current) => { + const next = new Set(current); + if (checked) next.add(role); else next.delete(role); + return next; + })} />)} +
+
+
+ +