feat(campaign): record unmatched file disposition
This commit is contained in:
@@ -456,7 +456,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
topic_id="campaigns.workflow.route-unassigned-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.",
|
||||
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,
|
||||
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"),
|
||||
@@ -481,7 +481,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
"de": {
|
||||
"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.",
|
||||
"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.",
|
||||
"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:
|
||||
if not issues:
|
||||
return
|
||||
@@ -1183,5 +1216,9 @@ def build_campaign_messages(
|
||||
duration_ms=(time.perf_counter() - started) * 1000,
|
||||
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)
|
||||
|
||||
@@ -117,6 +117,7 @@ class CampaignBuildReport(BaseModel):
|
||||
inactive_entries_count: int = 0
|
||||
messages: list[MessageDraft] = Field(default_factory=list)
|
||||
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
|
||||
residual_file_disposition: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def built_count(self) -> int:
|
||||
|
||||
@@ -876,6 +876,9 @@ def build_version(
|
||||
"write_eml": write_eml,
|
||||
"built_count": result.get("built_count"),
|
||||
"recovery_operation_id": recovery_start.operation_id,
|
||||
"residual_file_disposition": _residual_file_audit_evidence(
|
||||
result.get("residual_file_disposition")
|
||||
),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
@@ -906,3 +909,19 @@ def build_version(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(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",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -340,6 +340,22 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
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
|
||||
self.assertEqual(normal.validation_status.value, "ready")
|
||||
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()]
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -15,7 +15,10 @@ from govoplan_campaign.backend.persistence.campaigns import (
|
||||
_verify_build_storage_manifest,
|
||||
_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:
|
||||
@@ -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:
|
||||
storage = LocalFilesystemStorageBackend(tmp_path)
|
||||
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 build = asRecord(version?.build_summary);
|
||||
const residualFileDisposition = asRecord(build.residual_file_disposition);
|
||||
const residualFileRecipient = asRecord(residualFileDisposition.recipient);
|
||||
const printOutput = asRecord(build.print_output);
|
||||
const printArtifact = asRecord(printOutput.artifact);
|
||||
const summary = liveSummary ?? data.summary;
|
||||
@@ -1490,6 +1492,18 @@ export default function ReviewSendPage({
|
||||
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
|
||||
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
|
||||
<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") && (
|
||||
<div className="review-flow-data-section">
|
||||
<div className="page-heading split">
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"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.deactivate_all_value0.87e5d46f": "Deactivate all ({value0})",
|
||||
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Activate all recipients",
|
||||
@@ -1343,6 +1346,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||
},
|
||||
"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.deactivate_all_value0.87e5d46f": "Alle deaktivieren ({value0})",
|
||||
"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, /<MetricCard[^>]+label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
|
||||
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, /resolve_the_blocking_entries_then_validate_again/);
|
||||
|
||||
Reference in New Issue
Block a user