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
@@ -65,6 +65,7 @@ from govoplan_campaign.backend.campaign.postbox_targets import (
from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable,
postbox_integration,
templates_integration,
)
from govoplan_core.db.session import get_session
from govoplan_core.core.distribution_lists import (
@@ -755,6 +756,29 @@ def campaign_postbox_catalog(
)
@router.get("/{campaign_id}/print-templates")
def campaign_print_templates(
campaign_id: str,
query: str = Query(default="", min_length=0, max_length=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
integration = templates_integration()
if not integration.available:
return {"available": False, "templates": []}
templates = integration.list_templates(
session,
principal,
query=query,
limit=250,
)
return {
"available": True,
"templates": [dataclasses.asdict(template) for template in templates],
}
@router.post(
"/{campaign_id}/recipient-address-sources/snapshot",
response_model=CampaignRecipientAddressSourceSnapshotResponse,
+21 -6
View File
@@ -27,6 +27,7 @@ from govoplan_campaign.backend.db.models import (
CampaignMessageActionAttempt,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
@@ -397,6 +398,12 @@ def get_job_detail(
),
label="Postbox attempts for this campaign job",
)
print_attempts = _job_attempt_rows(
session.query(PrintOutputAttempt)
.filter(PrintOutputAttempt.job_id == job.id)
.order_by(PrintOutputAttempt.attempt_number.asc()),
label="Printable output attempts for this campaign job",
)
message_actions = _job_attempt_rows(
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id)
@@ -419,9 +426,10 @@ def get_job_detail(
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
postbox_attempts,
message_actions,
message_action_attempts,
postbox_attempts=postbox_attempts,
print_attempts=print_attempts,
message_actions=message_actions,
message_action_attempts=message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts(
session,
tenant_id=principal.tenant_id,
@@ -476,6 +484,12 @@ def get_job_diagnostics(
),
label="Postbox diagnostics for this campaign job",
)
print_attempts = _job_attempt_rows(
session.query(PrintOutputAttempt)
.filter(PrintOutputAttempt.job_id == job.id)
.order_by(PrintOutputAttempt.attempt_number.asc()),
label="Printable output diagnostics for this campaign job",
)
message_actions = _job_attempt_rows(
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id == job.id)
@@ -497,9 +511,10 @@ def get_job_diagnostics(
job,
send_attempts,
imap_attempts,
postbox_attempts,
message_actions,
message_action_attempts,
postbox_attempts=postbox_attempts,
print_attempts=print_attempts,
message_actions=message_actions,
message_action_attempts=message_action_attempts,
postbox_receipts=_postbox_receipts_for_attempts(
session,
tenant_id=principal.tenant_id,
@@ -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,