feat: complete campaign wizards and retention reporting
This commit is contained in:
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user