feat: complete campaign wizards and retention reporting

This commit is contained in:
2026-07-31 04:21:34 +02:00
parent e689fdf495
commit fa4eb39e0b
18 changed files with 785 additions and 55 deletions
@@ -12,6 +12,11 @@ from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.policy import (
CAPABILITY_POLICY_PRIVACY_RETENTION,
PrivacyRetentionService,
)
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
@@ -26,6 +31,7 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.runtime import capability
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
@@ -301,6 +307,9 @@ class _JobReportAggregate:
attachment_ambiguous: int = 0
attachment_status: Counter[str] = field(default_factory=Counter)
attachment_behavior: Counter[str] = field(default_factory=Counter)
eml_retained: int = 0
eml_expired: int = 0
eml_not_generated: int = 0
recent_failures: list[CampaignJob] = field(default_factory=list)
def add(
@@ -315,6 +324,7 @@ class _JobReportAggregate:
self._add_delivery_counts(job, retry_max_attempts=retry_max_attempts)
self._add_issues(job)
self._add_attachments(job)
self._add_eml_evidence(job)
self._add_recent_failure(job, include_recent_failures=include_recent_failures)
def _add_statuses(self, job: CampaignJob) -> None:
@@ -375,6 +385,14 @@ class _JobReportAggregate:
if status_value == "ambiguous":
self.attachment_ambiguous += 1
def _add_eml_evidence(self, job: CampaignJob) -> None:
if not job.eml_sha256:
self.eml_not_generated += 1
elif job.eml_local_path or job.eml_storage_key:
self.eml_retained += 1
else:
self.eml_expired += 1
NON_CANCELLABLE_SEND_STATUSES = {
"skipped",
@@ -865,6 +883,12 @@ def _campaign_report_payload(
include_diagnostics: bool,
review_decisions: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
postbox_receipts = _campaign_postbox_receipt_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
)
report = {
"generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
@@ -903,12 +927,7 @@ def _campaign_report_payload(
campaign_id=campaign.id,
version=version,
),
"postbox_receipts": _campaign_postbox_receipt_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"postbox_receipts": postbox_receipts,
"message_actions": _campaign_message_action_summary(
session,
tenant_id=tenant_id,
@@ -920,6 +939,13 @@ def _campaign_report_payload(
pending_job_count=aggregate.pending,
include_diagnostics=include_diagnostics,
),
"retention": _campaign_retention_projection(
session,
campaign=campaign,
version=version,
aggregate=aggregate,
postbox_receipts=postbox_receipts,
),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(
@@ -928,6 +954,221 @@ def _campaign_report_payload(
return report
def _campaign_retention_projection(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion | None,
aggregate: _JobReportAggregate,
postbox_receipts: dict[str, object],
) -> dict[str, Any]:
service = capability(CAPABILITY_POLICY_PRIVACY_RETENTION)
policy_status = "configured"
policy_reason: str | None = None
sources: list[dict[str, Any]] = []
if service is None:
policy_status = "defaults"
policy_reason = (
"The Policy module is not active. Platform defaults apply, but "
"automated retention enforcement is unavailable."
)
policy = PrivacyRetentionPolicyItem().model_dump(mode="json")
sources = [{
"scope_type": "system",
"path": "platform-defaults",
"label": "Platform defaults",
"applied_fields": list(policy),
}]
elif not isinstance(service, PrivacyRetentionService):
policy_status = "unavailable"
policy_reason = "The configured retention provider does not satisfy the platform contract."
policy = PrivacyRetentionPolicyItem().model_dump(mode="json")
else:
try:
policy = _model_payload(
service.effective_privacy_policy(
session,
campaign_id=campaign.id,
)
)
sources = _public_retention_sources(
service.effective_privacy_policy_sources(
session,
campaign_id=campaign.id,
)
)
except Exception: # pragma: no cover - report remains readable on provider failure.
logger.exception("Effective Campaign retention policy could not be resolved")
policy_status = "unavailable"
policy_reason = "The effective retention policy could not be resolved."
policy = PrivacyRetentionPolicyItem().model_dump(mode="json")
evidence = _retention_evidence_state(
version=version,
aggregate=aggregate,
postbox_receipts=postbox_receipts,
)
minimized = [
name
for name, item in evidence.items()
if item.get("state") in {"redacted", "partially_redacted", "expired", "partially_expired"}
]
retained = [
name
for name, item in evidence.items()
if item.get("state") in {"retained", "partially_expired"}
]
impact_state = (
"policy_unavailable"
if policy_status == "unavailable"
else "partially_minimized"
if minimized
else "retained"
)
return {
"policy_status": policy_status,
"policy_reason": policy_reason,
"effective_policy": policy,
"sources": sources,
"evidence": evidence,
"privacy_impact": {
"state": impact_state,
"summary": (
"Campaign reports expose delivery and recipient evidence only "
"to authorized report readers. The effective retention policy "
"controls when source JSON, generated message files, and stored "
"report detail are removed or redacted; aggregate counters and "
"audit references may remain."
),
"retained_categories": retained,
"minimized_categories": minimized,
},
}
def _model_payload(value: object) -> dict[str, Any]:
if hasattr(value, "model_dump"):
payload = value.model_dump(mode="json")
return dict(payload) if isinstance(payload, dict) else {}
return dict(value) if isinstance(value, dict) else {}
def _public_retention_sources(value: object) -> list[dict[str, Any]]:
if not isinstance(value, (list, tuple)):
return []
sources: list[dict[str, Any]] = []
for item in value:
if not isinstance(item, dict):
continue
sources.append({
"scope_type": item.get("scope_type"),
"path": item.get("path"),
"label": item.get("label"),
"applied_fields": [
str(field)
for field in item.get("applied_fields") or []
if field
],
})
return sources
def _retention_evidence_state(
*,
version: CampaignVersion | None,
aggregate: _JobReportAggregate,
postbox_receipts: dict[str, object],
) -> dict[str, dict[str, Any]]:
if version is None:
return {
key: {"state": "not_applicable"}
for key in (
"raw_campaign_json",
"stored_report_detail",
"generated_eml",
"postbox_copies",
)
}
raw_marker = _retention_marker(version.raw_json, "raw_json_redacted")
validation_marker = _retention_marker(
version.validation_summary,
"report_detail_redacted",
)
build_marker = _retention_marker(
version.build_summary,
"report_detail_redacted",
)
redacted_summaries = sum(bool(marker) for marker in (validation_marker, build_marker))
generated_total = aggregate.eml_retained + aggregate.eml_expired
generated_state = (
"not_generated"
if generated_total == 0
else "expired"
if aggregate.eml_expired == generated_total
else "partially_expired"
if aggregate.eml_expired
else "retained"
)
expired_postbox = int(postbox_receipts.get("expired_message_count") or 0)
withdrawn_postbox = int(postbox_receipts.get("withdrawn_message_count") or 0)
readable_postbox = int(postbox_receipts.get("currently_readable_delivery_count") or 0)
postbox_state = (
"not_applicable"
if postbox_receipts.get("status") == "not_applicable"
else "unavailable"
if postbox_receipts.get("status") == "unavailable"
else "partially_expired"
if (expired_postbox or withdrawn_postbox) and readable_postbox
else "expired"
if expired_postbox or withdrawn_postbox
else "retained"
)
return {
"raw_campaign_json": {
"state": "redacted" if raw_marker else "retained",
"redacted_at": raw_marker.get("redacted_at") if raw_marker else None,
},
"stored_report_detail": {
"state": (
"redacted"
if redacted_summaries == 2
else "partially_redacted"
if redacted_summaries
else "retained"
),
"redacted_summary_count": redacted_summaries,
"summary_count": 2,
"redacted_at": next(
(
marker.get("redacted_at")
for marker in (validation_marker, build_marker)
if marker and marker.get("redacted_at")
),
None,
),
},
"generated_eml": {
"state": generated_state,
"retained_count": aggregate.eml_retained,
"expired_count": aggregate.eml_expired,
"not_generated_count": aggregate.eml_not_generated,
},
"postbox_copies": {
"state": postbox_state,
"currently_readable_count": readable_postbox,
"expired_count": expired_postbox,
"withdrawn_count": withdrawn_postbox,
},
}
def _retention_marker(value: object, key: str) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
marker = value.get("_retention")
return dict(marker) if isinstance(marker, dict) and marker.get(key) else None
def _campaign_postbox_receipt_summary(
session: Session,
*,