Implement governed hybrid campaign delivery

This commit is contained in:
2026-08-02 13:58:37 +02:00
parent b38597f2be
commit 7733265cc8
40 changed files with 2531 additions and 122 deletions
@@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
@@ -19,6 +21,7 @@ from govoplan_campaign.backend.schemas import (
)
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_campaign.backend.db.models import (
CampaignVersion,
)
@@ -28,6 +31,7 @@ from govoplan_campaign.backend.response_security import (
)
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_object_storage,
build_campaign_version,
validate_campaign_version,
)
@@ -70,6 +74,86 @@ from govoplan_campaign.backend.routes.attachments import (
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
@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,
@@ -607,6 +691,7 @@ def validate_version(
version_id=version_id,
check_files=payload.check_files,
user_id=principal.user.id,
principal=principal,
)
audit_from_principal(
session,
@@ -659,6 +744,7 @@ def build_version(
version_id=version_id,
write_eml=payload.write_eml if payload else True,
user_id=principal.user.id,
principal=principal,
)
audit_from_principal(
session,