campaign version refinment, user locks, db repair

This commit is contained in:
2026-06-13 19:25:23 +02:00
parent fe5ac084b7
commit ffbddfc773
18 changed files with 896 additions and 39 deletions

View File

@@ -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)

View File

@@ -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.