Add institutional provenance and approval gates
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",),
|
||||
|
||||
@@ -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,10 +667,16 @@ 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({
|
||||
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,
|
||||
@@ -586,7 +688,8 @@ def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
|
||||
"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,7 +808,8 @@ def _store_job_issues(
|
||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
|
||||
) -> None:
|
||||
for job, message in job_build_pairs:
|
||||
session.add_all([
|
||||
session.add_all(
|
||||
[
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
@@ -702,7 +822,8 @@ def _store_job_issues(
|
||||
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(
|
||||
|
||||
@@ -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({
|
||||
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.",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
return CampaignDeliveryOptionsResponse(
|
||||
**synchronous_send_options(
|
||||
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(
|
||||
**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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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,
|
||||
(
|
||||
|
||||
@@ -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<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
|
||||
}
|
||||
|
||||
export async function requestCampaignApproval(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
payload: CampaignApprovalRequestPayload)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${campaignId}/versions/${versionId}/approval-request`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendCampaignNow(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -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<CampaignDeliveryOptions | null>(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<Set<"author" | "owner" | "validator" | "builder" | "reviewer">>(() => 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({
|
||||
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
||||
</div>
|
||||
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
|
||||
<div className="review-send-controls">
|
||||
<span>Approval gate</span>
|
||||
<StatusBadge
|
||||
status={approvalGateConfigured ? approvalGateApproved ? "approved" : String(approvalGate.state ?? "pending") : "not_required"}
|
||||
label={approvalGateConfigured ? approvalGateApproved ? "Approved" : humanize(String(approvalGate.state ?? "pending")) : "Not required"} />
|
||||
{!approvalGateConfigured && approvalGateAvailable && canRequestApproval &&
|
||||
<Button disabled={Boolean(busy) || readOnlyVersion || !hasBuild} onClick={() => setApprovalDialogOpen(true)}>
|
||||
<ShieldCheck size={16} aria-hidden="true" />Request approval
|
||||
</Button>}
|
||||
{approvalGateConfigured &&
|
||||
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
|
||||
</div>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
|
||||
@@ -1549,12 +1620,12 @@ export default function ReviewSendPage({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setQueueConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
|
||||
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || approvalGateBlocksLiveDelivery || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
|
||||
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
|
||||
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || !selectedDryRun && approvalGateBlocksLiveDelivery || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
|
||||
|
||||
{busy === "send" ?
|
||||
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
|
||||
@@ -1770,6 +1841,58 @@ export default function ReviewSendPage({
|
||||
|
||||
}
|
||||
|
||||
<Dialog
|
||||
open={approvalDialogOpen}
|
||||
title="Request Campaign approval"
|
||||
portal
|
||||
closeDisabled={busy === "approval"}
|
||||
onClose={() => setApprovalDialogOpen(false)}
|
||||
footer={<>
|
||||
<Button disabled={busy === "approval"} onClick={() => setApprovalDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy === "approval" || approvalSelectorKind !== "any_account" && !approvalSelectorValue.trim() || approvalRequiredCount < 1}
|
||||
onClick={() => void createApprovalGate()}>
|
||||
{busy === "approval" ? "Requesting..." : "Request approval"}
|
||||
</Button>
|
||||
</>}>
|
||||
<div className="form-grid">
|
||||
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
|
||||
<FormField label="Approver type">
|
||||
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
|
||||
<option value="account">Account</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="role">Role</option>
|
||||
<option value="function_assignment">Function assignment</option>
|
||||
<option value="any_account">Any account</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Approver reference">
|
||||
<input value={approvalSelectorKind === "any_account" ? "*" : approvalSelectorValue} disabled={busy === "approval" || approvalSelectorKind === "any_account"} onChange={(event) => setApprovalSelectorValue(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Required approvals">
|
||||
<input type="number" min={1} max={500} value={approvalRequiredCount} disabled={busy === "approval"} onChange={(event) => setApprovalRequiredCount(Math.max(1, Number(event.target.value) || 1))} />
|
||||
</FormField>
|
||||
<ToggleSwitch label="Require signature evidence" checked={approvalSignatureRequired} disabled={busy === "approval"} onChange={setApprovalSignatureRequired} />
|
||||
<div className="form-grid-wide">
|
||||
<span className="field-label">Actors excluded by four-eyes policy</span>
|
||||
<div className="button-row compact-actions">
|
||||
{(["author", "owner", "validator", "builder", "reviewer"] as const).map((role) =>
|
||||
<ToggleSwitch
|
||||
key={role}
|
||||
label={humanize(role)}
|
||||
checked={approvalExcludedRoles.has(role)}
|
||||
disabled={busy === "approval"}
|
||||
onChange={(checked) => setApprovalExcludedRoles((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(role); else next.delete(role);
|
||||
return next;
|
||||
})} />)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={attachmentLockConfirmOpen}
|
||||
title="i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653"
|
||||
|
||||
Reference in New Issue
Block a user