campaign version refinment, user locks, db repair
This commit is contained in:
@@ -4,6 +4,7 @@ import copy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -33,6 +34,26 @@ class LockedCampaignVersionError(CampaignPersistenceError):
|
||||
"""Raised when a caller tries to edit an immutable campaign version."""
|
||||
|
||||
|
||||
USER_LOCK_TEMPORARY = "temporary"
|
||||
USER_LOCK_PERMANENT = "permanent"
|
||||
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
|
||||
|
||||
|
||||
def campaign_version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||
"""Return the explicit user-lock state with backwards compatibility.
|
||||
|
||||
Older databases represented a permanent user lock only through
|
||||
published_at. Treat those rows as permanent until the migration has
|
||||
backfilled the explicit state.
|
||||
"""
|
||||
|
||||
state = getattr(version, "user_lock_state", None)
|
||||
if state in USER_LOCK_STATES:
|
||||
return state
|
||||
if version.published_at:
|
||||
return USER_LOCK_PERMANENT
|
||||
return None
|
||||
|
||||
|
||||
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
||||
"""Return a WebUI-friendly starter campaign JSON.
|
||||
@@ -227,9 +248,13 @@ LOCKED_WORKFLOW_STATES = {
|
||||
|
||||
|
||||
def is_version_locked(version: CampaignVersion) -> bool:
|
||||
"""Return True when a version is immutable and edits must fork."""
|
||||
"""Return True when a version is immutable and edits must fork/unlock."""
|
||||
|
||||
return bool(version.locked_at or version.workflow_state in LOCKED_WORKFLOW_STATES)
|
||||
return bool(
|
||||
version.locked_at
|
||||
or campaign_version_user_lock_state(version)
|
||||
or version.workflow_state in LOCKED_WORKFLOW_STATES
|
||||
)
|
||||
|
||||
|
||||
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
|
||||
@@ -312,16 +337,24 @@ def is_version_final_locked(version: CampaignVersion) -> bool:
|
||||
}
|
||||
|
||||
|
||||
def is_user_locked_version(version: CampaignVersion) -> bool:
|
||||
"""Return True when a user explicitly locked a version as an audit-safe snapshot."""
|
||||
def is_temporary_user_locked_version(version: CampaignVersion) -> bool:
|
||||
return campaign_version_user_lock_state(version) == USER_LOCK_TEMPORARY
|
||||
|
||||
return bool(version.published_at)
|
||||
|
||||
def is_permanent_user_locked_version(version: CampaignVersion) -> bool:
|
||||
return campaign_version_user_lock_state(version) == USER_LOCK_PERMANENT
|
||||
|
||||
|
||||
def is_user_locked_version(version: CampaignVersion) -> bool:
|
||||
"""Return True for either reversible or permanent user-requested locks."""
|
||||
|
||||
return campaign_version_user_lock_state(version) is not None
|
||||
|
||||
|
||||
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)
|
||||
return is_permanent_user_locked_version(version) or is_version_final_locked(version)
|
||||
|
||||
|
||||
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
|
||||
@@ -349,8 +382,10 @@ def unlock_validated_campaign_version(
|
||||
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_temporary_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.")
|
||||
if is_permanent_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version is permanently locked 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.")
|
||||
|
||||
@@ -370,6 +405,9 @@ def unlock_validated_campaign_version(
|
||||
version.locked_by_user_id = None
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
version.is_complete = False
|
||||
|
||||
@@ -450,29 +488,176 @@ def update_campaign_version(
|
||||
return version
|
||||
|
||||
|
||||
def publish_campaign_version(
|
||||
def update_campaign_review_state(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
inspection_complete: bool,
|
||||
reviewed_message_keys: list[str],
|
||||
user_id: str | None,
|
||||
) -> CampaignVersion:
|
||||
"""Persist review acknowledgement without mutating the locked campaign data.
|
||||
|
||||
Validation locks make the campaign JSON immutable, but review metadata is
|
||||
operational state attached to a specific build. It is therefore stored in
|
||||
editor_state and tied to the current build token so a rebuild invalidates it.
|
||||
"""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
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 {}
|
||||
if not build_summary:
|
||||
raise CampaignPersistenceError("Build messages before recording review state.")
|
||||
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
|
||||
if not build_token:
|
||||
# Backwards-compatible upgrade for build summaries created before
|
||||
# review-state tokens were introduced.
|
||||
build_token = uuid4().hex
|
||||
build_summary = copy.deepcopy(build_summary)
|
||||
build_summary["build_token"] = build_token
|
||||
version.build_summary = build_summary
|
||||
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state["review_send"] = {
|
||||
"build_token": build_token,
|
||||
"inspection_complete": bool(inspection_complete),
|
||||
"reviewed_message_keys": list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip())),
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"updated_by_user_id": user_id,
|
||||
}
|
||||
version.editor_state = editor_state
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def lock_campaign_version_temporarily(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
user_id: str | None,
|
||||
) -> CampaignVersion:
|
||||
"""Apply a reversible user-requested lock without changing workflow state."""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
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):
|
||||
raise LockedCampaignVersionError("This version is already permanently locked.")
|
||||
if is_temporary_user_locked_version(version):
|
||||
return version
|
||||
if version.locked_at:
|
||||
raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.")
|
||||
|
||||
version.user_lock_state = USER_LOCK_TEMPORARY
|
||||
version.user_locked_at = datetime.now(UTC)
|
||||
version.user_locked_by_user_id = user_id
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def unlock_user_locked_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> CampaignVersion:
|
||||
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
|
||||
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.ARCHIVED.value
|
||||
"""Remove a reversible user lock without invalidating campaign data."""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
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.")
|
||||
if state != USER_LOCK_TEMPORARY:
|
||||
raise LockedCampaignVersionError("This version does not have a temporary user lock.")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.")
|
||||
|
||||
version.user_lock_state = None
|
||||
version.user_locked_at = None
|
||||
version.user_locked_by_user_id = None
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def permanently_lock_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
user_id: str | None,
|
||||
) -> CampaignVersion:
|
||||
"""Apply an irreversible user lock.
|
||||
|
||||
The version remains in its current workflow state so the campaign itself is
|
||||
not silently archived. Future changes must be made in an editable copy.
|
||||
"""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
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):
|
||||
return version
|
||||
|
||||
now = datetime.now(UTC)
|
||||
version.user_lock_state = USER_LOCK_PERMANENT
|
||||
version.user_locked_at = now
|
||||
version.user_locked_by_user_id = user_id
|
||||
# Retain published_at as a compatibility marker for existing integrations.
|
||||
version.published_at = version.published_at or now
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def publish_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
user_id: str | None = None,
|
||||
) -> CampaignVersion:
|
||||
"""Backwards-compatible alias for the permanent user lock."""
|
||||
|
||||
return permanently_lock_campaign_version(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
|
||||
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user