feat(campaign): add privacy-safe aggregate reports

This commit is contained in:
2026-07-22 08:44:12 +02:00
parent 21f3014ac5
commit 06125cc0e8
5 changed files with 1043 additions and 4 deletions

View File

@@ -0,0 +1,479 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
from pydantic import BaseModel, ConfigDict
from sqlalchemy import case, func, or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.report_privacy_policy import (
CampaignReportPrivacyPolicy,
effective_campaign_report_privacy_policy,
)
class AggregateCampaignReportError(RuntimeError):
pass
class AggregateReportCampaign(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
name: str
status: str
class AggregateReportCampaignListItem(AggregateReportCampaign):
updated_at: datetime
class AggregateReportCampaignList(BaseModel):
model_config = ConfigDict(extra="forbid")
campaigns: list[AggregateReportCampaignListItem]
class AggregateCount(BaseModel):
model_config = ConfigDict(extra="forbid")
value: int | None
suppressed: bool = False
class AggregatePopulation(BaseModel):
model_config = ConfigDict(extra="forbid")
denominator: AggregateCount
denominator_definition: str
inactive_source_entries: AggregateCount
excluded_or_blocked_jobs: AggregateCount
class AggregateOutcomeCounts(BaseModel):
model_config = ConfigDict(extra="forbid")
smtp_accepted: AggregateCount
failed: AggregateCount
outcome_unknown: AggregateCount
queued_or_active: AggregateCount
cancelled: AggregateCount
excluded: AggregateCount
not_attempted: AggregateCount
class AggregateTimeRange(BaseModel):
model_config = ConfigDict(extra="forbid")
first_activity_at: datetime | None
last_activity_at: datetime | None
suppressed: bool
class AggregatePrivacy(BaseModel):
model_config = ConfigDict(extra="forbid")
small_cell_threshold: int
suppression_applied: bool
rule: str
class AggregateCampaignReport(BaseModel):
model_config = ConfigDict(extra="forbid")
generated_at: datetime
campaign: AggregateReportCampaign
version_number: int | None
completion_state: Literal[
"not_started",
"in_progress",
"completed",
"partially_completed",
"incomplete",
"outcome_unknown",
"suppressed",
]
population: AggregatePopulation
outcomes: AggregateOutcomeCounts
time_range: AggregateTimeRange
privacy: AggregatePrivacy
_OUTCOME_KEYS = (
"smtp_accepted",
"failed",
"outcome_unknown",
"queued_or_active",
"cancelled",
"excluded",
"not_attempted",
)
def generate_aggregate_campaign_report(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
) -> AggregateCampaignReport:
"""Build the deliberately small, recipient-free Campaign report projection."""
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
facts = _query_aggregate_facts(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
)
policy = effective_campaign_report_privacy_policy(session, tenant_id=tenant_id)
return _build_aggregate_campaign_report(
campaign=campaign,
version=version,
facts=facts,
policy=policy,
)
def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign:
campaign = (
session.query(Campaign)
.filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id)
.one_or_none()
)
if campaign is None:
raise AggregateCampaignReportError("Campaign not found")
return campaign
def _selected_version(
session: Session,
campaign: Campaign,
version_id: str | None,
) -> CampaignVersion | None:
selected_id = version_id or campaign.current_version_id
if not selected_id:
return None
version = session.get(CampaignVersion, selected_id)
if version is None or version.campaign_id != campaign.id:
raise AggregateCampaignReportError("Campaign version not found")
return version
def _query_aggregate_facts(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> _AggregateFacts:
if version is None:
return _AggregateFacts.empty()
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"})
failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"})
unknown = CampaignJob.send_status == "outcome_unknown"
active = CampaignJob.send_status.in_({"queued", "claimed", "sending"})
cancelled = CampaignJob.send_status == "cancelled"
excluded = CampaignJob.send_status == "skipped"
excluded_or_blocked = or_(
CampaignJob.validation_status.in_({"blocked", "excluded", "inactive"}),
CampaignJob.build_status != "built",
)
row = (
session.query(
func.count(CampaignJob.id).label("denominator"),
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"),
func.sum(case((failed, 1), else_=0)).label("failed"),
func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"),
func.sum(case((active, 1), else_=0)).label("queued_or_active"),
func.sum(case((cancelled, 1), else_=0)).label("cancelled"),
func.sum(case((excluded, 1), else_=0)).label("excluded"),
func.sum(
case((or_(accepted, failed, unknown, active, cancelled, excluded), 0), else_=1)
).label("not_attempted"),
func.sum(case((excluded_or_blocked, 1), else_=0)).label("excluded_or_blocked"),
func.min(CampaignJob.queued_at).label("queued_min"),
func.max(CampaignJob.queued_at).label("queued_max"),
func.min(CampaignJob.smtp_started_at).label("smtp_min"),
func.max(CampaignJob.smtp_started_at).label("smtp_max"),
func.min(CampaignJob.sent_at).label("sent_min"),
func.max(CampaignJob.sent_at).label("sent_max"),
func.min(CampaignJob.outcome_unknown_at).label("unknown_min"),
func.max(CampaignJob.outcome_unknown_at).label("unknown_max"),
)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.campaign_version_id == version.id,
)
.one()
)
values = row._mapping
activity = [
values[key]
for key in (
"queued_min",
"queued_max",
"smtp_min",
"smtp_max",
"sent_min",
"sent_max",
"unknown_min",
"unknown_max",
)
if values[key] is not None
]
return _AggregateFacts(
outcomes={key: int(values[key] or 0) for key in _OUTCOME_KEYS},
denominator=int(values["denominator"] or 0),
excluded_or_blocked=int(values["excluded_or_blocked"] or 0),
first_activity_at=min(activity) if activity else None,
last_activity_at=max(activity) if activity else None,
)
def build_aggregate_campaign_report(
*,
campaign: Campaign,
version: CampaignVersion | None,
jobs: list[CampaignJob],
policy: CampaignReportPrivacyPolicy,
generated_at: datetime | None = None,
) -> AggregateCampaignReport:
return _build_aggregate_campaign_report(
campaign=campaign,
version=version,
facts=_facts_from_jobs(jobs),
policy=policy,
generated_at=generated_at,
)
def _build_aggregate_campaign_report(
*,
campaign: Campaign,
version: CampaignVersion | None,
facts: _AggregateFacts,
policy: CampaignReportPrivacyPolicy,
generated_at: datetime | None = None,
) -> AggregateCampaignReport:
outcome_values = facts.outcomes
outcomes, denominator = _suppress_partition(
outcome_values,
threshold=policy.small_cell_threshold,
)
inactive_entries = _inactive_entry_count(version)
standalone_counts = {
"inactive_source_entries": _suppress_standalone_count(
inactive_entries,
threshold=policy.small_cell_threshold,
),
"excluded_or_blocked_jobs": _suppress_standalone_count(
facts.excluded_or_blocked,
threshold=policy.small_cell_threshold,
),
}
suppression_applied = denominator.suppressed or any(
item.suppressed for item in (*outcomes.values(), *standalone_counts.values())
)
first_activity = facts.first_activity_at
last_activity = facts.last_activity_at
suppress_time_range = suppression_applied or (
0 < facts.denominator < policy.small_cell_threshold
)
return AggregateCampaignReport(
generated_at=generated_at or datetime.now(timezone.utc),
campaign=AggregateReportCampaign(
id=campaign.id,
name=campaign.name,
status=campaign.status,
),
version_number=version.version_number if version else None,
completion_state=(
"suppressed"
if denominator.suppressed
else _completion_state(outcome_values, facts.denominator)
),
population=AggregatePopulation(
denominator=denominator,
denominator_definition=(
"All recipient delivery jobs built for the selected campaign version. "
"Inactive source entries are excluded and reported separately."
),
inactive_source_entries=standalone_counts["inactive_source_entries"],
excluded_or_blocked_jobs=standalone_counts["excluded_or_blocked_jobs"],
),
outcomes=AggregateOutcomeCounts(**outcomes),
time_range=AggregateTimeRange(
first_activity_at=None if suppress_time_range else first_activity,
last_activity_at=None if suppress_time_range else last_activity,
suppressed=suppress_time_range and first_activity is not None,
),
privacy=AggregatePrivacy(
small_cell_threshold=policy.small_cell_threshold,
suppression_applied=suppression_applied,
rule=(
"Positive counts below the threshold are hidden. At least one additional "
"count or the denominator is hidden when needed to prevent subtraction."
),
),
)
def aggregate_report_campaign_item(campaign: Campaign) -> AggregateReportCampaignListItem:
return AggregateReportCampaignListItem(
id=campaign.id,
name=campaign.name,
status=campaign.status,
updated_at=campaign.updated_at,
)
def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
counts: Counter[str] = Counter()
for job in jobs:
status = job.send_status
if status in {"smtp_accepted", "sent"}:
counts["smtp_accepted"] += 1
elif status in {"failed_temporary", "failed_permanent"}:
counts["failed"] += 1
elif status == "outcome_unknown":
counts["outcome_unknown"] += 1
elif status in {"queued", "claimed", "sending"}:
counts["queued_or_active"] += 1
elif status == "cancelled":
counts["cancelled"] += 1
elif status == "skipped":
counts["excluded"] += 1
else:
counts["not_attempted"] += 1
return {key: counts[key] for key in _OUTCOME_KEYS}
@dataclass(frozen=True, slots=True)
class _AggregateFacts:
outcomes: dict[str, int]
denominator: int
excluded_or_blocked: int
first_activity_at: datetime | None
last_activity_at: datetime | None
@classmethod
def empty(cls) -> _AggregateFacts:
return cls(
outcomes={key: 0 for key in _OUTCOME_KEYS},
denominator=0,
excluded_or_blocked=0,
first_activity_at=None,
last_activity_at=None,
)
def _facts_from_jobs(jobs: list[CampaignJob]) -> _AggregateFacts:
first_activity, last_activity = _activity_range(jobs)
return _AggregateFacts(
outcomes=_outcome_counts(jobs),
denominator=len(jobs),
excluded_or_blocked=sum(
1
for job in jobs
if job.validation_status in {"blocked", "excluded", "inactive"}
or job.build_status != "built"
),
first_activity_at=first_activity,
last_activity_at=last_activity,
)
def _suppress_partition(
counts: dict[str, int],
*,
threshold: int,
) -> tuple[dict[str, AggregateCount], AggregateCount]:
total = sum(counts.values())
suppressed = {key for key, value in counts.items() if 0 < value < threshold}
suppress_denominator = False
if suppressed:
companions = [
(value, key)
for key, value in counts.items()
if key not in suppressed and value > 0
]
if companions:
suppressed.add(max(companions)[1])
else:
suppress_denominator = True
denominator = AggregateCount(
value=None if suppress_denominator else total,
suppressed=suppress_denominator,
)
return (
{
key: AggregateCount(
value=None if key in suppressed else value,
suppressed=key in suppressed,
)
for key, value in counts.items()
},
denominator,
)
def _suppress_standalone_count(value: int, *, threshold: int) -> AggregateCount:
if 0 < value < threshold:
return AggregateCount(value=None, suppressed=True)
return AggregateCount(value=value, suppressed=False)
def _completion_state(
counts: dict[str, int],
total: int,
) -> Literal[
"not_started",
"in_progress",
"completed",
"partially_completed",
"incomplete",
"outcome_unknown",
]:
if total == 0 or counts["not_attempted"] + counts["excluded"] == total:
return "not_started"
if counts["outcome_unknown"]:
return "outcome_unknown"
if counts["queued_or_active"]:
return "in_progress"
accepted = counts["smtp_accepted"]
if accepted == total:
return "completed"
if accepted:
return "partially_completed"
return "incomplete"
def _activity_range(jobs: list[CampaignJob]) -> tuple[datetime | None, datetime | None]:
activity = [
value
for job in jobs
for value in (
job.queued_at,
job.smtp_started_at,
job.sent_at,
job.outcome_unknown_at,
)
if value is not None
]
if not activity:
return None, None
return min(activity), max(activity)
def _inactive_entry_count(version: CampaignVersion | None) -> int:
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)