refactor(api): split campaign workflow routers

This commit is contained in:
2026-07-29 20:07:11 +02:00
parent dd9592a192
commit 2199187e8b
22 changed files with 5595 additions and 4437 deletions
@@ -0,0 +1,667 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.schemas import (
BuildCampaignRequest,
CampaignCreateResponse,
CampaignResponse,
CampaignVersionDetailResponse,
CampaignVersionResponse,
CampaignVersionSetStepRequest,
CampaignReviewStateRequest,
CampaignVersionUpdateRequest,
CampaignPartialValidationRequest,
CampaignPartialValidationResponse,
ValidateCampaignRequest,
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import (
CampaignVersion,
)
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
)
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
build_campaign_version,
validate_campaign_version,
)
from govoplan_campaign.backend.path_security import CampaignPathSecurityError
from govoplan_campaign.backend.persistence.versions import (
LockedCampaignVersionError,
fork_campaign_version_for_edit,
is_version_final_locked,
is_user_locked_version,
get_campaign_version_for_tenant,
lock_campaign_version_temporarily,
permanently_lock_campaign_version,
publish_campaign_version,
unlock_user_locked_campaign_version,
unlock_validated_campaign_version,
update_campaign_version,
update_campaign_review_state,
validate_campaign_partial,
)
from govoplan_campaign.backend.route_support import (
_campaign_response_context,
_campaign_version_detail_response,
_get_campaign_for_principal,
_get_campaign_for_tenant,
_get_version_for_principal,
_get_version_for_tenant,
_require_mail_profile_use_if_needed,
_require_permission,
_update_campaign_version_detail_response,
_write_current_version_snapshot_if_available,
bounded_query_rows as _bounded_query_rows,
)
from govoplan_campaign.backend.routes.attachments import (
CampaignAttachmentLinkMatchesResponse,
_link_campaign_attachment_matches,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
def list_versions(
campaign_id: str,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
versions = _bounded_query_rows(
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc()),
limit=limit,
label="Campaign version history",
)
return [
CampaignVersionResponse.model_validate(
item,
context=_campaign_response_context(principal),
)
for item in versions
]
@router.get(
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
)
def get_version_detail(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:recipient:read")
try:
version = get_campaign_version_for_tenant(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
return CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
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}/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("campaigns:campaign:copy")),
):
"""Create the campaign's next and only editable working version.
A new working copy may be created only after the current version is
permanently user-locked or delivery-final. Validation and temporary user
locks must be removed in place instead of creating parallel drafts.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignVersionUpdateRequest()
source_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
if source_version.campaign_id != campaign_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
)
effective_json = (
payload.campaign_json
if isinstance(payload.campaign_json, dict)
else source_version.raw_json
)
_require_mail_profile_use_if_needed(
principal,
effective_json if isinstance(effective_json, dict) else {},
)
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,
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
commit=False,
)
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,
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
},
commit=True,
)
_write_current_version_snapshot_if_available(version)
return CampaignCreateResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
)
except LockedCampaignVersionError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except CampaignPathSecurityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except CampaignPersistenceError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except Exception:
session.rollback()
raise
@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("campaigns:campaign:update")),
):
"""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.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: unlock_validated_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
commit=False,
),
audit_action="campaign.version_validation_unlocked",
)
@router.post(
"/{campaign_id}/versions/{version_id}/lock-temporarily",
response_model=CampaignVersionDetailResponse,
)
def lock_version_temporarily(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: lock_campaign_version_temporarily(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_temporarily",
)
@router.post(
"/{campaign_id}/versions/{version_id}/unlock-user-lock",
response_model=CampaignVersionDetailResponse,
)
def unlock_version_user_lock(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: unlock_user_locked_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
commit=False,
),
audit_action="campaign.version_user_lock_removed",
)
@router.post(
"/{campaign_id}/versions/{version_id}/lock-permanently",
response_model=CampaignVersionDetailResponse,
)
def lock_version_permanently(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: permanently_lock_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_permanently",
)
@router.put(
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
)
def update_version_detail(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
return _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
autosave=False,
audit_action="campaign.version_updated",
)
@router.post(
"/{campaign_id}/versions/{version_id}/autosave",
response_model=CampaignVersionDetailResponse,
)
def autosave_version(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
return _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
autosave=True,
audit_action="campaign.version_autosaved",
)
@router.post(
"/{campaign_id}/versions/{version_id}/set-step",
response_model=CampaignVersionDetailResponse,
)
def set_version_step(
campaign_id: str,
version_id: str,
payload: CampaignVersionSetStepRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: update_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
current_flow=payload.current_flow,
current_step=payload.current_step,
autosave=True,
commit=False,
),
audit_action="campaign.version_step_updated",
details={
"campaign_id": campaign_id,
"current_flow": payload.current_flow,
"current_step": payload.current_step,
},
)
@router.post(
"/{campaign_id}/versions/{version_id}/review-state",
response_model=CampaignVersionDetailResponse,
)
def set_version_review_state(
campaign_id: str,
version_id: str,
payload: CampaignReviewStateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
try:
version = update_campaign_review_state(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
inspection_complete=payload.inspection_complete,
reviewed_message_keys=payload.reviewed_message_keys,
user_id=principal.user.id,
commit=False,
)
audit_from_principal(
session,
principal,
action="campaign.message_review_updated",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"inspection_complete": payload.inspection_complete,
"reviewed_message_count": len(payload.reviewed_message_keys),
},
commit=True,
)
return CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
except LockedCampaignVersionError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except CampaignPersistenceError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except Exception:
session.rollback()
raise
@router.post(
"/{campaign_id}/versions/{version_id}/validate-partial",
response_model=CampaignPartialValidationResponse,
)
def validate_version_partial(
campaign_id: str,
version_id: str,
payload: CampaignPartialValidationRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
try:
version = get_campaign_version_for_tenant(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
campaign_json = (
payload.campaign_json
if payload and payload.campaign_json is not None
else version.raw_json
)
result = validate_campaign_partial(
campaign_json, section=payload.section if payload else None
)
audit_from_principal(
session,
principal,
action="campaign.version_partially_validated",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"section": result.get("section"),
"ok": result.get("ok"),
},
commit=True,
)
return CampaignPartialValidationResponse(**result)
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}/publish",
response_model=CampaignVersionDetailResponse,
)
def publish_version(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
return _campaign_version_detail_response(
session,
principal,
campaign_id,
lambda: publish_campaign_version(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
user_id=principal.user.id,
commit=False,
),
audit_action="campaign.version_user_locked_permanently",
)
@router.post("/versions/{version_id}/validate")
def validate_version(
version_id: str,
payload: ValidateCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
):
_get_version_for_principal(session, version_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or ValidateCampaignRequest()
try:
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
_require_mail_profile_use_if_needed(
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
)
if is_user_locked_version(version) or is_version_final_locked(version):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.",
)
link_result: CampaignAttachmentLinkMatchesResponse | None = None
if payload.check_files and payload.link_unshared_matches:
_require_permission(principal, "files:file:share")
campaign = _get_campaign_for_tenant(
session, version.campaign_id, principal.tenant_id
)
link_result = _link_campaign_attachment_matches(
session,
principal,
campaign=campaign,
version=version,
raw=version.raw_json if isinstance(version.raw_json, dict) else {},
dry_run=False,
)
audit_from_principal(
session,
principal,
action="campaign.attachment_matches_linked",
object_type="campaign_version",
object_id=version_id,
details={
"matched_file_count": link_result.matched_file_count,
"already_linked_file_count": link_result.already_linked_file_count,
"linked_file_count": link_result.linked_file_count,
"during_validation": True,
},
commit=True,
)
result = validate_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
check_files=payload.check_files,
user_id=principal.user.id,
)
audit_from_principal(
session,
principal,
action="campaign.validated",
object_type="campaign_version",
object_id=version_id,
details={
"check_files": payload.check_files,
"link_unshared_matches": payload.link_unshared_matches,
"linked_file_count": link_result.linked_file_count
if link_result
else 0,
"ok": result.get("ok"),
},
commit=True,
)
return public_campaign_payload(
result,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except HTTPException:
raise
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
@router.post("/versions/{version_id}/build")
def build_version(
version_id: str,
payload: BuildCampaignRequest | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")),
):
version = _get_version_for_principal(session, version_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
_require_mail_profile_use_if_needed(
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
)
try:
result = build_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=payload.write_eml if payload else True,
)
audit_from_principal(
session,
principal,
action="campaign.messages_built",
object_type="campaign_version",
object_id=version_id,
details={
"write_eml": payload.write_eml if payload else True,
"built_count": result.get("built_count"),
},
commit=True,
)
return public_campaign_payload(
result,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc