1057 lines
36 KiB
Python
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),
|
|
}
|