feat(campaign): record unmatched file disposition

This commit is contained in:
2026-08-20 01:57:12 +02:00
parent b6af7665f4
commit f4fe534ee1
9 changed files with 177 additions and 3 deletions
@@ -456,7 +456,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
topic_id="campaigns.workflow.route-unassigned-files", topic_id="campaigns.workflow.route-unassigned-files",
title="Review and route unassigned campaign files", title="Review and route unassigned campaign files",
summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.", summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.",
body="Campaign already compares watched attachment sources with the exact files assigned to built recipient messages. An optional residual-file disposition turns the remaining set into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.", body="Campaign compares watched attachment sources with the exact files assigned to built recipient messages. The configurable validation behavior can block, require review, or explicitly ignore the remaining set. An optional residual-file disposition instead turns it into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The normalized action, observed file and source counts, routing mode, and configured recipient are visible in build review and retained in the campaign protocol; the audit event records the same policy and counts without copying the recipient address. A routed row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.",
order=35, order=35,
audience=("campaign_manager", "campaign_author", "campaign_reviewer"), audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"), required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"),
@@ -481,7 +481,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
"de": { "de": {
"title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten", "title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten",
"summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.", "summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.",
"body": "Campaign vergleicht überwachte Anhangsquellen bereits mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Eine optionale Restdatei-Behandlung erzeugt aus der verbleibenden Menge eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.", "body": "Campaign vergleicht überwachte Anhangsquellen mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Das konfigurierbare Validierungsverhalten kann die Restmenge sperren, zur Prüfung vorlegen oder ausdrücklich ignorieren. Eine optionale Restdatei-Behandlung erzeugt stattdessen eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die normalisierte Aktion, Datei- und Quellenanzahl, Routingart und der konfigurierte Empfänger sind in der Build-Prüfung sichtbar und bleiben im Kampagnenprotokoll erhalten; das Audit-Ereignis speichert Richtlinie und Anzahlen ohne die Empfängeradresse zu kopieren. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.",
"outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.", "outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.",
"verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.", "verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.",
} }
@@ -1103,6 +1103,39 @@ def _build_residual_file_message(
) )
def _residual_file_disposition_evidence(
*,
config: CampaignConfig,
residual_groups: list[_ResidualFileGroup],
) -> dict[str, object]:
disposition = config.attachments.residual_files
behavior = config.validation_policy.unsent_attachment_files.value
if disposition.mode == ResidualFileMode.REPORT:
action = "route_report"
elif disposition.mode == ResidualFileMode.ATTACH:
action = "route_with_files"
elif behavior == Behavior.BLOCK.value:
action = "block"
elif behavior in {Behavior.CONTINUE.value, Behavior.DROP.value}:
action = "ignore"
else:
action = "review"
recipient = (
disposition.recipient.model_dump(mode="json", by_alias=True)
if disposition.recipient is not None
else None
)
return {
"contract_version": "1",
"action": action,
"routing_mode": disposition.mode.value,
"validation_behavior": behavior,
"watched_source_count": len(residual_groups),
"residual_file_count": sum(len(group.files) for group in residual_groups),
"recipient": recipient,
}
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None: def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues: if not issues:
return return
@@ -1183,5 +1216,9 @@ def build_campaign_messages(
duration_ms=(time.perf_counter() - started) * 1000, duration_ms=(time.perf_counter() - started) * 1000,
rules_resolved=rules_resolved, rules_resolved=rules_resolved,
), ),
residual_file_disposition=_residual_file_disposition_evidence(
config=config,
residual_groups=residual_groups,
),
) )
return CampaignBuildResult(report=report, built_messages=built_messages) return CampaignBuildResult(report=report, built_messages=built_messages)
@@ -117,6 +117,7 @@ class CampaignBuildReport(BaseModel):
inactive_entries_count: int = 0 inactive_entries_count: int = 0
messages: list[MessageDraft] = Field(default_factory=list) messages: list[MessageDraft] = Field(default_factory=list)
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict) attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
residual_file_disposition: dict[str, object] = Field(default_factory=dict)
@property @property
def built_count(self) -> int: def built_count(self) -> int:
@@ -876,6 +876,9 @@ def build_version(
"write_eml": write_eml, "write_eml": write_eml,
"built_count": result.get("built_count"), "built_count": result.get("built_count"),
"recovery_operation_id": recovery_start.operation_id, "recovery_operation_id": recovery_start.operation_id,
"residual_file_disposition": _residual_file_audit_evidence(
result.get("residual_file_disposition")
),
}, },
commit=True, commit=True,
) )
@@ -906,3 +909,19 @@ def build_version(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc ) from exc
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",
)
}
+71
View File
@@ -340,6 +340,22 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
) )
self.assertEqual(len(result.report.messages), 2) self.assertEqual(len(result.report.messages), 2)
self.assertEqual(
{
"contract_version": "1",
"action": "route_report" if mode == "report" else "route_with_files",
"routing_mode": mode,
"validation_behavior": "block",
"watched_source_count": 1,
"residual_file_count": 1,
"recipient": {
"email": "operator@example.org",
"name": "Operator",
"type": "to",
},
},
result.report.residual_file_disposition,
)
normal, residual = result.report.messages normal, residual = result.report.messages
self.assertEqual(normal.validation_status.value, "ready") self.assertEqual(normal.validation_status.value, "ready")
self.assertEqual(residual.entry_id, "__residual_files__") self.assertEqual(residual.entry_id, "__residual_files__")
@@ -361,6 +377,61 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
filenames = [part.get_filename() for part in mime.iter_attachments()] filenames = [part.get_filename() for part in mime.iter_attachments()]
self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else []) self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else [])
def test_residual_file_policy_evidence_normalizes_block_and_ignore(self) -> None:
for behavior, action in (("block", "block"), ("continue", "ignore")):
with self.subTest(behavior=behavior), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
watched = root / "watched"
watched.mkdir()
(watched / "residual.txt").write_text("residual", encoding="utf-8")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": f"residual-{behavior}", "name": "Residual", "mode": "test"},
"fields": [],
"global_values": {},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {
"from": {"email": "sender@example.org", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Normal", "text": "Body"},
"attachments": {
"base_paths": [{
"id": "watched",
"name": "Watched folder",
"path": "watched",
"unsent_warning": True,
}],
},
"entries": {"inline": [{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
}]},
"validation_policy": {"unsent_attachment_files": behavior},
"delivery": {"imap_append_sent": {"enabled": False}},
})
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(action, result.report.residual_file_disposition["action"])
self.assertEqual(1, result.report.residual_file_disposition["residual_file_count"])
issue_codes = {
issue.code
for message in result.report.messages
for issue in message.issues
}
self.assertEqual(behavior == "block", "unsent_attachment_files" in issue_codes)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+23 -1
View File
@@ -15,7 +15,10 @@ from govoplan_campaign.backend.persistence.campaigns import (
_verify_build_storage_manifest, _verify_build_storage_manifest,
_verify_storage_keys_absent, _verify_storage_keys_absent,
) )
from govoplan_campaign.backend.routes.versions import _campaign_build_recovery_plan from govoplan_campaign.backend.routes.versions import (
_campaign_build_recovery_plan,
_residual_file_audit_evidence,
)
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None: def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
@@ -34,6 +37,25 @@ def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() ->
) )
def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
assert _residual_file_audit_evidence({
"contract_version": "1",
"action": "route_report",
"routing_mode": "report",
"validation_behavior": "warn",
"watched_source_count": 2,
"residual_file_count": 3,
"recipient": {"email": "operator@example.org"},
}) == {
"contract_version": "1",
"action": "route_report",
"routing_mode": "report",
"validation_behavior": "warn",
"watched_source_count": 2,
"residual_file_count": 3,
}
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None: def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path) storage = LocalFilesystemStorageBackend(tmp_path)
payload = b"Message-ID: <build@example.test>\r\n\r\nbody" payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
@@ -158,6 +158,8 @@ export default function ReviewSendPage({
); );
const validation = asRecord(version?.validation_summary); const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary); const build = asRecord(version?.build_summary);
const residualFileDisposition = asRecord(build.residual_file_disposition);
const residualFileRecipient = asRecord(residualFileDisposition.recipient);
const printOutput = asRecord(build.print_output); const printOutput = asRecord(build.print_output);
const printArtifact = asRecord(printOutput.artifact); const printArtifact = asRecord(printOutput.artifact);
const summary = liveSummary ?? data.summary; const summary = liveSummary ?? data.summary;
@@ -1490,6 +1492,18 @@ export default function ReviewSendPage({
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />} {hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />} {hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p> <p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
{hasBuild && Object.keys(residualFileDisposition).length > 0 && (
<div className="review-flow-data-section" data-residual-file-policy>
<h3>i18n:govoplan-campaign.unassigned_file_policy</h3>
<DescriptionList variant="inline">
<div><dt>i18n:govoplan-campaign.action.97c89a4d</dt><dd>{humanize(String(residualFileDisposition.action ?? "review"))}</dd></div>
<div><dt>i18n:govoplan-campaign.validation_policy.57dcc756</dt><dd>{humanize(String(residualFileDisposition.validation_behavior ?? "warn"))}</dd></div>
<div><dt>i18n:govoplan-campaign.unassigned_files_detected</dt><dd>{String(residualFileDisposition.residual_file_count ?? 0)}</dd></div>
<div><dt>i18n:govoplan-campaign.watched_sources</dt><dd>{String(residualFileDisposition.watched_source_count ?? 0)}</dd></div>
{residualFileRecipient.email ? <div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(residualFileRecipient.email)}</dd></div> : null}
</DescriptionList>
</div>
)}
{getText(printOutput, "render_id") && ( {getText(printOutput, "render_id") && (
<div className="review-flow-data-section"> <div className="review-flow-data-section">
<div className="page-heading split"> <div className="page-heading split">
+6
View File
@@ -2,6 +2,9 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-campaign.unassigned_file_policy": "Unassigned file policy",
"i18n:govoplan-campaign.unassigned_files_detected": "Unassigned files detected",
"i18n:govoplan-campaign.watched_sources": "Watched sources",
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Activate all ({value0})", "i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Activate all ({value0})",
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Deactivate all ({value0})", "i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Deactivate all ({value0})",
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Activate all recipients", "i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Activate all recipients",
@@ -1343,6 +1346,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto" "i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
}, },
"de": { "de": {
"i18n:govoplan-campaign.unassigned_file_policy": "Richtlinie für nicht zugeordnete Dateien",
"i18n:govoplan-campaign.unassigned_files_detected": "Erkannte nicht zugeordnete Dateien",
"i18n:govoplan-campaign.watched_sources": "Überwachte Quellen",
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Alle aktivieren ({value0})", "i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Alle aktivieren ({value0})",
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Alle deaktivieren ({value0})", "i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Alle deaktivieren ({value0})",
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Alle Empfänger aktivieren", "i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Alle Empfänger aktivieren",
@@ -19,5 +19,9 @@ assert.match(guidance, /campaigns\.workflow\.complete-review/);
assert.match(page, /calculateBuildReviewProgress/); assert.match(page, /calculateBuildReviewProgress/);
assert.match(page, /<MetricCard[^>]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/); assert.match(page, /<MetricCard[^>]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
assert.match(page, /showBuiltDetails\(\{ reviewed: 'list:\["no"\]' \}\)/); assert.match(page, /showBuiltDetails\(\{ reviewed: 'list:\["no"\]' \}\)/);
assert.match(page, /data-residual-file-policy/);
assert.match(page, /build\.residual_file_disposition/);
assert.match(page, /residual_file_count/);
assert.match(page, /watched_source_count/);
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/); assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/); assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);