Refactor campaign delivery decision paths
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user