Implement governed hybrid campaign delivery
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user