Files
govoplan-campaign/src/govoplan_campaign/backend/routes/versions.py
T

1057 lines
36 KiB
Python

from __future__ import annotations
import hashlib
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, 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_core.core.object_storage import StorageBackendError
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
RecoveryPlan,
)
from govoplan_core.core.recovery_runtime import (
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
)
from govoplan_campaign.backend.db.models import (
CampaignVersion,
)
from govoplan_core.db.session import get_database, get_session
from govoplan_core.server.runtime_agent import application_runtime_identity
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
)
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_object_storage,
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"])
def _canonical_sha256(value: object) -> str:
return hashlib.sha256(
json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
).hexdigest()
def _campaign_build_recovery_plan(raw_json: dict[str, object]) -> RecoveryPlan:
delivery = raw_json.get("delivery")
print_config = delivery.get("print") if isinstance(delivery, dict) else None
persists_managed_output = bool(
isinstance(print_config, dict)
and print_config.get("persist_to_files", True)
)
if persists_managed_output:
return RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=("validated Campaign version is locked",),
forward_recovery_steps=(
"reuse the Templates render idempotency key",
"reconcile the managed output and Campaign build manifests",
),
verification_steps=(
"compare committed Campaign jobs with generated object evidence",
),
)
return RecoveryPlan(
mode=RecoveryMode.COMPENSATION,
preconditions=("validated Campaign version is locked",),
compensation_steps=("delete every object under the reserved build prefix",),
verification_steps=(
"compare committed Campaign jobs with generated object evidence",
),
)
@router.get("/{campaign_id}/versions/{version_id}/print-output/download")
def download_print_output(
campaign_id: str,
version_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
) -> Response:
_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,
)
except CampaignPersistenceError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
build_summary = (
version.build_summary if isinstance(version.build_summary, dict) else {}
)
print_output = build_summary.get("print_output")
artifact = (
print_output.get("artifact")
if isinstance(print_output, dict)
and isinstance(print_output.get("artifact"), dict)
else {}
)
storage_key = artifact.get("storage_key")
if artifact.get("kind") != "bounded_download" or not storage_key:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="This printable output is not stored as a Campaign download.",
)
try:
payload = _object_storage().get_bytes(str(storage_key))
except StorageBackendError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The printable output is temporarily unavailable.",
) from exc
expected_sha256 = str(print_output.get("output_sha256") or "")
actual_sha256 = hashlib.sha256(payload).hexdigest()
if not expected_sha256 or actual_sha256 != expected_sha256:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The printable output failed its integrity check.",
)
audit_from_principal(
session,
principal,
action="campaign.print_output_downloaded",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign_id,
"output_sha256": actual_sha256,
"template_id": print_output.get("template_id"),
"template_revision_id": print_output.get("template_revision_id"),
},
commit=True,
)
filename = quote(
str(artifact.get("filename") or "campaign-print-output.html"),
safe="._-",
)
return Response(
content=payload,
media_type=str(artifact.get("content_type") or "application/octet-stream"),
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{filename}",
"X-Content-SHA256": actual_sha256,
"X-Content-Type-Options": "nosniff",
},
)
@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,
response: Response,
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,
)
result = CampaignVersionDetailResponse.model_validate(
version,
context=_campaign_response_context(principal),
)
response.headers["ETag"] = version.strong_etag
return 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}/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,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
result = _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
if_match=if_match,
autosave=False,
audit_action="campaign.version_updated",
)
response.headers["ETag"] = result.strong_etag
return result
@router.post(
"/{campaign_id}/versions/{version_id}/autosave",
response_model=CampaignVersionDetailResponse,
)
def autosave_version(
campaign_id: str,
version_id: str,
payload: CampaignVersionUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
result = _update_campaign_version_detail_response(
session,
principal,
campaign_id,
version_id,
payload,
if_match=if_match,
autosave=True,
audit_action="campaign.version_autosaved",
)
response.headers["ETag"] = result.strong_etag
return result
@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,
issue_decisions=[
item.model_dump()
for item in payload.issue_decisions
],
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),
"issue_decision_count": len(payload.issue_decisions),
"issue_decisions": _review_decision_audit_evidence(version),
},
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,
principal=principal,
)
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:
if _is_archive_encryption_denial(exc):
_audit_archive_encryption_denial(
session, principal, version_id=version_id, error=exc
)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from 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,
request: Request,
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 {}
)
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
write_eml = payload.write_eml if payload else True
source_sha256 = _canonical_sha256(raw_json)
validation_sha256 = _canonical_sha256(
version.validation_summary
if isinstance(version.validation_summary, dict)
else {}
)
idempotency_key = (
payload.idempotency_key
if payload and payload.idempotency_key
else (
f"campaign-build:{version.id}:{source_sha256}:"
f"{validation_sha256}:{int(write_eml)}"
)
)
try:
identity = application_runtime_identity(request.app)
recovery_start = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=identity,
module_id="campaigns",
operation_type="build-artifacts",
idempotency_key=idempotency_key,
request={
"tenant_id": principal.tenant_id,
"campaign_id": version.campaign_id,
"version_id": version.id,
"source_sha256": source_sha256,
"validation_sha256": validation_sha256,
"write_eml": write_eml,
},
recovery_plan=_campaign_build_recovery_plan(raw_json),
precondition_evidence={
"campaign_version_id": version.id,
"source_sha256": source_sha256,
"validation_sha256": validation_sha256,
"locked_at": version.locked_at.isoformat()
if version.locked_at
else None,
"workflow_state": version.workflow_state,
},
lease_resource_key=(
f"campaign:build:{principal.tenant_id}:{version.id}"
),
lease_ttl_seconds=30 * 60,
resource_type="campaign_version",
resource_id=version.id,
metadata={"actor_account_id": principal.account_id},
)
if recovery_start.replayed:
session.refresh(version)
if not isinstance(version.build_summary, dict):
raise RecoveryGuaranteeError(
"A completed build operation has no committed Campaign summary"
)
return public_campaign_payload(
version.build_summary,
include_diagnostics=has_scope(
principal, "campaigns:diagnostic:read"
),
)
recovery_operation = recovery_start.operation
if recovery_operation is None: # pragma: no cover - guarded by replay branch
raise RecoveryGuaranteeError("Campaign build authority was not created")
result = build_campaign_version(
session,
tenant_id=principal.tenant_id,
version_id=version_id,
write_eml=write_eml,
user_id=principal.user.id,
principal=principal,
recovery_operation=recovery_operation,
build_id=recovery_start.operation_id,
)
audit_from_principal(
session,
principal,
action="campaign.messages_built",
object_type="campaign_version",
object_id=version_id,
details={
"write_eml": write_eml,
"built_count": result.get("built_count"),
"recovery_operation_id": recovery_start.operation_id,
"residual_file_disposition": _residual_file_audit_evidence(
result.get("residual_file_disposition")
),
"attachment_reuse": _attachment_reuse_audit_evidence(
result.get("attachment_reuse")
),
"archive_encryption": _archive_encryption_audit_evidence(
result.get("archive_encryption")
),
},
commit=True,
)
return public_campaign_payload(
result,
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
)
except CampaignPersistenceError as exc:
if _is_archive_encryption_denial(exc):
_audit_archive_encryption_denial(
session, principal, version_id=version_id, error=exc
)
raise HTTPException(
status_code=(
status.HTTP_403_FORBIDDEN
if "Missing scope:" in str(exc)
else status.HTTP_422_UNPROCESSABLE_CONTENT
),
detail=str(exc),
) from exc
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except RecoveryGuaranteeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
) from exc
except RuntimeError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Campaign build coordination is unavailable",
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
def _is_archive_encryption_denial(error: Exception) -> bool:
message = str(error).casefold()
return any(
marker in message
for marker in (
"archive-encryption",
"archive encryption",
"zipcrypto",
"password-delivery channel",
)
)
def _audit_archive_encryption_denial(
session: Session,
principal: ApiPrincipal,
*,
version_id: str,
error: Exception,
) -> None:
session.rollback()
audit_from_principal(
session,
principal,
action="campaign.archive_encryption_denied",
object_type="campaign_version",
object_id=version_id,
details={"reason": str(error)},
commit=True,
)
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict):
return {}
return {
key: value.get(key)
for key in (
"contract_version",
"action",
"routing_mode",
"validation_behavior",
"watched_source_count",
"residual_file_count",
)
}
def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict):
return {}
policy = value.get("policy")
return {
"contract_version": value.get("contract_version"),
"policy": dict(policy) if isinstance(policy, dict) else {},
"duplicate_file_count": value.get("duplicate_file_count"),
"allowed_file_count": value.get("allowed_file_count"),
"violation_file_count": value.get("violation_file_count"),
"affected_message_count": value.get("affected_message_count"),
}
def _archive_encryption_audit_evidence(value: object) -> dict[str, object]:
if not isinstance(value, dict):
return {}
policy = value.get("policy")
archives = [
item for item in (value.get("archives") or []) if isinstance(item, dict)
]
return {
"policy_hash": policy.get("policy_hash")
if isinstance(policy, dict)
else None,
"archive_count": len(archives),
"legacy_zipcrypto_count": sum(
1 for item in archives if item.get("method") == "zip_standard"
),
"archive_sha256": [item.get("archive_sha256") for item in archives],
}
def _review_decision_audit_evidence(
version: CampaignVersion,
) -> dict[str, object]:
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
review_state = editor_state.get("review_send")
if not isinstance(review_state, dict):
return {}
raw_decisions = review_state.get("issue_decisions")
decisions = [item for item in raw_decisions or [] if isinstance(item, dict)]
evidence = [
{
"decision": item.get("decision"),
"issue_codes": sorted(
str(code) for code in item.get("issue_codes") or [] if code
),
"issue_fingerprint": item.get("issue_fingerprint"),
"message_sha256": item.get("message_sha256"),
"reason_recorded": bool(str(item.get("reason") or "").strip()),
}
for item in decisions
]
return {
"count": len(evidence),
"with_reason_count": sum(
1 for item in evidence if item["reason_recorded"]
),
"issue_codes": sorted(
{
code
for item in evidence
for code in item["issue_codes"]
}
),
"evidence_sha256": _canonical_sha256(evidence),
}