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 -1
View File
@@ -139,7 +139,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
delivery=DeliveryConfig(),
)
assert payload["snapshot_version"] == "7"
assert payload["snapshot_version"] == "8"
assert payload["mail_profile_id"] == "profile-1"
assert "smtp" not in payload
assert "imap" not in payload
+79 -1
View File
@@ -11,6 +11,7 @@ from govoplan_campaign.backend.db.models import JobSendStatus
from govoplan_campaign.backend.sending.jobs import (
SendJobResult,
_MailChannelOutcome,
_PrintChannelOutcome,
_final_multichannel_status,
_send_claimed_multichannel_job,
)
@@ -49,6 +50,71 @@ class _Session:
class PostboxFallbackOrchestrationTests(unittest.TestCase):
def test_mail_unknown_never_starts_print_fallback(self) -> None:
job = SimpleNamespace(id="job-1", print_status="ready")
expected = SendJobResult(
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(outcome_unknown=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_print_channel"
) as deliver_print,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=expected,
),
):
result = _send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_PRINT,
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
deliver_print.assert_not_called()
def test_mail_preacceptance_rejection_starts_print_fallback(self) -> None:
job = SimpleNamespace(id="job-1", print_status="ready")
with (
patch(
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
return_value=_MailChannelOutcome(rejected_permanent=True),
),
patch(
"govoplan_campaign.backend.sending.jobs._deliver_print_channel",
return_value=_PrintChannelOutcome(accepted=True),
) as deliver_print,
patch(
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
return_value=SendJobResult(
job_id=job.id,
status=JobSendStatus.PRINT_ACCEPTED.value,
attempt_number=1,
),
),
):
_send_claimed_multichannel_job(
_Session(job), # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=_context(), # type: ignore[arg-type]
channel_policy=DeliveryChannelPolicy.MAIL_THEN_PRINT,
use_rate_limit=False,
enqueue_imap_task=False,
)
deliver_print.assert_called_once()
def test_mail_unknown_never_starts_postbox_fallback(self) -> None:
job = _job()
expected = SendJobResult(
@@ -385,6 +451,12 @@ def test_rejection_precedence_is_exhaustive_for_every_delivery_policy(
PostboxChannelOutcome(rejected_permanent=1),
JobSendStatus.PARTIALLY_ACCEPTED.value,
),
(
DeliveryChannelPolicy.PRINT,
None,
PostboxChannelOutcome(),
JobSendStatus.PRINT_ACCEPTED.value,
),
],
)
def test_accepted_delivery_decision_table(
@@ -393,7 +465,13 @@ def test_accepted_delivery_decision_table(
postbox: PostboxChannelOutcome,
expected: str,
) -> None:
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
print_output = _PrintChannelOutcome(accepted=True) if policy == DeliveryChannelPolicy.PRINT else None
assert _final_multichannel_status(
channel_policy=policy,
mail=mail,
postbox=postbox,
print_output=print_output,
) == expected
if __name__ == "__main__":
+36
View File
@@ -180,6 +180,42 @@ def test_postbox_only_campaign_does_not_require_mail() -> None:
}
def test_print_only_campaign_requires_templates_and_an_explicit_target_not_mail() -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "campaign-print", "name": "Printed notice", "mode": "send"},
"template": {"subject": "Notice", "text": "Printed body", "body_mode": "text"},
"entries": {
"inline": [
{
"id": "recipient-1",
"channel_policy": "print",
"print_target": {
"channel": "postal",
"target": "Example Street 1",
"target_key": "postal:example-street-1",
},
}
]
},
"delivery": {
"channel_policy": "print",
"print": {"template_id": "template-1", "template_revision": 2},
},
}
)
available = validate_campaign_config(config, templates_available=True)
unavailable = validate_campaign_config(config, templates_available=False)
assert "missing_mail_profile" not in {issue.code for issue in available.issues}
assert "missing_sender" not in {issue.code for issue in available.issues}
assert "print_template_missing" not in {issue.code for issue in available.issues}
assert "print_target_missing" not in {issue.code for issue in available.issues}
assert "templates_unavailable" in {issue.code for issue in unavailable.issues}
def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None:
config = _config()
integration = _PostboxIntegration()
+282
View File
@@ -0,0 +1,282 @@
from __future__ import annotations
import hashlib
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignVersion,
JobPrintStatus,
PrintOutputAttempt,
)
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.messages.models import MessageValidationStatus
from govoplan_campaign.backend.persistence.campaigns import _resolve_built_print_outputs
from govoplan_campaign.backend.routes import versions as version_routes
from govoplan_campaign.backend.sending.jobs import _deliver_print_channel
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.templates import TemplateArtifactRef, TemplateRenderResult
from govoplan_core.db.base import Base
def test_print_acceptance_is_idempotent_per_frozen_artifact() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
PrintOutputAttempt.__table__,
ChangeSequenceEntry.__table__,
],
)
with Session(engine) as session:
job = CampaignJob(
id="job-print-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=1,
entry_id="entry-1",
recipient_email=None,
subject="Printable notice",
build_status="built",
validation_status="ready",
queue_status="draft",
send_status="not_queued",
print_status=JobPrintStatus.READY.value,
delivery_channel_policy="print",
resolved_attachments=[],
issues_snapshot=[],
resolved_print_output={
"render_id": "render-1",
"output_sha256": "a" * 64,
"template_id": "template-1",
"template_revision_id": "revision-1",
"template_hash": "b" * 64,
"input_hash": "c" * 64,
"recipient_key": "recipient-1",
"item_index": 0,
"route": {"channel": "postal", "target_key": "postal:1"},
},
)
session.add(job)
session.commit()
first = _deliver_print_channel(session, job=job)
second = _deliver_print_channel(session, job=job)
assert first.accepted is True
assert second.accepted is True
assert session.get(CampaignJob, job.id).print_attempt_count == 1
attempts = session.query(PrintOutputAttempt).all()
assert len(attempts) == 1
assert attempts[0].artifact_sha256 == "a" * 64
engine.dispose()
def test_print_build_uses_one_deterministic_template_render_request() -> None:
config = CampaignConfig.model_validate(
{
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Printed notice", "mode": "send"},
"fields": [{"name": "case_number", "type": "string"}],
"template": {"subject": "Notice", "text": "Body", "body_mode": "text"},
"entries": {
"inline": [
{
"id": "entry-1",
"name": "Ada",
"channel_policy": "print",
"fields": {"case_number": "C-1"},
"print_target": {
"channel": "postal",
"target": "Example Street 1",
"target_key": "postal:example-street-1",
},
"distribution_source": {
"list_id": "list-1",
"list_revision": 3,
"expansion_hash": "expansion-1",
"recipient_key": "recipient-1",
},
}
]
},
"delivery": {
"channel_policy": "print",
"print": {
"template_id": "template-1",
"template_revision": 4,
"output_format": "html",
"persist_to_files": True,
},
},
}
)
draft = SimpleNamespace(
entry_index=1,
entry_id="entry-1",
delivery_channel_policy="print",
validation_status=MessageValidationStatus.READY,
)
built = [SimpleNamespace(draft=draft)]
requests = []
stored = {}
class Storage:
def put_bytes(self, key, data, **_kwargs):
stored[key] = data
class Templates:
def render(self, _session, _principal, *, request):
requests.append(request)
return TemplateRenderResult(
render_id="render-1",
template_id="template-1",
revision_id="revision-4",
revision=4,
template_hash="b" * 64,
input_hash="c" * 64,
renderer_version="templates-1",
output_format="html",
content_type="text/html",
filename="printed-notice.html",
item_count=1,
page_count=1,
output_sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
output_size_bytes=len(b"<p>Printed notice</p>"),
artifact=TemplateArtifactRef(
kind="bounded_download",
filename="printed-notice.html",
content_type="text/html",
size_bytes=len(b"<p>Printed notice</p>"),
sha256=hashlib.sha256(b"<p>Printed notice</p>").hexdigest(),
download_path="/api/v1/templates/renders/render-1/download",
),
payload=b"<p>Printed notice</p>",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=2,
)
principal = SimpleNamespace(account_id="account-1")
storage = Storage()
with patch(
"govoplan_campaign.backend.persistence.campaigns.templates_integration",
return_value=Templates(),
):
first = _resolve_built_print_outputs(
object(), # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
build_id="build-1",
version=version, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
config=config,
built_messages=built,
entries_by_index={1: config.entries.inline[0]},
)
second = _resolve_built_print_outputs(
object(), # type: ignore[arg-type]
storage=storage, # type: ignore[arg-type]
tenant_id="tenant-1",
build_id="build-1",
version=version, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
config=config,
built_messages=built,
entries_by_index={1: config.entries.inline[0]},
)
assert requests[0].idempotency_key == requests[1].idempotency_key
assert requests[0].items[0]["case_number"] == "C-1"
assert requests[0].persist_to_files is True
assert first == second
assert first[1]["artifact"]["storage_key"] in stored
assert first[1]["artifact"]["download_path"] == (
"/api/v1/campaigns/campaign-1/versions/version-1/print-output/download"
)
assert first[1]["route"]["target_key"] == "postal:example-street-1"
class _RoutePrincipal:
tenant_id = "tenant-1"
account_id = "account-1"
user = SimpleNamespace(id="user-1")
def has(self, scope: str) -> bool:
return scope in {"campaigns:campaign:read", "campaigns:recipient:read"}
def test_print_download_is_authorized_by_campaign_and_hash_checked() -> None:
payload = b"<p>Printable output</p>"
digest = hashlib.sha256(payload).hexdigest()
version = SimpleNamespace(
id="version-1",
build_summary={
"print_output": {
"output_sha256": digest,
"template_id": "template-1",
"template_revision_id": "revision-1",
"artifact": {
"kind": "bounded_download",
"filename": "letters.html",
"content_type": "text/html",
"storage_key": "campaign-artifacts/tenant-1/letters.html",
},
}
},
)
storage = SimpleNamespace(get_bytes=lambda _key: payload)
with (
patch.object(version_routes, "_get_campaign_for_principal"),
patch.object(
version_routes,
"get_campaign_version_for_tenant",
return_value=version,
),
patch.object(version_routes, "_object_storage", return_value=storage),
patch.object(version_routes, "audit_from_principal") as audit,
):
response = version_routes.download_print_output(
"campaign-1",
"version-1",
session=Mock(),
principal=_RoutePrincipal(), # type: ignore[arg-type]
)
assert response.body == payload
assert response.headers["x-content-sha256"] == digest
audit.assert_called_once()
def test_print_download_requires_recipient_read_authority() -> None:
principal = _RoutePrincipal()
principal.has = lambda scope: scope == "campaigns:campaign:read" # type: ignore[method-assign]
with (
patch.object(version_routes, "_get_campaign_for_principal"),
pytest.raises(HTTPException) as denied,
):
version_routes.download_print_output(
"campaign-1",
"version-1",
session=Mock(),
principal=principal, # type: ignore[arg-type]
)
assert denied.value.status_code == 403
+15
View File
@@ -57,6 +57,14 @@ def _job() -> SimpleNamespace:
}
],
resolved_recipients={"to": [{"email": "person@example.test"}]},
resolved_print_output={
"output_sha256": "print-sha256",
"artifact": {
"filename": "letters.html",
"storage_key": "campaign/private/letters.html",
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
},
},
)
@@ -238,6 +246,13 @@ def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
assert "claimed_at" not in job_payload
assert "smtp_started_at" not in job_payload
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
assert job_payload["resolved_print_output"] == {
"output_sha256": "print-sha256",
"artifact": {
"filename": "letters.html",
"download_path": "/api/v1/campaigns/campaign-1/versions/version-1/print-output/download",
},
}
assert "claim_token" not in attempts["smtp"][0]
assert "claim_token" not in attempts["imap"][0]
assert "smtp_response" not in attempts["smtp"][0]
+1 -1
View File
@@ -38,7 +38,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 65
assert len(actual) == 70
assert not [operation for operation, count in Counter(actual).items() if count > 1]