Add institutional provenance and approval gates
This commit is contained in:
@@ -52,22 +52,30 @@ class _PartialValidationCollector:
|
||||
section: str | None
|
||||
issues: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def issue(self, severity: str, section: str, field: str, code: str, message: str) -> None:
|
||||
def issue(
|
||||
self, severity: str, section: str, field: str, code: str, message: str
|
||||
) -> None:
|
||||
if self.section is None or self.section == section:
|
||||
self.issues.append({
|
||||
"severity": severity,
|
||||
"section": section,
|
||||
"field": field,
|
||||
"code": code,
|
||||
"message": message,
|
||||
})
|
||||
self.issues.append(
|
||||
{
|
||||
"severity": severity,
|
||||
"section": section,
|
||||
"field": field,
|
||||
"code": code,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
def result(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": not any(item["severity"] == "error" for item in self.issues),
|
||||
"section": self.section,
|
||||
"error_count": sum(1 for item in self.issues if item["severity"] == "error"),
|
||||
"warning_count": sum(1 for item in self.issues if item["severity"] == "warning"),
|
||||
"error_count": sum(
|
||||
1 for item in self.issues if item["severity"] == "error"
|
||||
),
|
||||
"warning_count": sum(
|
||||
1 for item in self.issues if item["severity"] == "warning"
|
||||
),
|
||||
"info_count": sum(1 for item in self.issues if item["severity"] == "info"),
|
||||
"issues": self.issues,
|
||||
}
|
||||
@@ -101,7 +109,9 @@ def campaign_version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
||||
def minimal_campaign_json(
|
||||
*, external_id: str, name: str, description: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Return a WebUI-friendly starter campaign JSON.
|
||||
|
||||
It is intentionally usable as an editable working copy. It contains the
|
||||
@@ -216,9 +226,15 @@ def create_minimal_campaign(
|
||||
current_step: str = "basics",
|
||||
commit: bool = True,
|
||||
) -> tuple[Campaign, CampaignVersion]:
|
||||
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
|
||||
existing = (
|
||||
session.query(Campaign)
|
||||
.filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing:
|
||||
raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant")
|
||||
raise CampaignPersistenceError(
|
||||
f"Campaign with id '{external_id}' already exists for this tenant"
|
||||
)
|
||||
|
||||
campaign = Campaign(
|
||||
tenant_id=tenant_id,
|
||||
@@ -235,7 +251,9 @@ def create_minimal_campaign(
|
||||
version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description),
|
||||
raw_json=minimal_campaign_json(
|
||||
external_id=external_id, name=name, description=description
|
||||
),
|
||||
schema_version="1.0",
|
||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||
current_flow=current_flow,
|
||||
@@ -264,13 +282,16 @@ def get_campaign_version_for_tenant(
|
||||
) -> CampaignVersion:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id:
|
||||
if (
|
||||
not campaign
|
||||
or campaign.tenant_id != tenant_id
|
||||
or not version
|
||||
or version.campaign_id != campaign.id
|
||||
):
|
||||
raise CampaignPersistenceError("Campaign version not found")
|
||||
return version
|
||||
|
||||
|
||||
|
||||
|
||||
LOCKED_WORKFLOW_STATES = {
|
||||
CampaignVersionWorkflowState.APPROVED.value,
|
||||
CampaignVersionWorkflowState.BUILT.value,
|
||||
@@ -295,7 +316,9 @@ def is_version_locked(version: CampaignVersion) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def ensure_current_working_version(campaign: Campaign, version: CampaignVersion, *, action: str = "modify") -> None:
|
||||
def ensure_current_working_version(
|
||||
campaign: Campaign, version: CampaignVersion, *, action: str = "modify"
|
||||
) -> None:
|
||||
"""Require the campaign's single active working version.
|
||||
|
||||
Historical versions remain reviewable, but they never become writable in
|
||||
@@ -326,14 +349,18 @@ def campaign_has_active_working_version(session: Session, campaign: Campaign) ->
|
||||
|
||||
|
||||
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
|
||||
campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||
campaign_meta = (
|
||||
raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||
)
|
||||
if campaign_meta:
|
||||
campaign.name = campaign_meta.get("name") or campaign.name
|
||||
campaign.description = campaign_meta.get("description", campaign.description)
|
||||
campaign.external_id = campaign_meta.get("id") or campaign.external_id
|
||||
|
||||
|
||||
def _assert_fork_source_allowed(session: Session, *, campaign: Campaign, source: CampaignVersion) -> None:
|
||||
def _assert_fork_source_allowed(
|
||||
session: Session, *, campaign: Campaign, source: CampaignVersion
|
||||
) -> None:
|
||||
if campaign_has_active_working_version(session, campaign):
|
||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||
current_number = current.version_number if current else "current"
|
||||
@@ -375,7 +402,11 @@ def _fork_runtime_json(
|
||||
source_base_path=source_base_path,
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||
runtime_json = (
|
||||
normalize_campaign_paths(base_json, source_base_path)
|
||||
if source_base_path
|
||||
else copy.deepcopy(base_json)
|
||||
)
|
||||
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
@@ -404,10 +435,16 @@ def _new_forked_campaign_version(
|
||||
version_number=_next_version_number(session, campaign.id),
|
||||
raw_json=runtime_json,
|
||||
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
|
||||
source_filename=source_filename if source_filename is not None else source.source_filename,
|
||||
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
|
||||
source_filename=source_filename
|
||||
if source_filename is not None
|
||||
else source.source_filename,
|
||||
source_base_path=source_base_path
|
||||
if source_base_path is not None
|
||||
else source.source_base_path,
|
||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
|
||||
current_flow=current_flow
|
||||
if current_flow is not None
|
||||
else (source.current_flow or CampaignVersionFlow.MANUAL.value),
|
||||
current_step=current_step if current_step is not None else source.current_step,
|
||||
is_complete=False,
|
||||
editor_state=(
|
||||
@@ -461,7 +498,9 @@ def fork_campaign_version_for_edit(
|
||||
version is permanently user-locked or delivery-final.
|
||||
"""
|
||||
|
||||
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
source = get_campaign_version_for_tenant(
|
||||
session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id
|
||||
)
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
|
||||
_assert_fork_source_allowed(session, campaign=campaign, source=source)
|
||||
@@ -487,11 +526,15 @@ def fork_campaign_version_for_edit(
|
||||
source_base_path=source_base_path,
|
||||
autosave=autosave,
|
||||
)
|
||||
_persist_forked_version(session, campaign=campaign, version=new_version, commit=commit)
|
||||
_persist_forked_version(
|
||||
session, campaign=campaign, version=new_version, commit=commit
|
||||
)
|
||||
return new_version
|
||||
|
||||
|
||||
def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None:
|
||||
def lock_validated_version(
|
||||
version: CampaignVersion, *, user_id: str | None = None
|
||||
) -> None:
|
||||
if version.locked_at is None:
|
||||
version.locked_at = datetime.now(UTC)
|
||||
version.locked_by_user_id = user_id
|
||||
@@ -535,7 +578,11 @@ def is_audit_safe_version(version: CampaignVersion) -> bool:
|
||||
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
|
||||
"""Return True when the version was successfully validated and locked as a review snapshot."""
|
||||
|
||||
validation = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
||||
validation = (
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else {}
|
||||
)
|
||||
return bool(version.locked_at and validation.get("ok") is True)
|
||||
|
||||
|
||||
@@ -554,28 +601,40 @@ def unlock_validated_campaign_version(
|
||||
be copied instead.
|
||||
"""
|
||||
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
version = get_campaign_version_for_tenant(
|
||||
session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id
|
||||
)
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
ensure_current_working_version(campaign, version, action="unlock")
|
||||
|
||||
if is_temporary_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.")
|
||||
raise LockedCampaignVersionError(
|
||||
"This version has a temporary user lock. Remove that lock before unlocking validation."
|
||||
)
|
||||
if is_permanent_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.")
|
||||
raise LockedCampaignVersionError(
|
||||
"This version is permanently locked and cannot be unlocked. Create an editable copy instead."
|
||||
)
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.")
|
||||
raise LockedCampaignVersionError(
|
||||
"This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead."
|
||||
)
|
||||
|
||||
# A version with sent jobs is final even if workflow_state was not updated for some reason.
|
||||
sent_jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
CampaignJob.send_status.in_([JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]),
|
||||
CampaignJob.send_status.in_(
|
||||
[JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]
|
||||
),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if sent_jobs:
|
||||
raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.")
|
||||
raise LockedCampaignVersionError(
|
||||
"This version has sent messages and cannot be unlocked. Create an editable copy instead."
|
||||
)
|
||||
|
||||
version.locked_at = None
|
||||
version.locked_by_user_id = None
|
||||
@@ -584,12 +643,17 @@ def unlock_validated_campaign_version(
|
||||
clear_execution_snapshot(version)
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
editor_state.pop("approval_gate", None)
|
||||
version.editor_state = editor_state
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
version.is_complete = False
|
||||
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
session.query(CampaignIssue).filter(
|
||||
CampaignIssue.campaign_version_id == version.id
|
||||
).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(
|
||||
CampaignJob.campaign_version_id == version.id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
campaign.current_version_id = version.id
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
@@ -601,6 +665,7 @@ def unlock_validated_campaign_version(
|
||||
session.flush()
|
||||
return version
|
||||
|
||||
|
||||
def _assert_update_paths_safe(
|
||||
raw_json: dict[str, Any] | None,
|
||||
*,
|
||||
@@ -627,8 +692,14 @@ def _updated_runtime_json(
|
||||
source_base_path: str | None,
|
||||
migrate_legacy_mail_settings: bool,
|
||||
) -> dict[str, Any]:
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||
requires_migration = bool(campaign_mail_profile_boundary_violations(version.raw_json))
|
||||
runtime_json = (
|
||||
normalize_campaign_paths(raw_json, source_base_path)
|
||||
if source_base_path
|
||||
else copy.deepcopy(raw_json)
|
||||
)
|
||||
requires_migration = bool(
|
||||
campaign_mail_profile_boundary_violations(version.raw_json)
|
||||
)
|
||||
if requires_migration and not migrate_legacy_mail_settings:
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
||||
@@ -679,7 +750,9 @@ def _apply_version_field_updates(
|
||||
version.autosaved_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _invalidate_version_content(session: Session, *, campaign: Campaign, version: CampaignVersion) -> None:
|
||||
def _invalidate_version_content(
|
||||
session: Session, *, campaign: Campaign, version: CampaignVersion
|
||||
) -> None:
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
clear_execution_snapshot(version)
|
||||
@@ -688,7 +761,9 @@ def _invalidate_version_content(session: Session, *, campaign: Campaign, version
|
||||
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
session.query(CampaignIssue).filter(
|
||||
CampaignIssue.campaign_version_id == version.id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
|
||||
def _persist_updated_version(
|
||||
@@ -726,22 +801,23 @@ def update_campaign_version(
|
||||
expected_revision: int | None = None,
|
||||
commit: bool = True,
|
||||
) -> CampaignVersion:
|
||||
_assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path)
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
_assert_update_paths_safe(
|
||||
raw_json, source_filename=source_filename, source_base_path=source_base_path
|
||||
)
|
||||
version = get_campaign_version_for_tenant(
|
||||
session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id
|
||||
)
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
ensure_current_working_version(campaign, version, action="edit")
|
||||
if (
|
||||
expected_revision is not None
|
||||
and version.edit_revision != int(expected_revision)
|
||||
if expected_revision is not None and version.edit_revision != int(
|
||||
expected_revision
|
||||
):
|
||||
raise RevisionConflictError(
|
||||
resource_type="campaign_version",
|
||||
resource_id=version.id,
|
||||
current_revision=version.edit_revision,
|
||||
submitted_base_revision=int(expected_revision),
|
||||
refresh_path=(
|
||||
f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"
|
||||
),
|
||||
refresh_path=(f"/api/v1/campaigns/{campaign.id}/versions/{version.id}"),
|
||||
current_etag=strong_resource_etag(
|
||||
"campaign_version",
|
||||
version.id,
|
||||
@@ -765,7 +841,9 @@ def update_campaign_version(
|
||||
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
|
||||
)
|
||||
version.raw_json = runtime_json
|
||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||
version.schema_version = str(
|
||||
runtime_json.get("version", version.schema_version or "1.0")
|
||||
)
|
||||
_apply_campaign_metadata(campaign, runtime_json)
|
||||
|
||||
_apply_version_field_updates(
|
||||
@@ -810,9 +888,7 @@ def update_campaign_version(
|
||||
if expected_revision is not None
|
||||
else version.edit_revision
|
||||
),
|
||||
refresh_path=(
|
||||
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"
|
||||
),
|
||||
refresh_path=(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}"),
|
||||
current_etag=strong_resource_etag(
|
||||
"campaign_version",
|
||||
version_id,
|
||||
@@ -850,9 +926,15 @@ def update_campaign_review_state(
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
ensure_current_working_version(campaign, version, action="record review state for")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
|
||||
raise LockedCampaignVersionError(
|
||||
"Delivery has started; message review state can no longer be changed."
|
||||
)
|
||||
build_token = _campaign_review_build_token(version)
|
||||
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
|
||||
normalized_reviewed = list(
|
||||
dict.fromkeys(
|
||||
str(value) for value in reviewed_message_keys if str(value).strip()
|
||||
)
|
||||
)
|
||||
normalized_decisions: list[dict[str, Any]] = []
|
||||
if inspection_complete:
|
||||
normalized_reviewed, normalized_decisions = _complete_campaign_review(
|
||||
@@ -880,10 +962,14 @@ def update_campaign_review_state(
|
||||
|
||||
|
||||
def _campaign_review_build_token(version: CampaignVersion) -> str:
|
||||
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
build_summary = (
|
||||
version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
)
|
||||
if not build_summary:
|
||||
raise CampaignPersistenceError("Build messages before recording review state.")
|
||||
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
|
||||
build_token = str(
|
||||
build_summary.get("build_token") or build_summary.get("built_at") or ""
|
||||
).strip()
|
||||
if build_token:
|
||||
return build_token
|
||||
build_token = uuid4().hex
|
||||
@@ -908,13 +994,20 @@ def _complete_campaign_review(
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
|
||||
blocking = [
|
||||
job
|
||||
for job in jobs
|
||||
if job.build_status != "built" or job.validation_status == "blocked"
|
||||
]
|
||||
if blocking:
|
||||
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
|
||||
raise CampaignPersistenceError(
|
||||
"Blocked or failed messages must be resolved before review can be completed."
|
||||
)
|
||||
missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys))
|
||||
if missing:
|
||||
raise CampaignPersistenceError(
|
||||
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
|
||||
"Messages requiring an explicit decision must be opened before review can be completed: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
decisions = _normalize_review_issue_decisions(
|
||||
jobs,
|
||||
@@ -924,9 +1017,7 @@ def _complete_campaign_review(
|
||||
)
|
||||
return (
|
||||
list(
|
||||
dict.fromkeys(
|
||||
[*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]
|
||||
)
|
||||
dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)])
|
||||
),
|
||||
decisions,
|
||||
)
|
||||
@@ -957,9 +1048,7 @@ def _normalize_review_issue_decisions(
|
||||
"Only one review decision may be recorded per built message."
|
||||
)
|
||||
if str(item.get("decision") or "accept") != "accept":
|
||||
raise CampaignPersistenceError(
|
||||
"Unsupported campaign review decision."
|
||||
)
|
||||
raise CampaignPersistenceError("Unsupported campaign review decision.")
|
||||
requested_by_job[job_id] = item
|
||||
|
||||
timestamp = (decided_at or datetime.now(UTC)).isoformat()
|
||||
@@ -990,9 +1079,7 @@ def _normalize_review_issue_decisions(
|
||||
"Attachment exception decisions require a reason."
|
||||
)
|
||||
evidence_issues = reviewable_issues or [
|
||||
issue
|
||||
for issue in (job.issues_snapshot or [])
|
||||
if isinstance(issue, dict)
|
||||
issue for issue in (job.issues_snapshot or []) if isinstance(issue, dict)
|
||||
]
|
||||
issue_payload = [
|
||||
{
|
||||
@@ -1092,13 +1179,17 @@ def lock_campaign_version_temporarily(
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
ensure_current_working_version(campaign, version, action="lock")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
|
||||
raise LockedCampaignVersionError(
|
||||
"Delivery/final versions are permanently locked and cannot receive a temporary user lock."
|
||||
)
|
||||
if is_permanent_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version is already permanently locked.")
|
||||
if is_temporary_user_locked_version(version):
|
||||
return version
|
||||
if version.locked_at:
|
||||
raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.")
|
||||
raise LockedCampaignVersionError(
|
||||
"This version is already temporarily locked by validation. Unlock validation before applying a user lock."
|
||||
)
|
||||
|
||||
version.user_lock_state = USER_LOCK_TEMPORARY
|
||||
version.user_locked_at = datetime.now(UTC)
|
||||
@@ -1131,11 +1222,17 @@ def unlock_user_locked_campaign_version(
|
||||
ensure_current_working_version(campaign, version, action="unlock")
|
||||
state = campaign_version_user_lock_state(version)
|
||||
if state == USER_LOCK_PERMANENT:
|
||||
raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.")
|
||||
raise LockedCampaignVersionError(
|
||||
"Permanently locked versions cannot be unlocked. Create an editable copy instead."
|
||||
)
|
||||
if state != USER_LOCK_TEMPORARY:
|
||||
raise LockedCampaignVersionError("This version does not have a temporary user lock.")
|
||||
raise LockedCampaignVersionError(
|
||||
"This version does not have a temporary user lock."
|
||||
)
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.")
|
||||
raise LockedCampaignVersionError(
|
||||
"Delivery/final versions cannot be unlocked. Create an editable copy instead."
|
||||
)
|
||||
|
||||
version.user_lock_state = None
|
||||
version.user_locked_at = None
|
||||
@@ -1172,7 +1269,9 @@ def permanently_lock_campaign_version(
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
ensure_current_working_version(campaign, version, action="lock permanently")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
|
||||
raise LockedCampaignVersionError(
|
||||
"This version is already permanently locked by its delivery/final state."
|
||||
)
|
||||
if is_permanent_user_locked_version(version):
|
||||
return version
|
||||
|
||||
@@ -1211,7 +1310,9 @@ def publish_campaign_version(
|
||||
)
|
||||
|
||||
|
||||
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
|
||||
def validate_campaign_partial(
|
||||
raw_json: dict[str, Any], *, section: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
||||
|
||||
This is intentionally less strict than campaign.schema.json validation. It
|
||||
@@ -1235,20 +1336,44 @@ def _dict_value(value: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
return candidate if isinstance(candidate, dict) else {}
|
||||
|
||||
|
||||
def _validate_partial_basics(collector: _PartialValidationCollector, campaign: dict[str, Any]) -> None:
|
||||
def _validate_partial_basics(
|
||||
collector: _PartialValidationCollector, campaign: dict[str, Any]
|
||||
) -> None:
|
||||
if not campaign.get("id"):
|
||||
collector.issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
|
||||
collector.issue(
|
||||
"error",
|
||||
"basics",
|
||||
"campaign.id",
|
||||
"missing_campaign_id",
|
||||
"Campaign id is required.",
|
||||
)
|
||||
if not campaign.get("name"):
|
||||
collector.issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
|
||||
collector.issue(
|
||||
"error",
|
||||
"basics",
|
||||
"campaign.name",
|
||||
"missing_campaign_name",
|
||||
"Campaign name is required.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_partial_sender(collector: _PartialValidationCollector, recipients: dict[str, Any]) -> None:
|
||||
def _validate_partial_sender(
|
||||
collector: _PartialValidationCollector, recipients: dict[str, Any]
|
||||
) -> None:
|
||||
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
|
||||
if not sender.get("email"):
|
||||
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"sender",
|
||||
"recipients.from.email",
|
||||
"missing_sender_email",
|
||||
"Sender email is not configured yet.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None:
|
||||
def _validate_partial_mail_profile(
|
||||
collector: _PartialValidationCollector, raw_json: dict[str, Any]
|
||||
) -> None:
|
||||
violations = campaign_mail_profile_boundary_violations(raw_json)
|
||||
if violations:
|
||||
collector.issue(
|
||||
@@ -1268,13 +1393,27 @@ def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_j
|
||||
)
|
||||
|
||||
|
||||
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
|
||||
def _validate_partial_recipients(
|
||||
collector: _PartialValidationCollector, entries: dict[str, Any]
|
||||
) -> None:
|
||||
has_inline = bool(entries.get("inline"))
|
||||
has_source = isinstance(entries.get("source"), dict)
|
||||
if not has_inline and not has_source:
|
||||
collector.issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"recipients",
|
||||
"entries",
|
||||
"missing_recipients",
|
||||
"No inline recipients or external recipient source configured yet.",
|
||||
)
|
||||
if has_source and not _entries_source_has_email_mapping(entries):
|
||||
collector.issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"recipients",
|
||||
"entries.mapping",
|
||||
"missing_email_mapping",
|
||||
"No email field mapping is configured.",
|
||||
)
|
||||
|
||||
|
||||
def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
|
||||
@@ -1282,58 +1421,142 @@ def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
|
||||
return any(key in mapping for key in ("to.0.email", "to.email", "email"))
|
||||
|
||||
|
||||
def _validate_partial_template(collector: _PartialValidationCollector, template: dict[str, Any]) -> None:
|
||||
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
|
||||
def _validate_partial_template(
|
||||
collector: _PartialValidationCollector, template: dict[str, Any]
|
||||
) -> None:
|
||||
source_template = (
|
||||
template.get("source") if isinstance(template.get("source"), dict) else {}
|
||||
)
|
||||
if not template.get("subject") and not source_template.get("subject_path"):
|
||||
collector.issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"template",
|
||||
"template.subject",
|
||||
"missing_subject",
|
||||
"Template subject is empty.",
|
||||
)
|
||||
body_state = _partial_template_body_state(template, source_template)
|
||||
_validate_partial_template_body(collector, body_state)
|
||||
|
||||
|
||||
def _partial_template_body_state(template: dict[str, Any], source_template: dict[str, Any]) -> dict[str, Any]:
|
||||
def _partial_template_body_state(
|
||||
template: dict[str, Any], source_template: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None,
|
||||
"has_text": bool(template.get("text")) or bool(source_template.get("text_path")),
|
||||
"has_html": bool(template.get("html")) or bool(source_template.get("html_path")),
|
||||
"mode": template.get("body_mode")
|
||||
if template.get("body_mode") in {"text", "html", "both"}
|
||||
else None,
|
||||
"has_text": bool(template.get("text"))
|
||||
or bool(source_template.get("text_path")),
|
||||
"has_html": bool(template.get("html"))
|
||||
or bool(source_template.get("html_path")),
|
||||
"has_source": bool(source_template),
|
||||
}
|
||||
|
||||
|
||||
def _validate_partial_template_body(collector: _PartialValidationCollector, state: dict[str, Any]) -> None:
|
||||
def _validate_partial_template_body(
|
||||
collector: _PartialValidationCollector, state: dict[str, Any]
|
||||
) -> None:
|
||||
mode = state["mode"]
|
||||
has_text = bool(state["has_text"])
|
||||
has_html = bool(state["has_html"])
|
||||
if mode == "text" and not has_text:
|
||||
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"template",
|
||||
"template.text",
|
||||
"missing_template_text_body",
|
||||
"Template body mode is text only, but no text body is configured.",
|
||||
)
|
||||
elif mode == "html" and not has_html:
|
||||
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"template",
|
||||
"template.html",
|
||||
"missing_template_html_body",
|
||||
"Template body mode is HTML only, but no HTML body is configured.",
|
||||
)
|
||||
elif mode == "both":
|
||||
_validate_partial_dual_body_template(collector, has_text=has_text, has_html=has_html)
|
||||
_validate_partial_dual_body_template(
|
||||
collector, has_text=has_text, has_html=has_html
|
||||
)
|
||||
elif not has_text and not has_html and not state["has_source"]:
|
||||
collector.issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"template",
|
||||
"template",
|
||||
"missing_template_body",
|
||||
"No text, HTML or file-based template body configured yet.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_partial_dual_body_template(collector: _PartialValidationCollector, *, has_text: bool, has_html: bool) -> None:
|
||||
def _validate_partial_dual_body_template(
|
||||
collector: _PartialValidationCollector, *, has_text: bool, has_html: bool
|
||||
) -> None:
|
||||
if not has_text:
|
||||
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"template",
|
||||
"template.text",
|
||||
"missing_template_text_body",
|
||||
"Template body mode is both, but no text body is configured.",
|
||||
)
|
||||
if not has_html:
|
||||
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
|
||||
collector.issue(
|
||||
"warning",
|
||||
"template",
|
||||
"template.html",
|
||||
"missing_template_html_body",
|
||||
"Template body mode is both, but no HTML body is configured.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_partial_attachments(collector: _PartialValidationCollector, attachments: dict[str, Any]) -> None:
|
||||
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
|
||||
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
|
||||
def _validate_partial_attachments(
|
||||
collector: _PartialValidationCollector, attachments: dict[str, Any]
|
||||
) -> None:
|
||||
base_paths = (
|
||||
attachments.get("base_paths")
|
||||
if isinstance(attachments.get("base_paths"), list)
|
||||
else []
|
||||
)
|
||||
has_named_base_path = any(
|
||||
isinstance(item, dict) and item.get("path") for item in base_paths
|
||||
)
|
||||
if not has_named_base_path and not attachments.get("base_path"):
|
||||
collector.issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
|
||||
collector.issue(
|
||||
"info",
|
||||
"attachments",
|
||||
"attachments.base_path",
|
||||
"missing_attachment_base_path",
|
||||
"Attachment base path is not configured yet.",
|
||||
)
|
||||
|
||||
|
||||
def _validate_partial_delivery(collector: _PartialValidationCollector, delivery: dict[str, Any]) -> None:
|
||||
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
|
||||
def _validate_partial_delivery(
|
||||
collector: _PartialValidationCollector, delivery: dict[str, Any]
|
||||
) -> None:
|
||||
rate_limit = (
|
||||
delivery.get("rate_limit")
|
||||
if isinstance(delivery.get("rate_limit"), dict)
|
||||
else {}
|
||||
)
|
||||
messages_per_minute = rate_limit.get("messages_per_minute")
|
||||
if messages_per_minute is None:
|
||||
return
|
||||
try:
|
||||
if int(messages_per_minute) < 1:
|
||||
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
|
||||
collector.issue(
|
||||
"error",
|
||||
"send",
|
||||
"delivery.rate_limit.messages_per_minute",
|
||||
"invalid_rate_limit",
|
||||
"Messages per minute must be at least 1.",
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
|
||||
collector.issue(
|
||||
"error",
|
||||
"send",
|
||||
"delivery.rate_limit.messages_per_minute",
|
||||
"invalid_rate_limit",
|
||||
"Messages per minute must be a number.",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user