support versions, campaign structure change

This commit is contained in:
2026-06-11 02:54:39 +02:00
parent 3b06f3670e
commit b67c8abdc5
7 changed files with 318 additions and 40 deletions

2
.gitignore vendored
View File

@@ -181,3 +181,5 @@ runtime
runtime/ runtime/
server/*.db server/*.db
multi-seal-mail*.tar.gz
multisealmail*.zip

View File

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

View File

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

View File

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

View File

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

View File

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