support versions, campaign structure change
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -181,3 +181,5 @@ runtime
|
|||||||
runtime/
|
runtime/
|
||||||
|
|
||||||
server/*.db
|
server/*.db
|
||||||
|
multi-seal-mail*.tar.gz
|
||||||
|
multisealmail*.zip
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.api.v1.schemas import (
|
from app.api.v1.schemas import (
|
||||||
BuildCampaignRequest,
|
BuildCampaignRequest,
|
||||||
CampaignCreateRequest,
|
CampaignCreateRequest,
|
||||||
|
CampaignUpdateRequest,
|
||||||
CampaignCreateResponse,
|
CampaignCreateResponse,
|
||||||
CampaignCreateMinimalRequest,
|
CampaignCreateMinimalRequest,
|
||||||
CampaignJobsResponse,
|
CampaignJobsResponse,
|
||||||
@@ -34,9 +35,14 @@ from app.mailer.persistence.campaigns import (
|
|||||||
validate_campaign_version,
|
validate_campaign_version,
|
||||||
)
|
)
|
||||||
from app.mailer.persistence.versions import (
|
from app.mailer.persistence.versions import (
|
||||||
|
LockedCampaignVersionError,
|
||||||
create_minimal_campaign,
|
create_minimal_campaign,
|
||||||
|
fork_campaign_version_for_edit,
|
||||||
|
is_version_final_locked,
|
||||||
|
is_user_locked_version,
|
||||||
get_campaign_version_for_tenant,
|
get_campaign_version_for_tenant,
|
||||||
publish_campaign_version,
|
publish_campaign_version,
|
||||||
|
unlock_validated_campaign_version,
|
||||||
update_campaign_version,
|
update_campaign_version,
|
||||||
validate_campaign_partial,
|
validate_campaign_partial,
|
||||||
)
|
)
|
||||||
@@ -152,6 +158,50 @@ def get_campaign(
|
|||||||
return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id))
|
return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{campaign_id}", response_model=CampaignResponse)
|
||||||
|
def update_campaign_metadata_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: CampaignUpdateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||||
|
):
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||||
|
if payload.external_id is not None:
|
||||||
|
value = payload.external_id.strip()
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign ID cannot be empty")
|
||||||
|
duplicate = (
|
||||||
|
session.query(Campaign)
|
||||||
|
.filter(Campaign.tenant_id == principal.tenant_id, Campaign.external_id == value, Campaign.id != campaign.id)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if duplicate:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaign ID already exists for this tenant")
|
||||||
|
campaign.external_id = value
|
||||||
|
if payload.name is not None:
|
||||||
|
value = payload.name.strip()
|
||||||
|
if not value:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign name cannot be empty")
|
||||||
|
campaign.name = value
|
||||||
|
if payload.status is not None:
|
||||||
|
campaign.status = payload.status
|
||||||
|
if payload.description is not None:
|
||||||
|
campaign.description = payload.description
|
||||||
|
session.add(campaign)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(campaign)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.metadata_updated",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign.id,
|
||||||
|
details={"external_id": campaign.external_id, "name": campaign.name},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
return CampaignResponse.model_validate(campaign)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
|
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
|
||||||
def list_versions(
|
def list_versions(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
@@ -184,6 +234,90 @@ def get_version_detail(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse)
|
||||||
|
def fork_version_for_edit(
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
payload: CampaignVersionUpdateRequest | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||||
|
):
|
||||||
|
"""Create a new editable campaign version from a locked/validated/sent version.
|
||||||
|
|
||||||
|
Versions that were validated, built, queued or sent are immutable audit
|
||||||
|
snapshots. This endpoint makes an explicit editable copy and makes that
|
||||||
|
new copy the campaign's current version.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = payload or CampaignVersionUpdateRequest()
|
||||||
|
try:
|
||||||
|
version = fork_campaign_version_for_edit(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
raw_json=payload.campaign_json,
|
||||||
|
current_flow=payload.current_flow or "manual",
|
||||||
|
current_step=payload.current_step,
|
||||||
|
editor_state=payload.editor_state,
|
||||||
|
source_filename=payload.source_filename,
|
||||||
|
source_base_path=payload.source_base_path,
|
||||||
|
autosave=True,
|
||||||
|
)
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.version_forked_for_edit",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version.id,
|
||||||
|
details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
return CampaignCreateResponse(
|
||||||
|
campaign=CampaignResponse.model_validate(campaign),
|
||||||
|
version=CampaignVersionResponse.model_validate(version),
|
||||||
|
)
|
||||||
|
except CampaignPersistenceError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse)
|
||||||
|
def unlock_version_validation(
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaign:write")),
|
||||||
|
):
|
||||||
|
"""Unlock a successfully validated version before delivery starts.
|
||||||
|
|
||||||
|
Unlocking invalidates validation/build state and removes generated jobs for
|
||||||
|
that version. Sent/final versions cannot be unlocked and must be copied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = unlock_validated_campaign_version(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.version_validation_unlocked",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version.id,
|
||||||
|
details={"campaign_id": campaign_id},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
return CampaignVersionDetailResponse.model_validate(version)
|
||||||
|
except LockedCampaignVersionError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
except CampaignPersistenceError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
|
@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse)
|
||||||
def update_version_detail(
|
def update_version_detail(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
@@ -218,6 +352,8 @@ def update_version_detail(
|
|||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
return CampaignVersionDetailResponse.model_validate(version)
|
||||||
|
except LockedCampaignVersionError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -258,6 +394,8 @@ def autosave_version(
|
|||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
return CampaignVersionDetailResponse.model_validate(version)
|
||||||
|
except LockedCampaignVersionError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -283,6 +421,8 @@ def set_version_step(
|
|||||||
autosave=True,
|
autosave=True,
|
||||||
)
|
)
|
||||||
return CampaignVersionDetailResponse.model_validate(version)
|
return CampaignVersionDetailResponse.model_validate(version)
|
||||||
|
except LockedCampaignVersionError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -346,6 +486,12 @@ def validate_version(
|
|||||||
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
|
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||||
|
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="This version is audit-safe/final and cannot be validated again. Create an editable copy instead.",
|
||||||
|
)
|
||||||
result = validate_campaign_version(
|
result = validate_campaign_version(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
@@ -608,25 +754,23 @@ def send_campaign_now_endpoint(
|
|||||||
if not version_id:
|
if not version_id:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version")
|
||||||
|
|
||||||
validation_result: dict[str, object] | None = None
|
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||||
|
validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None
|
||||||
build_result: dict[str, object] | None = None
|
build_result: dict[str, object] | None = None
|
||||||
if payload.validate_before_send:
|
if is_user_locked_version(version):
|
||||||
validation_result = validate_campaign_version(
|
raise HTTPException(
|
||||||
session,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
tenant_id=principal.tenant_id,
|
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
|
||||||
version_id=version_id,
|
|
||||||
check_files=payload.check_files,
|
|
||||||
user_id=principal.user.id,
|
|
||||||
)
|
)
|
||||||
if not validation_result.get("ok"):
|
if not version.locked_at or not validation_result or validation_result.get("ok") is not True:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"message": "Campaign validation failed", "validation": validation_result})
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
if payload.build_before_send:
|
detail="Campaign version must be validated and locked before dry-run or sending.",
|
||||||
build_result = build_campaign_version(
|
)
|
||||||
session,
|
if not version.build_summary:
|
||||||
tenant_id=principal.tenant_id,
|
raise HTTPException(
|
||||||
version_id=version_id,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
write_eml=True,
|
detail="Campaign version must be built before dry-run or sending.",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = send_campaign_now(
|
result = send_campaign_now(
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ class CampaignCreateRequest(BaseModel):
|
|||||||
source_base_path: str | None = None
|
source_base_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
external_id: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CampaignCreateMinimalRequest(BaseModel):
|
class CampaignCreateMinimalRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -205,8 +216,8 @@ class SendCampaignNowRequest(BaseModel):
|
|||||||
version_id: str | None = None
|
version_id: str | None = None
|
||||||
include_warnings: bool = True
|
include_warnings: bool = True
|
||||||
check_files: bool = False
|
check_files: bool = False
|
||||||
validate_before_send: bool = True
|
validate_before_send: bool = False
|
||||||
build_before_send: bool = True
|
build_before_send: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
use_rate_limit: bool = True
|
use_rate_limit: bool = True
|
||||||
enqueue_imap_task: bool = False
|
enqueue_imap_task: bool = False
|
||||||
|
|||||||
@@ -147,6 +147,23 @@ def create_campaign_version_from_json(
|
|||||||
return campaign, version
|
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):
|
def load_version_config(session: Session, version_id: str):
|
||||||
version = session.get(CampaignVersion, version_id)
|
version = session.get(CampaignVersion, version_id)
|
||||||
if not version:
|
if not version:
|
||||||
@@ -168,6 +185,14 @@ def validate_campaign_version(
|
|||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_id:
|
if not campaign or campaign.tenant_id != tenant_id:
|
||||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
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 = validate_campaign_config(config, campaign_file=snapshot_path, check_files=check_files)
|
||||||
report_json = report.model_dump(mode="json")
|
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 {}
|
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
||||||
if not validation_summary.get("ok"):
|
if not validation_summary.get("ok"):
|
||||||
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
|
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
|
||||||
if version.locked_at is None:
|
_ensure_version_validated_and_locked(version)
|
||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
version.locked_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
|
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)
|
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,
|
CampaignVersion,
|
||||||
CampaignVersionFlow,
|
CampaignVersionFlow,
|
||||||
CampaignVersionWorkflowState,
|
CampaignVersionWorkflowState,
|
||||||
|
CampaignJob,
|
||||||
|
JobSendStatus,
|
||||||
)
|
)
|
||||||
from app.mailer.campaign.loader import load_campaign_config
|
from app.mailer.campaign.loader import load_campaign_config
|
||||||
from app.mailer.persistence.campaigns import (
|
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]:
|
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
||||||
"""Return a WebUI-friendly starter campaign JSON.
|
"""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_at = datetime.now(UTC)
|
||||||
version.locked_by_user_id = user_id
|
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(
|
def update_campaign_version(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -311,20 +402,8 @@ def update_campaign_version(
|
|||||||
assert campaign is not None
|
assert campaign is not None
|
||||||
|
|
||||||
if is_version_locked(version):
|
if is_version_locked(version):
|
||||||
if raw_json is None:
|
raise LockedCampaignVersionError(
|
||||||
raise CampaignPersistenceError("Campaign version is locked. Save campaign changes to create a new editable version.")
|
"Campaign version is locked. Create an editable copy before changing campaign data."
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if raw_json is not None:
|
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)
|
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)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
assert campaign is not None
|
assert campaign is not None
|
||||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
now = datetime.now(UTC)
|
||||||
version.published_at = 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.current_version_id = version.id
|
||||||
campaign.status = CampaignStatus.VALIDATED.value
|
campaign.status = CampaignStatus.ARCHIVED.value
|
||||||
session.add(version)
|
session.add(version)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.commit()
|
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:
|
def _utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
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)
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
version = _get_current_version(session, campaign, version_id=version_id)
|
version = _get_current_version(session, campaign, version_id=version_id)
|
||||||
|
_ensure_version_validated_and_locked(version)
|
||||||
|
|
||||||
allowed_validation = {JobValidationStatus.READY.value}
|
allowed_validation = {JobValidationStatus.READY.value}
|
||||||
if include_warnings:
|
if include_warnings:
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user