Refactor campaign delivery decision paths

This commit is contained in:
2026-07-29 17:06:15 +02:00
parent 5240749ae1
commit dd9592a192
9 changed files with 1103 additions and 593 deletions

View File

@@ -465,6 +465,177 @@ def _job_from_message(
)
def _resolve_built_postbox_targets(
session: Session,
*,
tenant_id: str,
config: CampaignConfig,
built_messages: list[Any],
entries_by_index: dict[int, Any],
) -> dict[int, list[dict[str, Any]]]:
resolved_by_index: dict[int, list[dict[str, Any]]] = {}
for built in built_messages:
if not DeliveryChannelPolicy(built.draft.delivery_channel_policy).uses_postbox:
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.")
resolved, issues, validation_status = resolve_entry_postbox_targets(
session,
tenant_id=tenant_id,
config=config,
entry=entry,
validation_status=built.draft.validation_status,
materialize=True,
)
built.draft.issues.extend(issues)
built.draft.validation_status = validation_status
resolved_by_index[built.draft.entry_index] = resolved
return resolved_by_index
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):
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,
})
return report_json
def _replace_version_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
built_messages: list[Any],
postbox_targets_by_index: dict[int, list[dict[str, Any]]],
) -> list[tuple[CampaignJob, MessageDraft]]:
session.query(CampaignIssue).filter(
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.flush()
pairs: list[tuple[CampaignJob, MessageDraft]] = []
for built in built_messages:
job = _job_from_message(
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
message=built.draft,
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []),
)
session.add(job)
pairs.append((job, built.draft))
session.flush()
return pairs
def _mail_execution_profile(
session: Session,
*,
version: CampaignVersion,
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):
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 {})
if profile_id is None:
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.")
return profile_id, summary
def _store_execution_snapshot(
session: Session,
*,
version: CampaignVersion,
config: CampaignConfig,
jobs: list[CampaignJob],
build_summary: dict[str, Any],
) -> None:
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,
smtp_server_id=profile.get("smtp_server_id"),
smtp_credential_id=profile.get("smtp_credential_id"),
imap_server_id=profile.get("imap_server_id"),
imap_credential_id=profile.get("imap_credential_id"),
smtp_transport_revision=profile.get("smtp_transport_revision"),
imap_transport_revision=profile.get("imap_transport_revision"),
delivery=config.delivery,
jobs=jobs,
build_summary=build_summary,
)
version.execution_snapshot = snapshot
version.execution_snapshot_hash = snapshot_hash
version.execution_snapshot_at = datetime.now(UTC)
def _store_job_issues(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
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
])
def _apply_campaign_build_state(
campaign: Campaign,
version: CampaignVersion,
*,
needs_review_count: int,
blocked_count: int,
queueable_count: int,
) -> None:
if needs_review_count or blocked_count:
campaign.status = CampaignStatus.NEEDS_REVIEW.value
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
elif queueable_count > 0:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
else:
campaign.status = CampaignStatus.VALIDATED.value
def build_campaign_version(
session: Session,
*,
@@ -508,154 +679,44 @@ def build_campaign_version(
start=1,
)
}
resolved_postbox_targets_by_index: dict[
int,
list[dict[str, Any]],
] = {}
for built in result.built_messages:
policy = DeliveryChannelPolicy(
built.draft.delivery_channel_policy
)
if not policy.uses_postbox:
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."
)
resolved_targets, postbox_issues, validation_status = (
resolve_entry_postbox_targets(
session,
tenant_id=tenant_id,
config=managed_config,
entry=entry,
validation_status=built.draft.validation_status,
materialize=True,
)
)
built.draft.issues.extend(postbox_issues)
built.draft.validation_status = validation_status
resolved_postbox_targets_by_index[built.draft.entry_index] = (
resolved_targets
)
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):
if isinstance(message_payload, dict):
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
report_json["built_at"] = datetime.now(UTC).isoformat()
report_json["build_token"] = uuid4().hex
report_json.update({
"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,
})
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
session,
tenant_id=tenant_id,
config=managed_config,
built_messages=result.built_messages,
entries_by_index=entries_by_index,
)
report_json = _campaign_build_report(result, files)
version.build_summary = report_json
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
version.editor_state = editor_state
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
session.query(CampaignIssue).filter(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.flush()
job_build_pairs: list[tuple[CampaignJob, MessageDraft]] = []
for built in result.built_messages:
job = _job_from_message(
tenant_id=tenant_id,
campaign_id=campaign.id,
version_id=version.id,
message=built.draft,
resolved_postbox_targets=resolved_postbox_targets_by_index.get(
built.draft.entry_index,
[],
),
)
session.add(job)
job_build_pairs.append((job, built.draft))
# Assign all job IDs in one round-trip, then persist exact attachment use
# records in bulk. This avoids one flush plus several metadata queries per
# recipient for large campaigns.
session.flush()
files.record_campaign_attachment_uses_for_jobs(
job_build_pairs = _replace_version_jobs(
session,
[job for job, _message in job_build_pairs],
stage="built",
tenant_id=tenant_id,
campaign_id=campaign.id,
version_id=version.id,
built_messages=result.built_messages,
postbox_targets_by_index=resolved_postbox_targets_by_index,
)
uses_mail = any(
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
for job, _message in job_build_pairs
jobs = [job for job, _message in job_build_pairs]
files.record_campaign_attachment_uses_for_jobs(session, jobs, stage="built")
_store_execution_snapshot(session, version=version, config=managed_config, jobs=jobs, build_summary=report_json)
_store_job_issues(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version_id=version.id,
job_build_pairs=job_build_pairs,
)
profile_id: str | None = None
profile_summary: dict[str, Any] = {}
if uses_mail:
if not managed_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 {}
)
if profile_id is None:
raise CampaignPersistenceError(
"Select an authorized Mail profile before building campaign "
"messages that use Mail."
)
profile_summary = profile_delivery_summary(session, version)
if not profile_summary.get("smtp_transport_revision"):
raise CampaignPersistenceError(
"The selected Mail profile has no SMTP transport revision."
)
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
_apply_campaign_build_state(
campaign,
version,
mail_profile_id=profile_id,
smtp_server_id=profile_summary.get("smtp_server_id"),
smtp_credential_id=profile_summary.get("smtp_credential_id"),
imap_server_id=profile_summary.get("imap_server_id"),
imap_credential_id=profile_summary.get("imap_credential_id"),
smtp_transport_revision=profile_summary.get(
"smtp_transport_revision"
),
imap_transport_revision=profile_summary.get("imap_transport_revision"),
delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs],
build_summary=report_json,
needs_review_count=result.report.needs_review_count,
blocked_count=result.report.blocked_count,
queueable_count=result.report.queueable_count,
)
version.execution_snapshot = execution_snapshot
version.execution_snapshot_hash = execution_snapshot_hash
version.execution_snapshot_at = datetime.now(UTC)
for job, message in job_build_pairs:
for issue in message.issues:
session.add(
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,
)
)
if result.report.needs_review_count or result.report.blocked_count:
campaign.status = CampaignStatus.NEEDS_REVIEW.value
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
elif result.report.queueable_count > 0:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
else:
campaign.status = CampaignStatus.VALIDATED.value
session.add(version)
session.add(campaign)

