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

View File

@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from app.api.v1.schemas import (
BuildCampaignRequest,
CampaignCreateRequest,
CampaignUpdateRequest,
CampaignCreateResponse,
CampaignCreateMinimalRequest,
CampaignJobsResponse,
@@ -34,9 +35,14 @@ from app.mailer.persistence.campaigns import (
validate_campaign_version,
)
from app.mailer.persistence.versions import (
LockedCampaignVersionError,
create_minimal_campaign,
fork_campaign_version_for_edit,
is_version_final_locked,
is_user_locked_version,
get_campaign_version_for_tenant,
publish_campaign_version,
unlock_validated_campaign_version,
update_campaign_version,
validate_campaign_partial,
)
@@ -152,6 +158,50 @@ def get_campaign(
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])
def list_versions(
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
@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)
def update_version_detail(
campaign_id: str,
@@ -218,6 +352,8 @@ def update_version_detail(
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
except Exception as exc:
@@ -258,6 +394,8 @@ def autosave_version(
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
except Exception as exc:
@@ -283,6 +421,8 @@ def set_version_step(
autosave=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
@@ -346,6 +486,12 @@ def validate_version(
principal: ApiPrincipal = Depends(require_scope("campaign:validate")),
):
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(
session,
tenant_id=principal.tenant_id,
@@ -608,25 +754,23 @@ def send_campaign_now_endpoint(
if not version_id:
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
if payload.validate_before_send:
validation_result = validate_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
check_files=payload.check_files,
user_id=principal.user.id,
if is_user_locked_version(version):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
)
if not validation_result.get("ok"):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"message": "Campaign validation failed", "validation": validation_result})
if payload.build_before_send:
build_result = build_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=True,
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="Campaign version must be validated and locked before dry-run or sending.",
)
if not version.build_summary:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Campaign version must be built before dry-run or sending.",
)
result = send_campaign_now(