campaign version refinment, user locks, db repair
This commit is contained in:
@@ -4,6 +4,8 @@ import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -29,6 +31,7 @@ from app.storage.services import record_campaign_attachment_uses_for_job
|
||||
from app.storage.campaign_attachments import (
|
||||
annotate_built_messages_with_managed_files,
|
||||
prepared_campaign_snapshot,
|
||||
public_attachment_summary_payload,
|
||||
)
|
||||
|
||||
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
||||
@@ -154,8 +157,15 @@ def create_campaign_version_from_json(
|
||||
|
||||
|
||||
|
||||
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||
state = getattr(version, "user_lock_state", None)
|
||||
if state in {"temporary", "permanent"}:
|
||||
return state
|
||||
return "permanent" if version.published_at else None
|
||||
|
||||
|
||||
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
||||
return bool(version.published_at)
|
||||
return _version_user_lock_state(version) is not None
|
||||
|
||||
|
||||
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||
@@ -164,8 +174,11 @@ def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
||||
|
||||
|
||||
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.")
|
||||
state = _version_user_lock_state(version)
|
||||
if state == "temporary":
|
||||
raise CampaignPersistenceError("This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending.")
|
||||
if state == "permanent":
|
||||
raise CampaignPersistenceError("This version is permanently user-locked. 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.")
|
||||
|
||||
@@ -190,14 +203,15 @@ 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 {
|
||||
if _version_is_user_locked(version) 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.")
|
||||
lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final"
|
||||
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
|
||||
|
||||
if check_files:
|
||||
with prepared_campaign_snapshot(
|
||||
@@ -289,7 +303,7 @@ def _job_from_message(
|
||||
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
||||
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
||||
},
|
||||
resolved_attachments=[item.model_dump(mode="json") for item in message.attachments],
|
||||
resolved_attachments=[public_attachment_summary_payload(item) for item in message.attachments],
|
||||
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
||||
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
||||
)
|
||||
@@ -326,6 +340,11 @@ def build_campaign_version(
|
||||
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
|
||||
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
||||
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"] = [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,
|
||||
@@ -338,6 +357,9 @@ def build_campaign_version(
|
||||
"queueable_count": result.report.queueable_count,
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -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