View File

@@ -328,6 +328,112 @@ def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> No
campaign.external_id = campaign_meta.get("id") or campaign.external_id
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"
raise LockedCampaignVersionError(
f"Campaign already has active working version #{current_number}. "
"Unlock or continue editing that version instead of creating a parallel draft."
)
if campaign.current_version_id and source.id != campaign.current_version_id:
raise LockedCampaignVersionError(
"Historical versions remain review-only and cannot become a new branch. "
"Create the next working copy from the campaign's current immutable version."
)
def _fork_runtime_json(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
source: CampaignVersion,
raw_json: dict[str, Any] | None,
source_filename: str | None,
source_base_path: str | None,
migrate_legacy_mail_settings: bool,
) -> dict[str, Any]:
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
requires_migration = bool(campaign_mail_profile_boundary_violations(source_json))
if requires_migration and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
if requires_migration and raw_json is None:
base_json["server"] = public_campaign_mail_server(source_json)
assert_server_safe_campaign_paths(
base_json,
source_filename=source_filename,
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)
assert_campaign_uses_mail_profile_reference(runtime_json)
mail_integration().assert_campaign_mail_policy_allows_json(
session,
tenant_id=tenant_id,
raw_json=runtime_json,
campaign_id=campaign.id,
)
return runtime_json
def _new_forked_campaign_version(
session: Session,
*,
campaign: Campaign,
source: CampaignVersion,
runtime_json: dict[str, Any],
current_flow: str | None,
current_step: str | None,
editor_state: dict[str, Any] | None,
source_filename: str | None,
source_base_path: str | None,
autosave: bool,
) -> CampaignVersion:
return CampaignVersion(
campaign_id=campaign.id,
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,
workflow_state=CampaignVersionWorkflowState.EDITING.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=(
validate_campaign_editor_state(editor_state)
if editor_state is not None
else campaign_editor_state_for_edit(source.editor_state)
),
autosaved_at=datetime.now(UTC) if autosave else None,
)
def _persist_forked_version(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
commit: bool,
) -> None:
session.add(version)
session.flush()
_apply_campaign_metadata(campaign, version.raw_json)
campaign.current_version_id = version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(campaign)
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
def fork_campaign_version_for_edit(
session: Session,
*,
@@ -354,69 +460,30 @@ def fork_campaign_version_for_edit(
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)
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"
raise LockedCampaignVersionError(
f"Campaign already has active working version #{current_number}. "
"Unlock or continue editing that version instead of creating a parallel draft."
)
if campaign.current_version_id and source.id != campaign.current_version_id:
raise LockedCampaignVersionError(
"Historical versions remain review-only and cannot become a new branch. "
"Create the next working copy from the campaign's current immutable version."
)
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
source_requires_mail_migration = bool(campaign_mail_profile_boundary_violations(source_json))
if source_requires_mail_migration and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
if source_requires_mail_migration and raw_json is None:
base_json["server"] = public_campaign_mail_server(source_json)
assert_server_safe_campaign_paths(
base_json,
_assert_fork_source_allowed(session, campaign=campaign, source=source)
runtime_json = _fork_runtime_json(
session,
tenant_id=tenant_id,
campaign=campaign,
source=source,
raw_json=raw_json,
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
)
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, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
new_version = CampaignVersion(
campaign_id=campaign.id,
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,
workflow_state=CampaignVersionWorkflowState.EDITING.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=(
validate_campaign_editor_state(editor_state)
if editor_state is not None
else campaign_editor_state_for_edit(source.editor_state)
),
autosaved_at=datetime.now(UTC) if autosave else None,
new_version = _new_forked_campaign_version(
session,
campaign=campaign,
source=source,
runtime_json=runtime_json,
current_flow=current_flow,
current_step=current_step,
editor_state=editor_state,
source_filename=source_filename,
source_base_path=source_base_path,
autosave=autosave,
)
session.add(new_version)
session.flush()
_apply_campaign_metadata(campaign, runtime_json)
campaign.current_version_id = new_version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(campaign)
if commit:
_write_campaign_snapshot(new_version)
session.commit()
else:
session.flush()
_persist_forked_version(session, campaign=campaign, version=new_version, commit=commit)
return new_version
@@ -530,6 +597,113 @@ def unlock_validated_campaign_version(
session.flush()
return version
def _assert_update_paths_safe(
raw_json: dict[str, Any] | None,
*,
source_filename: str | None,
source_base_path: str | None,
) -> None:
if raw_json is None and source_filename is None and source_base_path is None:
return
assert_server_safe_campaign_paths(
raw_json if raw_json is not None else {},
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
def _updated_runtime_json(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
version: CampaignVersion,
raw_json: dict[str, Any],
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))
if requires_migration and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
"will not be changed automatically."
)
assert_campaign_uses_mail_profile_reference(runtime_json)
if requires_migration and campaign_mail_profile_id(runtime_json) is None:
raise CampaignPersistenceError(
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
"Select a Mail profile before saving."
)
mail_integration().assert_campaign_mail_policy_allows_json(
session,
tenant_id=tenant_id,
raw_json=runtime_json,
campaign_id=campaign.id,
)
return runtime_json
def _apply_version_field_updates(
version: CampaignVersion,
*,
current_flow: str | None,
current_step: str | None,
workflow_state: str | None,
is_complete: bool | None,
editor_state: dict[str, Any] | None,
source_filename: str | None,
source_base_path: str | None,
autosave: bool,
) -> None:
updates = (
("current_flow", current_flow),
("current_step", current_step),
("workflow_state", workflow_state),
("is_complete", is_complete),
("source_filename", source_filename),
("source_base_path", source_base_path),
)
for field_name, value in updates:
if value is not None:
setattr(version, field_name, value)
if editor_state is not None:
version.editor_state = validate_campaign_editor_state(editor_state)
if autosave:
version.autosaved_at = datetime.now(UTC)
def _invalidate_version_content(session: Session, *, campaign: Campaign, version: CampaignVersion) -> None:
version.validation_summary = None
version.build_summary = None
clear_execution_snapshot(version)
version.locked_at = None
version.locked_by_user_id = None
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)
def _persist_updated_version(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
commit: bool,
) -> None:
session.add(version)
session.add(campaign)
session.flush()
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
def update_campaign_version(
session: Session,
*,
@@ -548,13 +722,7 @@ def update_campaign_version(
migrate_legacy_mail_settings: bool = False,
commit: bool = True,
) -> CampaignVersion:
if raw_json is not None or source_filename is not None or source_base_path is not None:
assert_server_safe_campaign_paths(
raw_json if raw_json is not None else {},
source_filename=source_filename,
source_base_path=source_base_path,
managed_files_available=files_integration().available,
)
_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")
@@ -565,61 +733,35 @@ def update_campaign_version(
)
if raw_json is not None:
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
"will not be changed automatically."
)
assert_campaign_uses_mail_profile_reference(runtime_json)
if campaign_mail_profile_boundary_violations(version.raw_json) and campaign_mail_profile_id(runtime_json) is None:
raise CampaignPersistenceError(
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
"Select a Mail profile before saving."
)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
runtime_json = _updated_runtime_json(
session,
tenant_id=tenant_id,
campaign=campaign,
version=version,
raw_json=raw_json,
source_base_path=source_base_path,
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"))
_apply_campaign_metadata(campaign, runtime_json)
if current_flow is not None:
version.current_flow = current_flow
if current_step is not None:
version.current_step = current_step
if workflow_state is not None:
version.workflow_state = workflow_state
if is_complete is not None:
version.is_complete = is_complete
if editor_state is not None:
version.editor_state = validate_campaign_editor_state(editor_state)
if source_filename is not None:
version.source_filename = source_filename
if source_base_path is not None:
version.source_base_path = source_base_path
if autosave:
version.autosaved_at = datetime.now(UTC)
_apply_version_field_updates(
version,
current_flow=current_flow,
current_step=current_step,
workflow_state=workflow_state,
is_complete=is_complete,
editor_state=editor_state,
source_filename=source_filename,
source_base_path=source_base_path,
autosave=autosave,
)
# Changes invalidate previous build and validation summaries.
if raw_json is not None:
version.validation_summary = None
version.build_summary = None
clear_execution_snapshot(version)
version.locked_at = None
version.locked_by_user_id = None
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.add(version)
session.add(campaign)
session.flush()
if commit:
_write_campaign_snapshot(version)
session.commit()
else:
session.flush()
_invalidate_version_content(session, campaign=campaign, version=version)
_persist_updated_version(session, campaign=campaign, version=version, commit=commit)
return version