Add institutional provenance and approval gates
This commit is contained in:
@@ -33,7 +33,10 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.loader import (
|
||||
load_campaign_json,
|
||||
validate_against_schema,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
campaign_mail_profile_id,
|
||||
@@ -46,7 +49,10 @@ from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||
)
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary
|
||||
from govoplan_campaign.backend.sending.execution import (
|
||||
create_execution_snapshot,
|
||||
profile_delivery_summary,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
CampaignConfig,
|
||||
DeliveryChannelPolicy,
|
||||
@@ -73,8 +79,6 @@ class _StoredEmlArtifact:
|
||||
message_id_header: str | None
|
||||
|
||||
|
||||
|
||||
|
||||
def load_campaign_config_from_json(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -116,7 +120,11 @@ def _campaign_reference_path(version: CampaignVersion) -> Path:
|
||||
"""Return a path anchor without persisting a second configuration copy."""
|
||||
|
||||
source_base_path = str(version.source_base_path or "").strip()
|
||||
base = Path(source_base_path).expanduser().resolve() if source_base_path else Path.cwd()
|
||||
base = (
|
||||
Path(source_base_path).expanduser().resolve()
|
||||
if source_base_path
|
||||
else Path.cwd()
|
||||
)
|
||||
return base / "campaign.json"
|
||||
|
||||
|
||||
@@ -142,8 +150,7 @@ def _persist_built_eml_artifacts(
|
||||
path = Path(message.eml_path)
|
||||
if not path.is_file():
|
||||
raise CampaignPersistenceError(
|
||||
"Generated EML evidence is missing for entry "
|
||||
f"{message.entry_index}"
|
||||
f"Generated EML evidence is missing for entry {message.entry_index}"
|
||||
)
|
||||
payload = path.read_bytes()
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
@@ -191,7 +198,11 @@ def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> None:
|
||||
|
||||
|
||||
def _next_version_number(session: Session, campaign_id: str) -> int:
|
||||
current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar()
|
||||
current = (
|
||||
session.query(func.max(CampaignVersion.version_number))
|
||||
.filter(CampaignVersion.campaign_id == campaign_id)
|
||||
.scalar()
|
||||
)
|
||||
return int(current or 0) + 1
|
||||
|
||||
|
||||
@@ -204,7 +215,9 @@ def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | No
|
||||
return str((base_path / path).resolve())
|
||||
|
||||
|
||||
def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]:
|
||||
def normalize_campaign_paths(
|
||||
raw_json: dict[str, Any], source_base_path: str | Path | None
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve paths for an explicitly trusted, file-oriented import.
|
||||
|
||||
The CLI naturally resolves relative paths against the campaign.json file.
|
||||
@@ -217,18 +230,28 @@ def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | P
|
||||
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
|
||||
data = copy.deepcopy(raw_json)
|
||||
|
||||
template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None
|
||||
template_source = (
|
||||
data.get("template", {}).get("source")
|
||||
if isinstance(data.get("template"), dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(template_source, dict):
|
||||
for key in ("subject_path", "text_path", "html_path"):
|
||||
template_source[key] = _resolve_runtime_path(base, template_source.get(key))
|
||||
|
||||
entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None
|
||||
entries_source = (
|
||||
data.get("entries", {}).get("source")
|
||||
if isinstance(data.get("entries"), dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(entries_source, dict):
|
||||
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
|
||||
|
||||
attachments = data.get("attachments")
|
||||
if isinstance(attachments, dict):
|
||||
attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "."
|
||||
attachments["base_path"] = (
|
||||
_resolve_runtime_path(base, attachments.get("base_path")) or "."
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
@@ -251,15 +274,21 @@ def create_campaign_version_from_json(
|
||||
)
|
||||
if source_base_path is None and source_filename:
|
||||
source_path = Path(source_filename).expanduser()
|
||||
source_base_path = str(source_path.parent if source_path.suffix else source_path)
|
||||
source_base_path = str(
|
||||
source_path.parent if source_path.suffix else source_path
|
||||
)
|
||||
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path)
|
||||
|
||||
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id)
|
||||
config = load_campaign_config_from_json(
|
||||
session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id
|
||||
)
|
||||
|
||||
campaign = (
|
||||
session.query(Campaign)
|
||||
.filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id)
|
||||
.filter(
|
||||
Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if campaign is None:
|
||||
@@ -275,7 +304,11 @@ def create_campaign_version_from_json(
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
else:
|
||||
current = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None
|
||||
current = (
|
||||
session.get(CampaignVersion, campaign.current_version_id)
|
||||
if campaign.current_version_id
|
||||
else None
|
||||
)
|
||||
if current and not _version_is_audit_safe_snapshot(current):
|
||||
raise CampaignPersistenceError(
|
||||
f"Campaign already has active working version #{current.version_number}. "
|
||||
@@ -303,8 +336,6 @@ def create_campaign_version_from_json(
|
||||
return campaign, version
|
||||
|
||||
|
||||
|
||||
|
||||
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||
state = getattr(version, "user_lock_state", None)
|
||||
if state in {"temporary", "permanent"}:
|
||||
@@ -317,7 +348,9 @@ def _version_is_user_locked(version: CampaignVersion) -> bool:
|
||||
|
||||
|
||||
def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool:
|
||||
return _version_user_lock_state(version) == "permanent" or version.workflow_state in {
|
||||
return _version_user_lock_state(
|
||||
version
|
||||
) == "permanent" or version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
CampaignVersionWorkflowState.SENDING.value,
|
||||
CampaignVersionWorkflowState.COMPLETED.value,
|
||||
@@ -329,7 +362,9 @@ def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool:
|
||||
}
|
||||
|
||||
|
||||
def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersion, *, action: str) -> None:
|
||||
def _ensure_current_campaign_version(
|
||||
campaign: Campaign, version: CampaignVersion, *, action: str
|
||||
) -> None:
|
||||
if campaign.current_version_id != version.id:
|
||||
raise CampaignPersistenceError(
|
||||
f"Historical campaign versions are read-only and cannot be used to {action}. "
|
||||
@@ -338,18 +373,33 @@ def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersio
|
||||
|
||||
|
||||
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
||||
return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version))
|
||||
validation_summary = (
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else {}
|
||||
)
|
||||
return bool(
|
||||
version.locked_at
|
||||
and validation_summary.get("ok") is True
|
||||
and not _version_is_user_locked(version)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
||||
state = _version_user_lock_state(version)
|
||||
if state == "temporary":
|
||||
raise CampaignPersistenceError("This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending.")
|
||||
raise CampaignPersistenceError(
|
||||
"This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending."
|
||||
)
|
||||
if state == "permanent":
|
||||
raise CampaignPersistenceError("This version is permanently user-locked. Create an editable copy instead.")
|
||||
raise CampaignPersistenceError(
|
||||
"This version is permanently user-locked. Create an editable copy instead."
|
||||
)
|
||||
if not _version_is_validated_and_locked(version):
|
||||
raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version must be validated and locked before building, queueing, dry-run or sending."
|
||||
)
|
||||
|
||||
|
||||
def load_version_config(session: Session, version_id: str):
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
@@ -364,7 +414,16 @@ def load_version_config(session: Session, version_id: str):
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
path = _campaign_reference_path(version)
|
||||
return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id)
|
||||
return (
|
||||
version,
|
||||
path,
|
||||
load_campaign_config_from_json(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id,
|
||||
raw_json=raw_json,
|
||||
campaign_id=campaign.id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def validate_campaign_version(
|
||||
@@ -379,7 +438,9 @@ def validate_campaign_version(
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version is not accessible for this tenant"
|
||||
)
|
||||
_ensure_current_campaign_version(campaign, version, action="validate")
|
||||
if _version_is_user_locked(version) or version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
@@ -391,8 +452,14 @@ def validate_campaign_version(
|
||||
CampaignVersionWorkflowState.CANCELLED.value,
|
||||
CampaignVersionWorkflowState.ARCHIVED.value,
|
||||
}:
|
||||
lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final"
|
||||
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
|
||||
lock_label = (
|
||||
"temporarily user-locked"
|
||||
if _version_user_lock_state(version) == "temporary"
|
||||
else "permanently locked/final"
|
||||
)
|
||||
raise CampaignPersistenceError(
|
||||
f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead."
|
||||
)
|
||||
|
||||
if check_files:
|
||||
files = files_integration()
|
||||
@@ -405,7 +472,12 @@ def validate_campaign_version(
|
||||
prefix="govoplan-managed-validate-",
|
||||
) as prepared:
|
||||
managed_raw = load_campaign_json(prepared.path)
|
||||
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
||||
managed_config = load_campaign_config_from_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=managed_raw,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
report = validate_campaign_config(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
@@ -434,7 +506,10 @@ def validate_campaign_version(
|
||||
# Replace version-level semantic issues from previous validations.
|
||||
(
|
||||
session.query(CampaignIssue)
|
||||
.filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None))
|
||||
.filter(
|
||||
CampaignIssue.campaign_version_id == version.id,
|
||||
CampaignIssue.job_id.is_(None),
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
for issue in report.issues:
|
||||
@@ -450,7 +525,11 @@ def validate_campaign_version(
|
||||
)
|
||||
)
|
||||
|
||||
campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value
|
||||
campaign.status = (
|
||||
CampaignStatus.VALIDATED.value
|
||||
if report.ok
|
||||
else CampaignStatus.NEEDS_REVIEW.value
|
||||
)
|
||||
if report.ok:
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
version.is_complete = True
|
||||
@@ -477,7 +556,9 @@ def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]:
|
||||
if not path.exists():
|
||||
return None, None
|
||||
payload = path.read_bytes()
|
||||
message_id = BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID")
|
||||
message_id = (
|
||||
BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID")
|
||||
)
|
||||
return hashlib.sha256(payload).hexdigest(), str(message_id) if message_id else None
|
||||
|
||||
|
||||
@@ -508,7 +589,9 @@ def _job_from_message(
|
||||
eml_local_path=message.eml_path if stored_eml is None else None,
|
||||
eml_size_bytes=stored_eml.size_bytes if stored_eml else message.eml_size_bytes,
|
||||
eml_sha256=eml_sha256,
|
||||
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
|
||||
build_status=message.build_status.value
|
||||
if hasattr(message.build_status, "value")
|
||||
else str(message.build_status),
|
||||
validation_status=_job_validation_status(message.validation_status.value),
|
||||
queue_status=JobQueueStatus.DRAFT.value,
|
||||
send_status=(
|
||||
@@ -522,7 +605,9 @@ def _job_from_message(
|
||||
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
|
||||
else JobPostboxStatus.NOT_REQUESTED.value
|
||||
),
|
||||
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
|
||||
imap_status=message.imap_status.value
|
||||
if hasattr(message.imap_status, "value")
|
||||
else JobImapStatus.NOT_REQUESTED.value,
|
||||
resolved_recipients={
|
||||
"from": message.from_.model_dump(mode="json") if message.from_ else None,
|
||||
"from_all": [item.model_dump(mode="json") for item in message.from_all],
|
||||
@@ -531,12 +616,21 @@ def _job_from_message(
|
||||
"bcc": [item.model_dump(mode="json") for item in message.bcc],
|
||||
"reply_to": [item.model_dump(mode="json") for item in message.reply_to],
|
||||
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
||||
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
||||
"disposition_notification_to": [
|
||||
item.model_dump(mode="json")
|
||||
for item in message.disposition_notification_to
|
||||
],
|
||||
},
|
||||
resolved_postbox_targets=resolved_postbox_targets or [],
|
||||
resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments],
|
||||
resolved_attachments=[
|
||||
files_integration().public_attachment_summary_payload(item)
|
||||
for item in message.attachments
|
||||
],
|
||||
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
||||
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
||||
last_error="; ".join(
|
||||
issue.message for issue in message.issues if issue.severity == "error"
|
||||
)
|
||||
or None,
|
||||
)
|
||||
|
||||
|
||||
@@ -554,7 +648,9 @@ def _resolve_built_postbox_targets(
|
||||
continue
|
||||
entry = entries_by_index.get(built.draft.entry_index)
|
||||
if entry is None:
|
||||
raise CampaignPersistenceError("Built recipient row is missing from the campaign input.")
|
||||
raise CampaignPersistenceError(
|
||||
"Built recipient row is missing from the campaign input."
|
||||
)
|
||||
resolved, issues, validation_status = resolve_entry_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -571,22 +667,29 @@ def _resolve_built_postbox_targets(
|
||||
|
||||
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
|
||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
||||
for message_payload, message in zip(
|
||||
report_json.get("messages", []), result.report.messages, strict=False
|
||||
):
|
||||
if isinstance(message_payload, dict):
|
||||
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
|
||||
report_json.update({
|
||||
"built_at": datetime.now(UTC).isoformat(),
|
||||
"build_token": uuid4().hex,
|
||||
"built_count": result.report.built_count,
|
||||
"build_failed_count": result.report.build_failed_count,
|
||||
"ready_count": result.report.ready_count,
|
||||
"warning_count": result.report.warning_count,
|
||||
"needs_review_count": result.report.needs_review_count,
|
||||
"blocked_count": result.report.blocked_count,
|
||||
"excluded_count": result.report.excluded_count,
|
||||
"inactive_count": result.report.inactive_count,
|
||||
"queueable_count": result.report.queueable_count,
|
||||
})
|
||||
message_payload["attachments"] = [
|
||||
files.public_attachment_summary_payload(item)
|
||||
for item in message.attachments
|
||||
]
|
||||
report_json.update(
|
||||
{
|
||||
"built_at": datetime.now(UTC).isoformat(),
|
||||
"build_token": uuid4().hex,
|
||||
"built_count": result.report.built_count,
|
||||
"build_failed_count": result.report.build_failed_count,
|
||||
"ready_count": result.report.ready_count,
|
||||
"warning_count": result.report.warning_count,
|
||||
"needs_review_count": result.report.needs_review_count,
|
||||
"blocked_count": result.report.blocked_count,
|
||||
"excluded_count": result.report.excluded_count,
|
||||
"inactive_count": result.report.inactive_count,
|
||||
"queueable_count": result.report.queueable_count,
|
||||
}
|
||||
)
|
||||
return report_json
|
||||
|
||||
|
||||
@@ -614,7 +717,9 @@ def _replace_version_jobs(
|
||||
CampaignIssue.campaign_version_id == version_id,
|
||||
CampaignIssue.job_id.is_not(None),
|
||||
).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(
|
||||
CampaignJob.campaign_version_id == version_id
|
||||
).delete(synchronize_session=False)
|
||||
session.flush()
|
||||
|
||||
pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
||||
@@ -624,7 +729,9 @@ def _replace_version_jobs(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
message=built.draft,
|
||||
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []),
|
||||
resolved_postbox_targets=postbox_targets_by_index.get(
|
||||
built.draft.entry_index, []
|
||||
),
|
||||
stored_eml=stored_eml_by_index.get(built.draft.entry_index),
|
||||
)
|
||||
session.add(job)
|
||||
@@ -640,16 +747,26 @@ def _mail_execution_profile(
|
||||
config: CampaignConfig,
|
||||
jobs: list[CampaignJob],
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
if not any(DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs):
|
||||
if not any(
|
||||
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs
|
||||
):
|
||||
return None, {}
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created.")
|
||||
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
raise CampaignPersistenceError(
|
||||
"The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created."
|
||||
)
|
||||
profile_id = campaign_mail_profile_id(
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
if profile_id is None:
|
||||
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages that use Mail.")
|
||||
raise CampaignPersistenceError(
|
||||
"Select an authorized Mail profile before building campaign messages that use Mail."
|
||||
)
|
||||
summary = profile_delivery_summary(session, version)
|
||||
if not summary.get("smtp_transport_revision"):
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision.")
|
||||
raise CampaignPersistenceError(
|
||||
"The selected Mail profile has no SMTP transport revision."
|
||||
)
|
||||
return profile_id, summary
|
||||
|
||||
|
||||
@@ -661,7 +778,9 @@ def _store_execution_snapshot(
|
||||
jobs: list[CampaignJob],
|
||||
build_summary: dict[str, Any],
|
||||
) -> None:
|
||||
profile_id, profile = _mail_execution_profile(session, version=version, config=config, jobs=jobs)
|
||||
profile_id, profile = _mail_execution_profile(
|
||||
session, version=version, config=config, jobs=jobs
|
||||
)
|
||||
snapshot, snapshot_hash = create_execution_snapshot(
|
||||
version,
|
||||
mail_profile_id=profile_id,
|
||||
@@ -689,20 +808,22 @@ def _store_job_issues(
|
||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
|
||||
) -> None:
|
||||
for job, message in job_build_pairs:
|
||||
session.add_all([
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
campaign_version_id=version_id,
|
||||
job_id=job.id,
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.source,
|
||||
behavior=issue.behavior,
|
||||
)
|
||||
for issue in message.issues
|
||||
])
|
||||
session.add_all(
|
||||
[
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
campaign_version_id=version_id,
|
||||
job_id=job.id,
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.source,
|
||||
behavior=issue.behavior,
|
||||
)
|
||||
for issue in message.issues
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _apply_campaign_build_state(
|
||||
@@ -734,13 +855,21 @@ def build_campaign_version(
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version is not accessible for this tenant"
|
||||
)
|
||||
_ensure_current_campaign_version(campaign, version, action="build")
|
||||
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
||||
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
||||
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
||||
validation_summary = (
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else {}
|
||||
)
|
||||
if not validation_summary.get("ok"):
|
||||
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
|
||||
raise CampaignPersistenceError(
|
||||
"Campaign version must be successfully validated before messages are built"
|
||||
)
|
||||
_ensure_version_validated_and_locked(version)
|
||||
|
||||
files = files_integration()
|
||||
@@ -804,6 +933,7 @@ def build_campaign_version(
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
editor_state.pop("approval_gate", None)
|
||||
version.editor_state = editor_state
|
||||
|
||||
job_build_pairs, old_storage_keys = _replace_version_jobs(
|
||||
|
||||
Reference in New Issue
Block a user