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), }