Add institutional provenance and approval gates

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent cec3d17bff
commit 2afdd38128
16 changed files with 2472 additions and 509 deletions
@@ -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.",
)