support versions, campaign structure change
This commit is contained in:
@@ -147,6 +147,23 @@ def create_campaign_version_from_json(
|
||||
return campaign, version
|
||||
|
||||
|
||||
|
||||
|
||||
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
||||
return bool(version.published_at)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
||||
if _version_is_user_locked(version):
|
||||
raise CampaignPersistenceError("User-locked audit-safe versions cannot be built, queued, dry-run or sent. 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.")
|
||||
|
||||
def load_version_config(session: Session, version_id: str):
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if not version:
|
||||
@@ -168,6 +185,14 @@ def validate_campaign_version(
|
||||
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")
|
||||
if version.published_at or version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
CampaignVersionWorkflowState.SENDING.value,
|
||||
CampaignVersionWorkflowState.COMPLETED.value,
|
||||
CampaignVersionWorkflowState.CANCELLED.value,
|
||||
CampaignVersionWorkflowState.ARCHIVED.value,
|
||||
}:
|
||||
raise CampaignPersistenceError("Audit-safe/final campaign versions cannot be validated. Create an editable copy instead.")
|
||||
|
||||
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=check_files)
|
||||
report_json = report.model_dump(mode="json")
|
||||
@@ -269,10 +294,7 @@ def build_campaign_version(
|
||||
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")
|
||||
if version.locked_at is None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
version.locked_at = datetime.now(UTC)
|
||||
_ensure_version_validated_and_locked(version)
|
||||
|
||||
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
|
||||
result = build_campaign_messages(config, campaign_file=snapshot_path, output_dir=output_dir, write_eml=write_eml)
|
||||
|
||||
@@ -15,6 +15,8 @@ from app.db.models import (
|
||||
CampaignVersion,
|
||||
CampaignVersionFlow,
|
||||
CampaignVersionWorkflowState,
|
||||
CampaignJob,
|
||||
JobSendStatus,
|
||||
)
|
||||
from app.mailer.campaign.loader import load_campaign_config
|
||||
from app.mailer.persistence.campaigns import (
|
||||
@@ -27,6 +29,11 @@ from app.mailer.persistence.campaigns import (
|
||||
)
|
||||
|
||||
|
||||
class LockedCampaignVersionError(CampaignPersistenceError):
|
||||
"""Raised when a caller tries to edit an immutable campaign version."""
|
||||
|
||||
|
||||
|
||||
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
||||
"""Return a WebUI-friendly starter campaign JSON.
|
||||
|
||||
@@ -290,6 +297,90 @@ def lock_validated_version(version: CampaignVersion, *, user_id: str | None = No
|
||||
version.locked_at = datetime.now(UTC)
|
||||
version.locked_by_user_id = user_id
|
||||
|
||||
|
||||
def is_version_final_locked(version: CampaignVersion) -> bool:
|
||||
"""Return True when a version is part of or past delivery and must stay immutable."""
|
||||
|
||||
return version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
CampaignVersionWorkflowState.SENDING.value,
|
||||
CampaignVersionWorkflowState.COMPLETED.value,
|
||||
CampaignVersionWorkflowState.CANCELLED.value,
|
||||
CampaignVersionWorkflowState.ARCHIVED.value,
|
||||
}
|
||||
|
||||
|
||||
def is_user_locked_version(version: CampaignVersion) -> bool:
|
||||
"""Return True when a user explicitly locked a version as an audit-safe snapshot."""
|
||||
|
||||
return bool(version.published_at)
|
||||
|
||||
|
||||
def is_audit_safe_version(version: CampaignVersion) -> bool:
|
||||
"""Return True when a version is immutable and cannot be unlocked."""
|
||||
|
||||
return is_user_locked_version(version) or is_version_final_locked(version)
|
||||
|
||||
|
||||
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 {}
|
||||
return bool(version.locked_at and validation.get("ok") is True)
|
||||
|
||||
|
||||
def unlock_validated_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> CampaignVersion:
|
||||
"""Unlock a validation snapshot so it can be edited again.
|
||||
|
||||
This is only allowed before delivery starts. Unlocking invalidates validation,
|
||||
build output and queued job records for that version. Sent/final versions must
|
||||
be copied instead.
|
||||
"""
|
||||
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
|
||||
if is_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version was locked as an audit-safe snapshot 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.")
|
||||
|
||||
# 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 == JobSendStatus.SENT.value,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if sent_jobs:
|
||||
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
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
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)
|
||||
|
||||
campaign.current_version_id = version.id
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
def update_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -311,20 +402,8 @@ def update_campaign_version(
|
||||
assert campaign is not None
|
||||
|
||||
if is_version_locked(version):
|
||||
if raw_json is None:
|
||||
raise CampaignPersistenceError("Campaign version is locked. Save campaign changes to create a new editable version.")
|
||||
return fork_campaign_version_for_edit(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
raw_json=raw_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,
|
||||
raise LockedCampaignVersionError(
|
||||
"Campaign version is locked. Create an editable copy before changing campaign data."
|
||||
)
|
||||
|
||||
if raw_json is not None:
|
||||
@@ -379,10 +458,13 @@ def publish_campaign_version(
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
version.published_at = datetime.now(UTC)
|
||||
now = datetime.now(UTC)
|
||||
version.workflow_state = CampaignVersionWorkflowState.ARCHIVED.value
|
||||
version.published_at = now
|
||||
if version.locked_at is None:
|
||||
version.locked_at = now
|
||||
campaign.current_version_id = version.id
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
campaign.status = CampaignStatus.ARCHIVED.value
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
|
||||
@@ -130,6 +130,22 @@ QUEUEABLE_VALIDATION_STATUSES = {
|
||||
}
|
||||
|
||||
|
||||
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
||||
return bool(version.published_at)
|
||||
|
||||
|
||||
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||
validation = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
||||
return bool(version.locked_at and validation.get("ok") is True and not _version_is_user_locked(version))
|
||||
|
||||
|
||||
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
||||
if _version_is_user_locked(version):
|
||||
raise QueueingError("User-locked audit-safe versions cannot be queued, dry-run or sent. Create an editable copy instead.")
|
||||
if not _version_is_validated_and_locked(version):
|
||||
raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -182,6 +198,7 @@ def queue_campaign_jobs(
|
||||
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_current_version(session, campaign, version_id=version_id)
|
||||
_ensure_version_validated_and_locked(version)
|
||||
|
||||
allowed_validation = {JobValidationStatus.READY.value}
|
||||
if include_warnings:
|
||||
|
||||
Reference in New Issue
Block a user