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
|
belong to the optional Approvals capability. Campaign must not claim an
|
||||||
approval merely because validation, building, or message review completed.
|
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
|
Normal readers and reviewers see business state and safe evidence. Process-local
|
||||||
paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
||||||
the dedicated diagnostic permission and must not leak through ordinary campaign,
|
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",
|
"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_OPT_IN_KEYS = frozenset(
|
||||||
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
|
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
|
||||||
)
|
)
|
||||||
@@ -27,6 +29,16 @@ CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
|
|||||||
"updated_by_user_id",
|
"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):
|
class CampaignMailProfileBoundaryError(ValueError):
|
||||||
@@ -94,10 +106,13 @@ def validate_campaign_editor_state(
|
|||||||
if value is None:
|
if value is None:
|
||||||
return {}
|
return {}
|
||||||
if not isinstance(value, dict):
|
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)
|
allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
|
||||||
if allow_server_review_state:
|
if allow_server_review_state:
|
||||||
allowed.add("review_send")
|
allowed.add("review_send")
|
||||||
|
allowed.add("approval_gate")
|
||||||
if any(key not in allowed for key in value):
|
if any(key not in allowed for key in value):
|
||||||
raise CampaignMailProfileBoundaryError(
|
raise CampaignMailProfileBoundaryError(
|
||||||
"Campaign editor state contains unsupported or transport-owned fields"
|
"Campaign editor state contains unsupported or transport-owned fields"
|
||||||
@@ -113,11 +128,13 @@ def validate_campaign_editor_state(
|
|||||||
if "opt_ins" in value:
|
if "opt_ins" in value:
|
||||||
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
|
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
|
||||||
if "field_overrides" in value:
|
if "field_overrides" in value:
|
||||||
result["field_overrides"] = _validated_field_overrides(
|
result["field_overrides"] = _validated_field_overrides(value["field_overrides"])
|
||||||
value["field_overrides"]
|
|
||||||
)
|
|
||||||
if "review_send" in value:
|
if "review_send" in value:
|
||||||
result["review_send"] = _validated_server_review_state(value["review_send"])
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -162,6 +179,13 @@ def public_campaign_editor_state(
|
|||||||
result["review_send"] = review_state
|
result["review_send"] = review_state
|
||||||
except CampaignMailProfileBoundaryError:
|
except CampaignMailProfileBoundaryError:
|
||||||
pass
|
pass
|
||||||
|
if "approval_gate" in value:
|
||||||
|
try:
|
||||||
|
result["approval_gate"] = _validated_server_approval_gate(
|
||||||
|
value["approval_gate"]
|
||||||
|
)
|
||||||
|
except CampaignMailProfileBoundaryError:
|
||||||
|
pass
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -170,9 +194,48 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
state = public_campaign_editor_state(value)
|
state = public_campaign_editor_state(value)
|
||||||
state.pop("review_send", None)
|
state.pop("review_send", None)
|
||||||
|
state.pop("approval_gate", None)
|
||||||
return state
|
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:
|
def _is_valid_reviewed_message_key(value: Any) -> bool:
|
||||||
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
|
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",
|
"issue_codes",
|
||||||
}
|
}
|
||||||
for item in value:
|
for item in value:
|
||||||
if not isinstance(item, dict) or any(
|
if not isinstance(item, dict) or any(key not in allowed_keys for key in item):
|
||||||
key not in allowed_keys for key in item
|
|
||||||
):
|
|
||||||
raise CampaignMailProfileBoundaryError(
|
raise CampaignMailProfileBoundaryError(
|
||||||
"Campaign review issue decision is invalid"
|
"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",
|
error="Campaign review decision is invalid",
|
||||||
),
|
),
|
||||||
"reason": item.get("reason"),
|
"reason": item.get("reason"),
|
||||||
"actor_user_id": _validated_review_actor(
|
"actor_user_id": _validated_review_actor(item.get("actor_user_id")),
|
||||||
item.get("actor_user_id")
|
|
||||||
),
|
|
||||||
"decided_at": _validated_required_string(
|
"decided_at": _validated_required_string(
|
||||||
item.get("decided_at"),
|
item.get("decided_at"),
|
||||||
max_length=128,
|
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
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||||
if not isinstance(server, dict):
|
if not isinstance(server, dict):
|
||||||
return ()
|
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:
|
for key in CAMPAIGN_MAIL_SERVER_KEYS:
|
||||||
if key not in server:
|
if key not in server:
|
||||||
continue
|
continue
|
||||||
@@ -378,9 +443,7 @@ def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -
|
|||||||
violations.append(f"/server/{key}")
|
violations.append(f"/server/{key}")
|
||||||
references = campaign_mail_resource_ids(raw_json)
|
references = campaign_mail_resource_ids(raw_json)
|
||||||
if references["mail_profile_id"] is None and any(
|
if references["mail_profile_id"] is None and any(
|
||||||
references[key]
|
references[key] for key in references if key != "mail_profile_id"
|
||||||
for key in references
|
|
||||||
if key != "mail_profile_id"
|
|
||||||
):
|
):
|
||||||
violations.append("/server/mail_profile_id")
|
violations.append("/server/mail_profile_id")
|
||||||
for protocol in ("smtp", "imap"):
|
for protocol in ("smtp", "imap"):
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ from contextlib import contextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterator
|
from typing import Any, Iterator
|
||||||
|
|
||||||
|
from govoplan_core.core.approvals import (
|
||||||
|
ApprovalCheck,
|
||||||
|
ApprovalRequestCreateCommand,
|
||||||
|
ApprovalRequestProvider,
|
||||||
|
ApprovalRequestRef,
|
||||||
|
CAPABILITY_APPROVAL_REQUESTS,
|
||||||
|
)
|
||||||
from govoplan_core.core.postbox import (
|
from govoplan_core.core.postbox import (
|
||||||
CAPABILITY_POSTBOX_DIRECTORY,
|
CAPABILITY_POSTBOX_DIRECTORY,
|
||||||
CAPABILITY_POSTBOX_DELIVERY,
|
CAPABILITY_POSTBOX_DELIVERY,
|
||||||
@@ -29,6 +36,7 @@ MAIL_CAPABILITY = "mail.campaign_delivery"
|
|||||||
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
|
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
|
||||||
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
|
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
|
||||||
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
|
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
|
||||||
|
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
|
||||||
|
|
||||||
|
|
||||||
class OptionalModuleUnavailable(RuntimeError):
|
class OptionalModuleUnavailable(RuntimeError):
|
||||||
@@ -40,7 +48,9 @@ class SmtpConfigurationError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class SmtpSendError(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)
|
super().__init__(message)
|
||||||
self.temporary = temporary
|
self.temporary = temporary
|
||||||
self.outcome_unknown = outcome_unknown
|
self.outcome_unknown = outcome_unknown
|
||||||
@@ -75,6 +85,10 @@ class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalGateUnavailable(OptionalModuleUnavailable):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class _PreparedCampaignSnapshot:
|
class _PreparedCampaignSnapshot:
|
||||||
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
|
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
|
||||||
self._directory = directory
|
self._directory = directory
|
||||||
@@ -103,20 +117,30 @@ class FilesCampaignIntegration:
|
|||||||
yield prepared
|
yield prepared
|
||||||
return
|
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-")
|
prefix = str(kwargs.get("prefix") or "govoplan-campaign-")
|
||||||
directory = Path(tempfile.mkdtemp(prefix=prefix))
|
directory = Path(tempfile.mkdtemp(prefix=prefix))
|
||||||
snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json)
|
snapshot = _PreparedCampaignSnapshot(
|
||||||
snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
|
directory, directory / "campaign.json", raw_json
|
||||||
|
)
|
||||||
|
snapshot.path.write_text(
|
||||||
|
json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
yield snapshot
|
yield snapshot
|
||||||
finally:
|
finally:
|
||||||
snapshot.cleanup()
|
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:
|
if self._delegate is None:
|
||||||
return []
|
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]:
|
def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]:
|
||||||
if self._delegate is not None:
|
if self._delegate is not None:
|
||||||
@@ -127,20 +151,30 @@ class FilesCampaignIntegration:
|
|||||||
return dict(attachment)
|
return dict(attachment)
|
||||||
return {"path": str(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:
|
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:
|
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]:
|
def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]:
|
||||||
if self._delegate is None:
|
if self._delegate is None:
|
||||||
raise OptionalModuleUnavailable("Files module is not available")
|
raise OptionalModuleUnavailable("Files module is not available")
|
||||||
return self._delegate.current_version_and_blob(session, asset)
|
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:
|
if self._delegate is None:
|
||||||
raise OptionalModuleUnavailable("Files module is not available")
|
raise OptionalModuleUnavailable("Files module is not available")
|
||||||
return self._delegate.share_assets_with_campaign(session, **kwargs)
|
return self._delegate.share_assets_with_campaign(session, **kwargs)
|
||||||
@@ -154,7 +188,9 @@ class MailCampaignIntegration:
|
|||||||
def __init__(self, delegate: Any | None = None) -> None:
|
def __init__(self, delegate: Any | None = None) -> None:
|
||||||
self._delegate = delegate
|
self._delegate = delegate
|
||||||
if delegate is not None:
|
if delegate is not None:
|
||||||
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError)
|
self.MailProfileError = getattr(
|
||||||
|
delegate, "MailProfileError", MailProfileError
|
||||||
|
)
|
||||||
|
|
||||||
MailProfileError = MailProfileError
|
MailProfileError = MailProfileError
|
||||||
SmtpConfigurationError = SmtpConfigurationError
|
SmtpConfigurationError = SmtpConfigurationError
|
||||||
@@ -177,26 +213,38 @@ class MailCampaignIntegration:
|
|||||||
raise MailProfileError("Mail module is not available")
|
raise MailProfileError("Mail module is not available")
|
||||||
return self._delegate
|
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:
|
if self._delegate is None:
|
||||||
raw_json = kwargs.get("raw_json")
|
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:
|
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
|
return None
|
||||||
try:
|
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:
|
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
raise MailProfileError(str(exc)) from 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:
|
if self._delegate is not None:
|
||||||
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
|
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
|
||||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
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
|
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
|
||||||
return str(profile_id).strip() if profile_id 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()
|
delegate = self._require()
|
||||||
try:
|
try:
|
||||||
return delegate.campaign_profile_delivery_summary(session, **kwargs)
|
return delegate.campaign_profile_delivery_summary(session, **kwargs)
|
||||||
@@ -213,8 +261,14 @@ class MailCampaignIntegration:
|
|||||||
try:
|
try:
|
||||||
return delegate.send_campaign_email_bytes(*args, **kwargs)
|
return delegate.send_campaign_email_bytes(*args, **kwargs)
|
||||||
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
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
|
raise SmtpSendError(
|
||||||
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
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
|
raise SmtpConfigurationError(str(exc)) from exc
|
||||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
raise MailProfileError(str(exc)) from exc
|
raise MailProfileError(str(exc)) from exc
|
||||||
@@ -229,7 +283,9 @@ class MailCampaignIntegration:
|
|||||||
temporary=getattr(exc, "temporary", None),
|
temporary=getattr(exc, "temporary", None),
|
||||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||||
) from exc
|
) from exc
|
||||||
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
except getattr(
|
||||||
|
delegate, "ImapConfigurationError", ImapConfigurationError
|
||||||
|
) as exc:
|
||||||
raise ImapConfigurationError(str(exc)) from exc
|
raise ImapConfigurationError(str(exc)) from exc
|
||||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
raise MailProfileError(str(exc)) from exc
|
raise MailProfileError(str(exc)) from exc
|
||||||
@@ -302,8 +358,7 @@ class PostboxCampaignIntegration:
|
|||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
return (
|
return (
|
||||||
self._delivery_delegate is not None
|
self._delivery_delegate is not None and self._directory_delegate is not None
|
||||||
and self._directory_delegate is not None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -381,6 +436,63 @@ class PostboxCampaignIntegration:
|
|||||||
return summaries
|
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:
|
def files_integration() -> FilesCampaignIntegration:
|
||||||
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
|
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
|
||||||
|
|
||||||
@@ -395,3 +507,7 @@ def postbox_integration() -> PostboxCampaignIntegration:
|
|||||||
capability(POSTBOX_DIRECTORY_CAPABILITY),
|
capability(POSTBOX_DIRECTORY_CAPABILITY),
|
||||||
capability(POSTBOX_EVIDENCE_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 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 (
|
from govoplan_core.core.campaigns import (
|
||||||
CAPABILITY_CAMPAIGNS_ACCESS,
|
CAPABILITY_CAMPAIGNS_ACCESS,
|
||||||
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
|
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS,
|
||||||
@@ -10,7 +14,10 @@ from govoplan_core.core.campaigns import (
|
|||||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
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.ownership import OwnershipProviderRegistration
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
@@ -39,12 +46,17 @@ from govoplan_core.core.postbox import (
|
|||||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
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.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.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()
|
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)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
scope=scope,
|
scope=scope,
|
||||||
@@ -59,32 +71,162 @@ def _permission(scope: str, label: str, description: str, category: str) -> Perm
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission("campaigns:campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"),
|
_permission(
|
||||||
_permission("campaigns:campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"),
|
"campaigns:campaign:read",
|
||||||
_permission("campaigns:campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"),
|
"View campaigns",
|
||||||
_permission("campaigns:campaign:copy", "Copy campaigns", "Create campaigns or working versions from existing campaigns.", "Campaigns"),
|
"Open campaign metadata, versions and permitted message summaries.",
|
||||||
_permission("campaigns:campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"),
|
"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(
|
||||||
_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"),
|
"campaigns:campaign:create",
|
||||||
_permission("campaigns:ownership:recover", "Recover campaign ownership", "Participate in delayed, quorum-backed administrative campaign ownership recovery.", "Campaign governance"),
|
"Create campaigns",
|
||||||
_permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
|
"Create new campaigns in an accessible tenant scope.",
|
||||||
_permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
|
"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(
|
||||||
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
|
"campaigns:campaign:update",
|
||||||
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
|
"Edit campaigns",
|
||||||
_permission("campaigns:campaign:send", "Send campaigns", "Start real Mail or Postbox delivery.", "Campaigns"),
|
"Edit current working campaign versions.",
|
||||||
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
|
"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(
|
||||||
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
|
"campaigns:campaign:copy",
|
||||||
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
|
"Copy campaigns",
|
||||||
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
|
"Create campaigns or working versions from existing campaigns.",
|
||||||
_permission("campaigns:recipient:export", "Export recipients", "Export recipient data and reports.", "Recipients"),
|
"Campaigns",
|
||||||
_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(
|
||||||
_permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"),
|
"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 = (
|
OPERATOR_QUEUE_REQUIRED_ANY = (
|
||||||
@@ -108,9 +250,7 @@ ROLE_TEMPLATES = (
|
|||||||
slug="campaign_aggregate_reader",
|
slug="campaign_aggregate_reader",
|
||||||
name="Campaign aggregate reader",
|
name="Campaign aggregate reader",
|
||||||
description="View privacy-protected outcome totals without recipient or delivery diagnostics.",
|
description="View privacy-protected outcome totals without recipient or delivery diagnostics.",
|
||||||
permissions=(
|
permissions=("campaigns:report:read",),
|
||||||
"campaigns:report:read",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="campaign_manager",
|
slug="campaign_manager",
|
||||||
@@ -166,7 +306,11 @@ ROLE_TEMPLATES = (
|
|||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
from govoplan_campaign.backend.db.models import Campaign
|
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]]:
|
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)
|
.group_by(Campaign.tenant_id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return {
|
return {tenant_id: {"campaigns": int(count)} for tenant_id, count in rows}
|
||||||
tenant_id: {"campaigns": int(count)}
|
|
||||||
for tenant_id, count in rows
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _campaigns_router(context: ModuleContext):
|
def _campaigns_router(context: ModuleContext):
|
||||||
@@ -207,14 +348,21 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.12",
|
version="0.1.12",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(
|
||||||
optional_capabilities=(CAPABILITY_ACCESS_REFERENCE_OPTIONS,),
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
|
),
|
||||||
|
optional_capabilities=(
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
|
CAPABILITY_APPROVAL_REQUESTS,
|
||||||
|
),
|
||||||
optional_dependencies=(
|
optional_dependencies=(
|
||||||
"files",
|
"files",
|
||||||
"mail",
|
"mail",
|
||||||
"notifications",
|
"notifications",
|
||||||
"addresses",
|
"addresses",
|
||||||
"postbox",
|
"postbox",
|
||||||
|
"approvals",
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
@@ -278,6 +426,12 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_APPROVAL_REQUESTS,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_campaigns_router,
|
route_factory=_campaigns_router,
|
||||||
@@ -285,7 +439,13 @@ manifest = ModuleManifest(
|
|||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
||||||
nav_items=(
|
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(
|
frontend=FrontendModule(
|
||||||
module_id="campaigns",
|
module_id="campaigns",
|
||||||
@@ -321,8 +481,16 @@ manifest = ModuleManifest(
|
|||||||
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
|
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
|
||||||
),
|
),
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20),
|
NavItem(
|
||||||
NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
|
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=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
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.",
|
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",
|
layer="available",
|
||||||
documentation_types=("user", "admin"),
|
documentation_types=("user", "admin"),
|
||||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender", "campaign_operator"),
|
audience=(
|
||||||
|
"campaign_manager",
|
||||||
|
"campaign_reviewer",
|
||||||
|
"campaign_sender",
|
||||||
|
"campaign_operator",
|
||||||
|
),
|
||||||
order=45,
|
order=45,
|
||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("campaigns", "postbox"),
|
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=(
|
links=(
|
||||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
DocumentationLink(label="Postboxes", href="/postbox", 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"),
|
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={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/campaigns/{campaign_id}/global-settings",
|
"route": "/campaigns/{campaign_id}/global-settings",
|
||||||
@@ -439,8 +620,16 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
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",),
|
related_modules=("mail",),
|
||||||
unlocks=("Profile-backed SMTP delivery and optional IMAP append-to-Sent.",),
|
unlocks=("Profile-backed SMTP delivery and optional IMAP append-to-Sent.",),
|
||||||
@@ -480,13 +669,27 @@ manifest = ModuleManifest(
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("campaigns", "mail"),
|
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=(
|
links=(
|
||||||
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
|
label="Mail profiles",
|
||||||
DocumentationLink(label="Mail profile boundary", href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", kind="repository"),
|
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"),
|
related_modules=("mail", "access"),
|
||||||
unlocks=("Auditable, reusable transport configuration across campaigns.",),
|
unlocks=("Auditable, reusable transport configuration across campaigns.",),
|
||||||
@@ -514,13 +717,27 @@ manifest = ModuleManifest(
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("campaigns", "mail"),
|
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=(
|
links=(
|
||||||
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Campaign reports", href="/campaigns/reports", kind="runtime"),
|
label="Campaign operator queue",
|
||||||
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
|
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"),
|
related_modules=("mail", "audit"),
|
||||||
unlocks=("Fail-closed recovery without exposing Mail credentials.",),
|
unlocks=("Fail-closed recovery without exposing Mail credentials.",),
|
||||||
@@ -548,16 +765,30 @@ manifest = ModuleManifest(
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("campaigns",),
|
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=(
|
links=(
|
||||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Recipient import guide", href="govoplan-campaign/docs/RECIPIENT_IMPORT_GUIDE.md", kind="repository"),
|
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"),
|
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={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/campaigns/{campaign_id}/global-settings",
|
"route": "/campaigns/{campaign_id}/global-settings",
|
||||||
@@ -608,15 +839,24 @@ manifest = ModuleManifest(
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("campaigns",),
|
required_modules=("campaigns",),
|
||||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:review"),
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:review",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
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"),
|
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={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/campaigns/{campaign_id}/review",
|
"route": "/campaigns/{campaign_id}/review",
|
||||||
@@ -652,20 +892,38 @@ manifest = ModuleManifest(
|
|||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("campaigns",),
|
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=(
|
links=(
|
||||||
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
|
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"),
|
related_modules=("mail", "audit"),
|
||||||
unlocks=("Evidence-backed recovery without accidental duplicate external effects.",),
|
unlocks=(
|
||||||
|
"Evidence-backed recovery without accidental duplicate external effects.",
|
||||||
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/campaigns/queue",
|
"route": "/campaigns/queue",
|
||||||
"screen": "Campaign operator queue",
|
"screen": "Campaign operator queue",
|
||||||
"help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"],
|
"help_contexts": [
|
||||||
|
"campaign.review-send",
|
||||||
|
"campaign.report",
|
||||||
|
"campaign.audit",
|
||||||
|
],
|
||||||
"prerequisites": [
|
"prerequisites": [
|
||||||
"You may perform the selected retry or reconciliation action.",
|
"You may perform the selected retry or reconciliation action.",
|
||||||
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
"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.",
|
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",
|
layer="evidence",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("platform_operator", "security_reviewer", "release_reviewer", "integrator"),
|
audience=(
|
||||||
|
"platform_operator",
|
||||||
|
"security_reviewer",
|
||||||
|
"release_reviewer",
|
||||||
|
"integrator",
|
||||||
|
),
|
||||||
order=52,
|
order=52,
|
||||||
conditions=(
|
conditions=(
|
||||||
DocumentationCondition(
|
DocumentationCondition(
|
||||||
required_modules=("campaigns",),
|
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=(
|
links=(
|
||||||
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Reference examples and release checklist", href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", kind="repository"),
|
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"),
|
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={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"route": "/campaigns",
|
"route": "/campaigns",
|
||||||
@@ -735,8 +1013,16 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
|
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"),
|
related_modules=("files", "ops", "mail"),
|
||||||
metadata={
|
metadata={
|
||||||
@@ -800,9 +1086,22 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/CAMPAIGN_HANDBOOK.md",
|
documentation_ref="docs/CAMPAIGN_HANDBOOK.md",
|
||||||
test_ref="tests/test_execution_snapshot_integrity.py",
|
test_ref="tests/test_execution_snapshot_integrity.py",
|
||||||
known_limits=("Target-environment recovery and accessibility evidence is incomplete for reference-ready maturity.",),
|
known_limits=(
|
||||||
owned_concepts=("campaign", "campaign version", "recipient snapshot", "delivery job", "delivery report"),
|
"Target-environment recovery and accessibility evidence is incomplete for reference-ready maturity.",
|
||||||
non_owned_concepts=("mail transport", "postbox", "durable address directory", "file storage"),
|
),
|
||||||
|
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",),
|
recovery_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
|
||||||
security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",),
|
security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",),
|
||||||
operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
|
operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobSendStatus,
|
JobSendStatus,
|
||||||
JobValidationStatus,
|
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 (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
assert_campaign_uses_mail_profile_reference,
|
assert_campaign_uses_mail_profile_reference,
|
||||||
campaign_mail_profile_id,
|
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.builder import build_campaign_messages
|
||||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
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 (
|
from govoplan_campaign.backend.campaign.models import (
|
||||||
CampaignConfig,
|
CampaignConfig,
|
||||||
DeliveryChannelPolicy,
|
DeliveryChannelPolicy,
|
||||||
@@ -73,8 +79,6 @@ class _StoredEmlArtifact:
|
|||||||
message_id_header: str | None
|
message_id_header: str | None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def load_campaign_config_from_json(
|
def load_campaign_config_from_json(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -116,7 +120,11 @@ def _campaign_reference_path(version: CampaignVersion) -> Path:
|
|||||||
"""Return a path anchor without persisting a second configuration copy."""
|
"""Return a path anchor without persisting a second configuration copy."""
|
||||||
|
|
||||||
source_base_path = str(version.source_base_path or "").strip()
|
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"
|
return base / "campaign.json"
|
||||||
|
|
||||||
|
|
||||||
@@ -142,8 +150,7 @@ def _persist_built_eml_artifacts(
|
|||||||
path = Path(message.eml_path)
|
path = Path(message.eml_path)
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"Generated EML evidence is missing for entry "
|
f"Generated EML evidence is missing for entry {message.entry_index}"
|
||||||
f"{message.entry_index}"
|
|
||||||
)
|
)
|
||||||
payload = path.read_bytes()
|
payload = path.read_bytes()
|
||||||
digest = hashlib.sha256(payload).hexdigest()
|
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:
|
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
|
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())
|
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.
|
"""Resolve paths for an explicitly trusted, file-oriented import.
|
||||||
|
|
||||||
The CLI naturally resolves relative paths against the campaign.json file.
|
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
|
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
|
||||||
data = copy.deepcopy(raw_json)
|
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):
|
if isinstance(template_source, dict):
|
||||||
for key in ("subject_path", "text_path", "html_path"):
|
for key in ("subject_path", "text_path", "html_path"):
|
||||||
template_source[key] = _resolve_runtime_path(base, template_source.get(key))
|
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):
|
if isinstance(entries_source, dict):
|
||||||
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
|
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
|
||||||
|
|
||||||
attachments = data.get("attachments")
|
attachments = data.get("attachments")
|
||||||
if isinstance(attachments, dict):
|
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
|
return data
|
||||||
|
|
||||||
@@ -251,15 +274,21 @@ def create_campaign_version_from_json(
|
|||||||
)
|
)
|
||||||
if source_base_path is None and source_filename:
|
if source_base_path is None and source_filename:
|
||||||
source_path = Path(source_filename).expanduser()
|
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)
|
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 = (
|
campaign = (
|
||||||
session.query(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()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
if campaign is None:
|
if campaign is None:
|
||||||
@@ -275,7 +304,11 @@ def create_campaign_version_from_json(
|
|||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.flush()
|
session.flush()
|
||||||
else:
|
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):
|
if current and not _version_is_audit_safe_snapshot(current):
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
f"Campaign already has active working version #{current.version_number}. "
|
f"Campaign already has active working version #{current.version_number}. "
|
||||||
@@ -303,8 +336,6 @@ def create_campaign_version_from_json(
|
|||||||
return campaign, version
|
return campaign, version
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||||
state = getattr(version, "user_lock_state", None)
|
state = getattr(version, "user_lock_state", None)
|
||||||
if state in {"temporary", "permanent"}:
|
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:
|
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.QUEUED.value,
|
||||||
CampaignVersionWorkflowState.SENDING.value,
|
CampaignVersionWorkflowState.SENDING.value,
|
||||||
CampaignVersionWorkflowState.COMPLETED.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:
|
if campaign.current_version_id != version.id:
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
f"Historical campaign versions are read-only and cannot be used to {action}. "
|
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:
|
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||||
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
validation_summary = (
|
||||||
return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version))
|
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:
|
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
||||||
state = _version_user_lock_state(version)
|
state = _version_user_lock_state(version)
|
||||||
if state == "temporary":
|
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":
|
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):
|
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):
|
def load_version_config(session: Session, version_id: str):
|
||||||
version = session.get(CampaignVersion, version_id)
|
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,
|
managed_files_available=files_integration().available,
|
||||||
)
|
)
|
||||||
path = _campaign_reference_path(version)
|
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(
|
def validate_campaign_version(
|
||||||
@@ -379,7 +438,9 @@ def validate_campaign_version(
|
|||||||
version, snapshot_path, config = load_version_config(session, version_id)
|
version, snapshot_path, config = load_version_config(session, version_id)
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_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")
|
_ensure_current_campaign_version(campaign, version, action="validate")
|
||||||
if _version_is_user_locked(version) or version.workflow_state in {
|
if _version_is_user_locked(version) or version.workflow_state in {
|
||||||
CampaignVersionWorkflowState.QUEUED.value,
|
CampaignVersionWorkflowState.QUEUED.value,
|
||||||
@@ -391,8 +452,14 @@ def validate_campaign_version(
|
|||||||
CampaignVersionWorkflowState.CANCELLED.value,
|
CampaignVersionWorkflowState.CANCELLED.value,
|
||||||
CampaignVersionWorkflowState.ARCHIVED.value,
|
CampaignVersionWorkflowState.ARCHIVED.value,
|
||||||
}:
|
}:
|
||||||
lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final"
|
lock_label = (
|
||||||
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
|
"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:
|
if check_files:
|
||||||
files = files_integration()
|
files = files_integration()
|
||||||
@@ -405,7 +472,12 @@ def validate_campaign_version(
|
|||||||
prefix="govoplan-managed-validate-",
|
prefix="govoplan-managed-validate-",
|
||||||
) as prepared:
|
) as prepared:
|
||||||
managed_raw = load_campaign_json(prepared.path)
|
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(
|
report = validate_campaign_config(
|
||||||
managed_config,
|
managed_config,
|
||||||
campaign_file=prepared.path,
|
campaign_file=prepared.path,
|
||||||
@@ -434,7 +506,10 @@ def validate_campaign_version(
|
|||||||
# Replace version-level semantic issues from previous validations.
|
# Replace version-level semantic issues from previous validations.
|
||||||
(
|
(
|
||||||
session.query(CampaignIssue)
|
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)
|
.delete(synchronize_session=False)
|
||||||
)
|
)
|
||||||
for issue in report.issues:
|
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:
|
if report.ok:
|
||||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||||
version.is_complete = True
|
version.is_complete = True
|
||||||
@@ -477,7 +556,9 @@ def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None, None
|
return None, None
|
||||||
payload = path.read_bytes()
|
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
|
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_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_size_bytes=stored_eml.size_bytes if stored_eml else message.eml_size_bytes,
|
||||||
eml_sha256=eml_sha256,
|
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),
|
validation_status=_job_validation_status(message.validation_status.value),
|
||||||
queue_status=JobQueueStatus.DRAFT.value,
|
queue_status=JobQueueStatus.DRAFT.value,
|
||||||
send_status=(
|
send_status=(
|
||||||
@@ -522,7 +605,9 @@ def _job_from_message(
|
|||||||
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
|
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
|
||||||
else JobPostboxStatus.NOT_REQUESTED.value
|
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={
|
resolved_recipients={
|
||||||
"from": message.from_.model_dump(mode="json") if message.from_ else None,
|
"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],
|
"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],
|
"bcc": [item.model_dump(mode="json") for item in message.bcc],
|
||||||
"reply_to": [item.model_dump(mode="json") for item in message.reply_to],
|
"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],
|
"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_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],
|
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
|
continue
|
||||||
entry = entries_by_index.get(built.draft.entry_index)
|
entry = entries_by_index.get(built.draft.entry_index)
|
||||||
if entry is None:
|
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(
|
resolved, issues, validation_status = resolve_entry_postbox_targets(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -571,22 +667,29 @@ def _resolve_built_postbox_targets(
|
|||||||
|
|
||||||
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
|
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
|
||||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
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):
|
if isinstance(message_payload, dict):
|
||||||
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
|
message_payload["attachments"] = [
|
||||||
report_json.update({
|
files.public_attachment_summary_payload(item)
|
||||||
"built_at": datetime.now(UTC).isoformat(),
|
for item in message.attachments
|
||||||
"build_token": uuid4().hex,
|
]
|
||||||
"built_count": result.report.built_count,
|
report_json.update(
|
||||||
"build_failed_count": result.report.build_failed_count,
|
{
|
||||||
"ready_count": result.report.ready_count,
|
"built_at": datetime.now(UTC).isoformat(),
|
||||||
"warning_count": result.report.warning_count,
|
"build_token": uuid4().hex,
|
||||||
"needs_review_count": result.report.needs_review_count,
|
"built_count": result.report.built_count,
|
||||||
"blocked_count": result.report.blocked_count,
|
"build_failed_count": result.report.build_failed_count,
|
||||||
"excluded_count": result.report.excluded_count,
|
"ready_count": result.report.ready_count,
|
||||||
"inactive_count": result.report.inactive_count,
|
"warning_count": result.report.warning_count,
|
||||||
"queueable_count": result.report.queueable_count,
|
"needs_review_count": result.report.needs_review_count,
|
||||||
})
|
"blocked_count": result.report.blocked_count,
|
||||||
|
"excluded_count": result.report.excluded_count,
|
||||||
|
"inactive_count": result.report.inactive_count,
|
||||||
|
"queueable_count": result.report.queueable_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
return report_json
|
return report_json
|
||||||
|
|
||||||
|
|
||||||
@@ -614,7 +717,9 @@ def _replace_version_jobs(
|
|||||||
CampaignIssue.campaign_version_id == version_id,
|
CampaignIssue.campaign_version_id == version_id,
|
||||||
CampaignIssue.job_id.is_not(None),
|
CampaignIssue.job_id.is_not(None),
|
||||||
).delete(synchronize_session=False)
|
).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()
|
session.flush()
|
||||||
|
|
||||||
pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
||||||
@@ -624,7 +729,9 @@ def _replace_version_jobs(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
message=built.draft,
|
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),
|
stored_eml=stored_eml_by_index.get(built.draft.entry_index),
|
||||||
)
|
)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
@@ -640,16 +747,26 @@ def _mail_execution_profile(
|
|||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
jobs: list[CampaignJob],
|
jobs: list[CampaignJob],
|
||||||
) -> tuple[str | None, dict[str, Any]]:
|
) -> 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, {}
|
return None, {}
|
||||||
if not config.server.profile_capabilities.smtp_available:
|
if not config.server.profile_capabilities.smtp_available:
|
||||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created.")
|
raise CampaignPersistenceError(
|
||||||
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
"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:
|
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)
|
summary = profile_delivery_summary(session, version)
|
||||||
if not summary.get("smtp_transport_revision"):
|
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
|
return profile_id, summary
|
||||||
|
|
||||||
|
|
||||||
@@ -661,7 +778,9 @@ def _store_execution_snapshot(
|
|||||||
jobs: list[CampaignJob],
|
jobs: list[CampaignJob],
|
||||||
build_summary: dict[str, Any],
|
build_summary: dict[str, Any],
|
||||||
) -> None:
|
) -> 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(
|
snapshot, snapshot_hash = create_execution_snapshot(
|
||||||
version,
|
version,
|
||||||
mail_profile_id=profile_id,
|
mail_profile_id=profile_id,
|
||||||
@@ -689,20 +808,22 @@ def _store_job_issues(
|
|||||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
|
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
|
||||||
) -> None:
|
) -> None:
|
||||||
for job, message in job_build_pairs:
|
for job, message in job_build_pairs:
|
||||||
session.add_all([
|
session.add_all(
|
||||||
CampaignIssue(
|
[
|
||||||
tenant_id=tenant_id,
|
CampaignIssue(
|
||||||
campaign_id=campaign_id,
|
tenant_id=tenant_id,
|
||||||
campaign_version_id=version_id,
|
campaign_id=campaign_id,
|
||||||
job_id=job.id,
|
campaign_version_id=version_id,
|
||||||
severity=issue.severity,
|
job_id=job.id,
|
||||||
code=issue.code,
|
severity=issue.severity,
|
||||||
message=issue.message,
|
code=issue.code,
|
||||||
source=issue.source,
|
message=issue.message,
|
||||||
behavior=issue.behavior,
|
source=issue.source,
|
||||||
)
|
behavior=issue.behavior,
|
||||||
for issue in message.issues
|
)
|
||||||
])
|
for issue in message.issues
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_campaign_build_state(
|
def _apply_campaign_build_state(
|
||||||
@@ -734,13 +855,21 @@ def build_campaign_version(
|
|||||||
version, snapshot_path, config = load_version_config(session, version_id)
|
version, snapshot_path, config = load_version_config(session, version_id)
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_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")
|
_ensure_current_campaign_version(campaign, version, action="build")
|
||||||
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
||||||
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
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"):
|
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)
|
_ensure_version_validated_and_locked(version)
|
||||||
|
|
||||||
files = files_integration()
|
files = files_integration()
|
||||||
@@ -804,6 +933,7 @@ def build_campaign_version(
|
|||||||
version.build_summary = report_json
|
version.build_summary = report_json
|
||||||
editor_state = copy.deepcopy(version.editor_state or {})
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
editor_state.pop("review_send", None)
|
editor_state.pop("review_send", None)
|
||||||
|
editor_state.pop("approval_gate", None)
|
||||||
version.editor_state = editor_state
|
version.editor_state = editor_state
|
||||||
|
|
||||||
job_build_pairs, old_storage_keys = _replace_version_jobs(
|
job_build_pairs, old_storage_keys = _replace_version_jobs(
|
||||||
|
|||||||
@@ -52,22 +52,30 @@ class _PartialValidationCollector:
|
|||||||
section: str | None
|
section: str | None
|
||||||
issues: list[dict[str, Any]] = field(default_factory=list)
|
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:
|
if self.section is None or self.section == section:
|
||||||
self.issues.append({
|
self.issues.append(
|
||||||
"severity": severity,
|
{
|
||||||
"section": section,
|
"severity": severity,
|
||||||
"field": field,
|
"section": section,
|
||||||
"code": code,
|
"field": field,
|
||||||
"message": message,
|
"code": code,
|
||||||
})
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def result(self) -> dict[str, Any]:
|
def result(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"ok": not any(item["severity"] == "error" for item in self.issues),
|
"ok": not any(item["severity"] == "error" for item in self.issues),
|
||||||
"section": self.section,
|
"section": self.section,
|
||||||
"error_count": sum(1 for item in self.issues if item["severity"] == "error"),
|
"error_count": sum(
|
||||||
"warning_count": sum(1 for item in self.issues if item["severity"] == "warning"),
|
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"),
|
"info_count": sum(1 for item in self.issues if item["severity"] == "info"),
|
||||||
"issues": self.issues,
|
"issues": self.issues,
|
||||||
}
|
}
|
||||||
@@ -101,7 +109,9 @@ def campaign_version_user_lock_state(version: CampaignVersion) -> str | None:
|
|||||||
return 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.
|
"""Return a WebUI-friendly starter campaign JSON.
|
||||||
|
|
||||||
It is intentionally usable as an editable working copy. It contains the
|
It is intentionally usable as an editable working copy. It contains the
|
||||||
@@ -216,9 +226,15 @@ def create_minimal_campaign(
|
|||||||
current_step: str = "basics",
|
current_step: str = "basics",
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
) -> tuple[Campaign, CampaignVersion]:
|
) -> 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:
|
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(
|
campaign = Campaign(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -235,7 +251,9 @@ def create_minimal_campaign(
|
|||||||
version = CampaignVersion(
|
version = CampaignVersion(
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
version_number=1,
|
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",
|
schema_version="1.0",
|
||||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||||
current_flow=current_flow,
|
current_flow=current_flow,
|
||||||
@@ -264,13 +282,16 @@ def get_campaign_version_for_tenant(
|
|||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
version = session.get(CampaignVersion, version_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")
|
raise CampaignPersistenceError("Campaign version not found")
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
LOCKED_WORKFLOW_STATES = {
|
LOCKED_WORKFLOW_STATES = {
|
||||||
CampaignVersionWorkflowState.APPROVED.value,
|
CampaignVersionWorkflowState.APPROVED.value,
|
||||||
CampaignVersionWorkflowState.BUILT.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.
|
"""Require the campaign's single active working version.
|
||||||
|
|
||||||
Historical versions remain reviewable, but they never become writable in
|
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:
|
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:
|
if campaign_meta:
|
||||||
campaign.name = campaign_meta.get("name") or campaign.name
|
campaign.name = campaign_meta.get("name") or campaign.name
|
||||||
campaign.description = campaign_meta.get("description", campaign.description)
|
campaign.description = campaign_meta.get("description", campaign.description)
|
||||||
campaign.external_id = campaign_meta.get("id") or campaign.external_id
|
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):
|
if campaign_has_active_working_version(session, campaign):
|
||||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||||
current_number = current.version_number if current else "current"
|
current_number = current.version_number if current else "current"
|
||||||
@@ -375,7 +402,11 @@ def _fork_runtime_json(
|
|||||||
source_base_path=source_base_path,
|
source_base_path=source_base_path,
|
||||||
managed_files_available=files_integration().available,
|
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)
|
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||||
mail_integration().assert_campaign_mail_policy_allows_json(
|
mail_integration().assert_campaign_mail_policy_allows_json(
|
||||||
session,
|
session,
|
||||||
@@ -404,10 +435,16 @@ def _new_forked_campaign_version(
|
|||||||
version_number=_next_version_number(session, campaign.id),
|
version_number=_next_version_number(session, campaign.id),
|
||||||
raw_json=runtime_json,
|
raw_json=runtime_json,
|
||||||
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
|
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_filename=source_filename
|
||||||
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
|
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,
|
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,
|
current_step=current_step if current_step is not None else source.current_step,
|
||||||
is_complete=False,
|
is_complete=False,
|
||||||
editor_state=(
|
editor_state=(
|
||||||
@@ -461,7 +498,9 @@ def fork_campaign_version_for_edit(
|
|||||||
version is permanently user-locked or delivery-final.
|
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)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
|
|
||||||
_assert_fork_source_allowed(session, campaign=campaign, source=source)
|
_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,
|
source_base_path=source_base_path,
|
||||||
autosave=autosave,
|
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
|
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:
|
if version.locked_at is None:
|
||||||
version.locked_at = datetime.now(UTC)
|
version.locked_at = datetime.now(UTC)
|
||||||
version.locked_by_user_id = user_id
|
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:
|
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
|
||||||
"""Return True when the version was successfully validated and locked as a review snapshot."""
|
"""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)
|
return bool(version.locked_at and validation.get("ok") is True)
|
||||||
|
|
||||||
|
|
||||||
@@ -554,28 +601,40 @@ def unlock_validated_campaign_version(
|
|||||||
be copied instead.
|
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)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
ensure_current_working_version(campaign, version, action="unlock")
|
ensure_current_working_version(campaign, version, action="unlock")
|
||||||
|
|
||||||
if is_temporary_user_locked_version(version):
|
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):
|
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):
|
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.
|
# A version with sent jobs is final even if workflow_state was not updated for some reason.
|
||||||
sent_jobs = (
|
sent_jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(
|
.filter(
|
||||||
CampaignJob.campaign_version_id == version.id,
|
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()
|
.count()
|
||||||
)
|
)
|
||||||
if sent_jobs:
|
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_at = None
|
||||||
version.locked_by_user_id = None
|
version.locked_by_user_id = None
|
||||||
@@ -584,12 +643,17 @@ def unlock_validated_campaign_version(
|
|||||||
clear_execution_snapshot(version)
|
clear_execution_snapshot(version)
|
||||||
editor_state = copy.deepcopy(version.editor_state or {})
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
editor_state.pop("review_send", None)
|
editor_state.pop("review_send", None)
|
||||||
|
editor_state.pop("approval_gate", None)
|
||||||
version.editor_state = editor_state
|
version.editor_state = editor_state
|
||||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||||
version.is_complete = False
|
version.is_complete = False
|
||||||
|
|
||||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
session.query(CampaignIssue).filter(
|
||||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
|
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.current_version_id = version.id
|
||||||
campaign.status = CampaignStatus.DRAFT.value
|
campaign.status = CampaignStatus.DRAFT.value
|
||||||
@@ -601,6 +665,7 @@ def unlock_validated_campaign_version(
|
|||||||
session.flush()
|
session.flush()
|
||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
def _assert_update_paths_safe(
|
def _assert_update_paths_safe(
|
||||||
raw_json: dict[str, Any] | None,
|
raw_json: dict[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
@@ -627,8 +692,14 @@ def _updated_runtime_json(
|
|||||||
source_base_path: str | None,
|
source_base_path: str | None,
|
||||||
migrate_legacy_mail_settings: bool,
|
migrate_legacy_mail_settings: bool,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
runtime_json = (
|
||||||
requires_migration = bool(campaign_mail_profile_boundary_violations(version.raw_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:
|
if requires_migration and not migrate_legacy_mail_settings:
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
"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)
|
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.validation_summary = None
|
||||||
version.build_summary = None
|
version.build_summary = None
|
||||||
clear_execution_snapshot(version)
|
clear_execution_snapshot(version)
|
||||||
@@ -688,7 +761,9 @@ def _invalidate_version_content(session: Session, *, campaign: Campaign, version
|
|||||||
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
|
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
|
||||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||||
campaign.status = CampaignStatus.DRAFT.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(
|
def _persist_updated_version(
|
||||||
@@ -726,22 +801,23 @@ def update_campaign_version(
|
|||||||
expected_revision: int | None = None,
|
expected_revision: int | None = None,
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
_assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path)
|
_assert_update_paths_safe(
|
||||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
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)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
ensure_current_working_version(campaign, version, action="edit")
|
ensure_current_working_version(campaign, version, action="edit")
|
||||||
if (
|
if expected_revision is not None and version.edit_revision != int(
|
||||||
expected_revision is not None
|
expected_revision
|
||||||
and version.edit_revision != int(expected_revision)
|
|
||||||
):
|
):
|
||||||
raise RevisionConflictError(
|
raise RevisionConflictError(
|
||||||
resource_type="campaign_version",
|
resource_type="campaign_version",
|
||||||
resource_id=version.id,
|
resource_id=version.id,
|
||||||
current_revision=version.edit_revision,
|
current_revision=version.edit_revision,
|
||||||
submitted_base_revision=int(expected_revision),
|
submitted_base_revision=int(expected_revision),
|
||||||
refresh_path=(
|
refresh_path=(f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"),
|
||||||
f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"
|
|
||||||
),
|
|
||||||
current_etag=strong_resource_etag(
|
current_etag=strong_resource_etag(
|
||||||
"campaign_version",
|
"campaign_version",
|
||||||
version.id,
|
version.id,
|
||||||
@@ -765,7 +841,9 @@ def update_campaign_version(
|
|||||||
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
|
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
|
||||||
)
|
)
|
||||||
version.raw_json = runtime_json
|
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_campaign_metadata(campaign, runtime_json)
|
||||||
|
|
||||||
_apply_version_field_updates(
|
_apply_version_field_updates(
|
||||||
@@ -810,9 +888,7 @@ def update_campaign_version(
|
|||||||
if expected_revision is not None
|
if expected_revision is not None
|
||||||
else version.edit_revision
|
else version.edit_revision
|
||||||
),
|
),
|
||||||
refresh_path=(
|
refresh_path=(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"),
|
||||||
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"
|
|
||||||
),
|
|
||||||
current_etag=strong_resource_etag(
|
current_etag=strong_resource_etag(
|
||||||
"campaign_version",
|
"campaign_version",
|
||||||
version_id,
|
version_id,
|
||||||
@@ -850,9 +926,15 @@ def update_campaign_review_state(
|
|||||||
campaign = _require_campaign(session, campaign_id)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
ensure_current_working_version(campaign, version, action="record review state for")
|
ensure_current_working_version(campaign, version, action="record review state for")
|
||||||
if is_version_final_locked(version):
|
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)
|
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]] = []
|
normalized_decisions: list[dict[str, Any]] = []
|
||||||
if inspection_complete:
|
if inspection_complete:
|
||||||
normalized_reviewed, normalized_decisions = _complete_campaign_review(
|
normalized_reviewed, normalized_decisions = _complete_campaign_review(
|
||||||
@@ -880,10 +962,14 @@ def update_campaign_review_state(
|
|||||||
|
|
||||||
|
|
||||||
def _campaign_review_build_token(version: CampaignVersion) -> str:
|
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:
|
if not build_summary:
|
||||||
raise CampaignPersistenceError("Build messages before recording review state.")
|
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:
|
if build_token:
|
||||||
return build_token
|
return build_token
|
||||||
build_token = uuid4().hex
|
build_token = uuid4().hex
|
||||||
@@ -908,13 +994,20 @@ def _complete_campaign_review(
|
|||||||
.order_by(CampaignJob.entry_index.asc())
|
.order_by(CampaignJob.entry_index.asc())
|
||||||
.all()
|
.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:
|
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))
|
missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys))
|
||||||
if missing:
|
if missing:
|
||||||
raise CampaignPersistenceError(
|
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(
|
decisions = _normalize_review_issue_decisions(
|
||||||
jobs,
|
jobs,
|
||||||
@@ -924,9 +1017,7 @@ def _complete_campaign_review(
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
list(
|
list(
|
||||||
dict.fromkeys(
|
dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)])
|
||||||
[*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
decisions,
|
decisions,
|
||||||
)
|
)
|
||||||
@@ -957,9 +1048,7 @@ def _normalize_review_issue_decisions(
|
|||||||
"Only one review decision may be recorded per built message."
|
"Only one review decision may be recorded per built message."
|
||||||
)
|
)
|
||||||
if str(item.get("decision") or "accept") != "accept":
|
if str(item.get("decision") or "accept") != "accept":
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError("Unsupported campaign review decision.")
|
||||||
"Unsupported campaign review decision."
|
|
||||||
)
|
|
||||||
requested_by_job[job_id] = item
|
requested_by_job[job_id] = item
|
||||||
|
|
||||||
timestamp = (decided_at or datetime.now(UTC)).isoformat()
|
timestamp = (decided_at or datetime.now(UTC)).isoformat()
|
||||||
@@ -990,9 +1079,7 @@ def _normalize_review_issue_decisions(
|
|||||||
"Attachment exception decisions require a reason."
|
"Attachment exception decisions require a reason."
|
||||||
)
|
)
|
||||||
evidence_issues = reviewable_issues or [
|
evidence_issues = reviewable_issues or [
|
||||||
issue
|
issue for issue in (job.issues_snapshot or []) if isinstance(issue, dict)
|
||||||
for issue in (job.issues_snapshot or [])
|
|
||||||
if isinstance(issue, dict)
|
|
||||||
]
|
]
|
||||||
issue_payload = [
|
issue_payload = [
|
||||||
{
|
{
|
||||||
@@ -1092,13 +1179,17 @@ def lock_campaign_version_temporarily(
|
|||||||
campaign = _require_campaign(session, campaign_id)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
ensure_current_working_version(campaign, version, action="lock")
|
ensure_current_working_version(campaign, version, action="lock")
|
||||||
if is_version_final_locked(version):
|
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):
|
if is_permanent_user_locked_version(version):
|
||||||
raise LockedCampaignVersionError("This version is already permanently locked.")
|
raise LockedCampaignVersionError("This version is already permanently locked.")
|
||||||
if is_temporary_user_locked_version(version):
|
if is_temporary_user_locked_version(version):
|
||||||
return version
|
return version
|
||||||
if version.locked_at:
|
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_lock_state = USER_LOCK_TEMPORARY
|
||||||
version.user_locked_at = datetime.now(UTC)
|
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")
|
ensure_current_working_version(campaign, version, action="unlock")
|
||||||
state = campaign_version_user_lock_state(version)
|
state = campaign_version_user_lock_state(version)
|
||||||
if state == USER_LOCK_PERMANENT:
|
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:
|
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):
|
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_lock_state = None
|
||||||
version.user_locked_at = None
|
version.user_locked_at = None
|
||||||
@@ -1172,7 +1269,9 @@ def permanently_lock_campaign_version(
|
|||||||
campaign = _require_campaign(session, campaign_id)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
ensure_current_working_version(campaign, version, action="lock permanently")
|
ensure_current_working_version(campaign, version, action="lock permanently")
|
||||||
if is_version_final_locked(version):
|
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):
|
if is_permanent_user_locked_version(version):
|
||||||
return 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.
|
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
||||||
|
|
||||||
This is intentionally less strict than campaign.schema.json validation. It
|
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 {}
|
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"):
|
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"):
|
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 {}
|
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
|
||||||
if not sender.get("email"):
|
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)
|
violations = campaign_mail_profile_boundary_violations(raw_json)
|
||||||
if violations:
|
if violations:
|
||||||
collector.issue(
|
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_inline = bool(entries.get("inline"))
|
||||||
has_source = isinstance(entries.get("source"), dict)
|
has_source = isinstance(entries.get("source"), dict)
|
||||||
if not has_inline and not has_source:
|
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):
|
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:
|
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"))
|
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:
|
def _validate_partial_template(
|
||||||
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
|
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"):
|
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)
|
body_state = _partial_template_body_state(template, source_template)
|
||||||
_validate_partial_template_body(collector, body_state)
|
_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 {
|
return {
|
||||||
"mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None,
|
"mode": template.get("body_mode")
|
||||||
"has_text": bool(template.get("text")) or bool(source_template.get("text_path")),
|
if template.get("body_mode") in {"text", "html", "both"}
|
||||||
"has_html": bool(template.get("html")) or bool(source_template.get("html_path")),
|
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),
|
"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"]
|
mode = state["mode"]
|
||||||
has_text = bool(state["has_text"])
|
has_text = bool(state["has_text"])
|
||||||
has_html = bool(state["has_html"])
|
has_html = bool(state["has_html"])
|
||||||
if mode == "text" and not has_text:
|
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:
|
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":
|
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"]:
|
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:
|
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:
|
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:
|
def _validate_partial_attachments(
|
||||||
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
|
collector: _PartialValidationCollector, attachments: dict[str, Any]
|
||||||
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
|
) -> 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"):
|
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:
|
def _validate_partial_delivery(
|
||||||
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
|
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")
|
messages_per_minute = rate_limit.get("messages_per_minute")
|
||||||
if messages_per_minute is None:
|
if messages_per_minute is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
if int(messages_per_minute) < 1:
|
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):
|
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,
|
"base_revision": payload.base_revision,
|
||||||
"result_revision": version.edit_revision,
|
"result_revision": version.edit_revision,
|
||||||
"reconciliation_kind": payload.reconciliation_kind,
|
"reconciliation_kind": payload.reconciliation_kind,
|
||||||
"resolved_conflict_path_count": len(
|
"resolved_conflict_path_count": len(payload.resolved_conflict_paths),
|
||||||
payload.resolved_conflict_paths
|
|
||||||
),
|
|
||||||
"resolved_conflict_sections": sorted(
|
"resolved_conflict_sections": sorted(
|
||||||
{
|
{
|
||||||
path.strip("/").split("/", 1)[0][:80]
|
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 = copy.deepcopy(version.editor_state or {})
|
||||||
editor_state.pop("review_send", None)
|
editor_state.pop("review_send", None)
|
||||||
|
editor_state.pop("approval_gate", None)
|
||||||
version.editor_state = editor_state
|
version.editor_state = editor_state
|
||||||
|
|
||||||
session.query(CampaignIssue).filter(
|
session.query(CampaignIssue).filter(
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
SendCampaignNowRequest,
|
SendCampaignNowRequest,
|
||||||
SendCampaignNowResponse,
|
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.auth import ApiPrincipal, require_any_scope, require_scope
|
||||||
from govoplan_core.audit.logging import audit_from_principal
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
from govoplan_campaign.backend.db.models import (
|
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)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
_require_permission(principal, "campaigns:recipient:read")
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
try:
|
try:
|
||||||
|
options = synchronous_send_options(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
version = _get_version_for_tenant(
|
||||||
|
session, str(options["version_id"]), principal.tenant_id
|
||||||
|
)
|
||||||
return CampaignDeliveryOptionsResponse(
|
return CampaignDeliveryOptionsResponse(
|
||||||
**synchronous_send_options(
|
**options,
|
||||||
session,
|
|
||||||
tenant_id=principal.tenant_id,
|
|
||||||
campaign_id=campaign_id,
|
|
||||||
version_id=version_id,
|
|
||||||
),
|
|
||||||
postbox_available=postbox_integration().available,
|
postbox_available=postbox_integration().available,
|
||||||
|
approval_gate=campaign_approval_status(
|
||||||
|
session, tenant_id=principal.tenant_id, version=version
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except QueueingError as exc:
|
except QueueingError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -107,6 +120,77 @@ def campaign_delivery_options(
|
|||||||
) from exc
|
) 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)
|
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
|
||||||
def queue_campaign(
|
def queue_campaign(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated, Any, Literal
|
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_core.api.v1.schemas import DeltaDeletedItem
|
||||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
@@ -25,8 +33,6 @@ class CampaignCreateRequest(BaseModel):
|
|||||||
source_base_path: str | None = None
|
source_base_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class CampaignUpdateRequest(BaseModel):
|
class CampaignUpdateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -64,7 +70,9 @@ class CampaignVersionUpdateRequest(BaseModel):
|
|||||||
|
|
||||||
@field_validator("editor_state")
|
@field_validator("editor_state")
|
||||||
@classmethod
|
@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
|
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
|
build_summary: dict[str, Any] | None = None
|
||||||
execution_snapshot_hash: str | None = None
|
execution_snapshot_hash: str | None = None
|
||||||
execution_snapshot_at: datetime | 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
|
delivery_mode_selected_at: datetime | None = None
|
||||||
|
|
||||||
@field_validator("editor_state", mode="before")
|
@field_validator("editor_state", mode="before")
|
||||||
@classmethod
|
@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(
|
return public_campaign_editor_state(
|
||||||
value,
|
value,
|
||||||
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
|
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
|
||||||
@@ -291,7 +303,9 @@ class RecipientImportColumnMappingPayload(BaseModel):
|
|||||||
column_index: int = Field(ge=0, alias="columnIndex")
|
column_index: int = Field(ge=0, alias="columnIndex")
|
||||||
kind: RecipientImportColumnKind
|
kind: RecipientImportColumnKind
|
||||||
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
|
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):
|
class RecipientImportMappingProfilePayload(BaseModel):
|
||||||
@@ -300,14 +314,22 @@ class RecipientImportMappingProfilePayload(BaseModel):
|
|||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
column_count: int = Field(ge=0, le=500, alias="columnCount")
|
column_count: int = Field(ge=0, le=500, alias="columnCount")
|
||||||
headers: list[str] = Field(default_factory=list, max_length=500)
|
headers: list[str] = Field(default_factory=list, max_length=500)
|
||||||
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
|
normalized_headers: list[str] = Field(
|
||||||
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
|
default_factory=list, max_length=500, alias="normalizedHeaders"
|
||||||
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
|
)
|
||||||
|
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"]
|
delimiter: Literal[",", ";", "\t"]
|
||||||
header_rows: int = Field(ge=0, le=10, alias="headerRows")
|
header_rows: int = Field(ge=0, le=10, alias="headerRows")
|
||||||
quoted: bool = True
|
quoted: bool = True
|
||||||
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
|
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")
|
@model_validator(mode="after")
|
||||||
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
|
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
|
||||||
@@ -583,6 +605,7 @@ class CampaignDeliveryOptionsResponse(BaseModel):
|
|||||||
version_id: str
|
version_id: str
|
||||||
worker_queue_available: bool
|
worker_queue_available: bool
|
||||||
postbox_available: bool = False
|
postbox_available: bool = False
|
||||||
|
approval_gate: dict[str, Any] = Field(default_factory=dict)
|
||||||
synchronous_send: 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:
|
if len(recipient) > 320:
|
||||||
raise ValueError("report recipient addresses must be at most 320 characters")
|
raise ValueError("report recipient addresses must be at most 320 characters")
|
||||||
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
|
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:
|
if recipient.count("@") != 1:
|
||||||
raise ValueError("report recipients must be email addresses")
|
raise ValueError("report recipients must be email addresses")
|
||||||
local, domain = recipient.split("@", 1)
|
local, domain = recipient.split("@", 1)
|
||||||
invalid_local = not local or local.startswith(".") or local.endswith(".") or ".." in local
|
invalid_local = (
|
||||||
invalid_address = (
|
not local or local.startswith(".") or local.endswith(".") or ".." in local
|
||||||
any(character.isspace() for character in recipient)
|
)
|
||||||
or any(character in ',;:<>[]()\\"' for character in recipient)
|
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):
|
if invalid_local or invalid_address or not _valid_report_email_domain(domain):
|
||||||
raise ValueError("report recipients must be email addresses")
|
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)
|
actual = _operation_keys(router)
|
||||||
|
|
||||||
assert actual == expected
|
assert actual == expected
|
||||||
assert len(actual) == 64
|
assert len(actual) == 65
|
||||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
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")),
|
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||||
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
||||||
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
||||||
|
(
|
||||||
|
delivery_router,
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/campaigns/{campaign_id}/versions/{version_id}/approval-request",
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
attachments_router,
|
attachments_router,
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -406,6 +406,16 @@ export type CampaignDeliveryOptions = {
|
|||||||
version_id: string;
|
version_id: string;
|
||||||
worker_queue_available: boolean;
|
worker_queue_available: boolean;
|
||||||
postbox_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: {
|
synchronous_send: {
|
||||||
allowed?: boolean;
|
allowed?: boolean;
|
||||||
reason?: string | null;
|
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 = {
|
export type CampaignSendNowPayload = {
|
||||||
version_id?: string | null;
|
version_id?: string | null;
|
||||||
include_warnings?: boolean;
|
include_warnings?: boolean;
|
||||||
@@ -1148,6 +1178,19 @@ versionId?: string | null)
|
|||||||
return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
|
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(
|
export async function sendCampaignNow(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
pauseCampaign,
|
pauseCampaign,
|
||||||
previewCampaignAttachments,
|
previewCampaignAttachments,
|
||||||
queueCampaign,
|
queueCampaign,
|
||||||
|
requestCampaignApproval,
|
||||||
resumeCampaign,
|
resumeCampaign,
|
||||||
retryCampaignJobs,
|
retryCampaignJobs,
|
||||||
sendCampaignJob,
|
sendCampaignJob,
|
||||||
@@ -35,7 +36,7 @@ import {
|
|||||||
type CampaignSummary } from
|
type CampaignSummary } from
|
||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
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 { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
@@ -106,7 +107,7 @@ import {
|
|||||||
synchronousSendReason
|
synchronousSendReason
|
||||||
} from "./review/reviewPresentation";
|
} 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"];
|
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 canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue");
|
||||||
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
|
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
|
||||||
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
|
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
|
||||||
|
const canRequestApproval = hasScope(auth, "campaigns:campaign:review");
|
||||||
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
|
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
|
||||||
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
|
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
|
||||||
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
||||||
@@ -186,6 +188,12 @@ export default function ReviewSendPage({
|
|||||||
const [attachmentLinking, setAttachmentLinking] = useState(false);
|
const [attachmentLinking, setAttachmentLinking] = useState(false);
|
||||||
const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false);
|
const [attachmentLockConfirmOpen, setAttachmentLockConfirmOpen] = useState(false);
|
||||||
const [reviewConfirmOpen, setReviewConfirmOpen] = 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 [dryRun, setDryRun] = useState(false);
|
||||||
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
||||||
const [queueConfirmOpen, setQueueConfirmOpen] = useState(false);
|
const [queueConfirmOpen, setQueueConfirmOpen] = useState(false);
|
||||||
@@ -215,6 +223,7 @@ export default function ReviewSendPage({
|
|||||||
setAttachmentPreview(null);
|
setAttachmentPreview(null);
|
||||||
setAttachmentPreviewError("");
|
setAttachmentPreviewError("");
|
||||||
setAttachmentLockConfirmOpen(false);
|
setAttachmentLockConfirmOpen(false);
|
||||||
|
setApprovalDialogOpen(false);
|
||||||
setSendResult(null);
|
setSendResult(null);
|
||||||
setQueueResult(null);
|
setQueueResult(null);
|
||||||
setImapAppendResult(null);
|
setImapAppendResult(null);
|
||||||
@@ -343,6 +352,11 @@ export default function ReviewSendPage({
|
|||||||
const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]);
|
const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]);
|
||||||
const synchronousSendAllowed = synchronousSendOption.allowed === true;
|
const synchronousSendAllowed = synchronousSendOption.allowed === true;
|
||||||
const workerQueueAvailable = deliveryOptions?.worker_queue_available === 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 persistedDeliveryMode = version?.delivery_mode ?? null;
|
||||||
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
|
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
|
||||||
const selectedDryRun = dryRun && !directQueuedSendAllowed;
|
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 mockStateDisplayLabel = !mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : !mockVerificationRequired ? "i18n:govoplan-campaign.optional.0c6c4102" : stateLabel(mockState);
|
||||||
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || mockPartial || downstreamDeliveryActivity);
|
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || mockPartial || downstreamDeliveryActivity);
|
||||||
const singleMessageSendWorkflowBlocked = ["archived", "cancelled"].includes(currentWorkflowState);
|
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 ?
|
const sendLockReason = !inspectionSatisfied ?
|
||||||
"i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" :
|
"i18n:govoplan-campaign.build_and_complete_the_required_message_review_f.c0cd00fe" :
|
||||||
mockWorkflowRequired ?
|
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() {
|
async function runSendNow() {
|
||||||
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return;
|
|
||||||
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
|
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
|
||||||
|
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted || !effectiveDryRun && approvalGateBlocksLiveDelivery) return;
|
||||||
setBusy("send");
|
setBusy("send");
|
||||||
setMessage(effectiveDryRun ?
|
setMessage(effectiveDryRun ?
|
||||||
"i18n:govoplan-campaign.checking_the_built_queue_without_sending.e15bb876" :
|
"i18n:govoplan-campaign.checking_the_built_queue_without_sending.e15bb876" :
|
||||||
@@ -881,7 +929,7 @@ export default function ReviewSendPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runQueueForWorkers() {
|
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");
|
setBusy("queue");
|
||||||
setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
|
setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
|
||||||
setQueueResult(null);
|
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.",
|
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"
|
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",
|
label: "i18n:govoplan-campaign.delivery_mode.109ed9d1",
|
||||||
detail: deliveryOptionsLoading ?
|
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><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
|
<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">
|
<div className="review-flow-fact-grid">
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
|
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
|
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
|
||||||
@@ -1549,12 +1620,12 @@ export default function ReviewSendPage({
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => setQueueConfirmOpen(true)}
|
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"}
|
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
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" ?
|
{busy === "send" ?
|
||||||
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
|
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
|
<ConfirmDialog
|
||||||
open={attachmentLockConfirmOpen}
|
open={attachmentLockConfirmOpen}
|
||||||
title="i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653"
|
title="i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653"
|
||||||
|
|||||||
Reference in New Issue
Block a user