Only one writeable campaign version at a time
This commit is contained in:
@@ -135,6 +135,12 @@ 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
|
||||
if current and not _version_is_audit_safe_snapshot(current):
|
||||
raise CampaignPersistenceError(
|
||||
f"Campaign already has active working version #{current.version_number}. "
|
||||
"Continue editing or unlock that version instead of importing a parallel draft."
|
||||
)
|
||||
campaign.name = config.campaign.name
|
||||
campaign.description = config.campaign.description
|
||||
|
||||
@@ -168,6 +174,24 @@ def _version_is_user_locked(version: CampaignVersion) -> bool:
|
||||
return _version_user_lock_state(version) is not None
|
||||
|
||||
|
||||
def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool:
|
||||
return _version_user_lock_state(version) == "permanent" or version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
CampaignVersionWorkflowState.SENDING.value,
|
||||
CampaignVersionWorkflowState.COMPLETED.value,
|
||||
CampaignVersionWorkflowState.CANCELLED.value,
|
||||
CampaignVersionWorkflowState.ARCHIVED.value,
|
||||
}
|
||||
|
||||
|
||||
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}. "
|
||||
"Open the current working version instead."
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
@@ -203,6 +227,7 @@ 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")
|
||||
_ensure_current_campaign_version(campaign, version, action="validate")
|
||||
if _version_is_user_locked(version) or version.workflow_state in {
|
||||
CampaignVersionWorkflowState.QUEUED.value,
|
||||
CampaignVersionWorkflowState.SENDING.value,
|
||||
@@ -320,6 +345,7 @@ def build_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")
|
||||
_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 {}
|
||||
|
||||
@@ -257,6 +257,36 @@ def is_version_locked(version: CampaignVersion) -> bool:
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
place. Continuing from immutable history must create a new working copy,
|
||||
and that copy becomes the campaign's sole current version.
|
||||
"""
|
||||
|
||||
if campaign.current_version_id != version.id:
|
||||
raise LockedCampaignVersionError(
|
||||
f"Historical campaign versions are read-only and cannot be used to {action}. "
|
||||
"Open the current working version instead."
|
||||
)
|
||||
|
||||
|
||||
def campaign_has_active_working_version(session: Session, campaign: Campaign) -> bool:
|
||||
"""Return True while the campaign already has a non-final working version.
|
||||
|
||||
Validation locks and temporary user locks are still the same working
|
||||
version; they must be unlocked rather than forked into parallel drafts.
|
||||
"""
|
||||
|
||||
if not campaign.current_version_id:
|
||||
return False
|
||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if not current or current.campaign_id != campaign.id:
|
||||
return False
|
||||
return not is_audit_safe_version(current)
|
||||
|
||||
|
||||
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 {}
|
||||
if campaign_meta:
|
||||
@@ -279,17 +309,30 @@ def fork_campaign_version_for_edit(
|
||||
source_base_path: str | None = None,
|
||||
autosave: bool = True,
|
||||
) -> CampaignVersion:
|
||||
"""Create a new editable working version from a locked/validated version.
|
||||
"""Create the next sole working version from immutable campaign history.
|
||||
|
||||
This preserves the audit value of the validated/sent version while allowing
|
||||
users to continue editing a campaign. New content starts with the supplied
|
||||
raw_json when provided, otherwise with a clone of the source version.
|
||||
Validation and temporary user locks are still the active working version
|
||||
and must be unlocked in place. A copy is allowed only once the current
|
||||
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)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not 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."
|
||||
)
|
||||
|
||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
|
||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||
|
||||
@@ -381,6 +424,7 @@ def unlock_validated_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
|
||||
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.")
|
||||
@@ -440,6 +484,7 @@ def update_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
|
||||
ensure_current_working_version(campaign, version, action="edit")
|
||||
|
||||
if is_version_locked(version):
|
||||
raise LockedCampaignVersionError(
|
||||
@@ -511,6 +556,9 @@ def update_campaign_review_state(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
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.")
|
||||
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
@@ -555,6 +603,9 @@ def lock_campaign_version_temporarily(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
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.")
|
||||
if is_permanent_user_locked_version(version):
|
||||
@@ -587,6 +638,9 @@ def unlock_user_locked_campaign_version(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
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.")
|
||||
@@ -623,6 +677,9 @@ def permanently_lock_campaign_version(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
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.")
|
||||
if is_permanent_user_locked_version(version):
|
||||
|
||||
Reference in New Issue
Block a user