309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""Cross-module provider for Campaign's recipient-free aggregate report."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_campaign.backend.db.models import Campaign
|
|
from govoplan_campaign.backend.report_privacy_policy import (
|
|
effective_campaign_report_privacy_policy,
|
|
)
|
|
from govoplan_campaign.backend.reports.aggregate import (
|
|
generate_aggregate_campaign_report,
|
|
)
|
|
from govoplan_campaign.backend.route_support import (
|
|
_campaign_query_for_principal,
|
|
_get_campaign_for_principal,
|
|
)
|
|
from govoplan_core.auth import has_scope
|
|
from govoplan_core.core.reporting import (
|
|
REPORT_PROVIDER_CONTRACT_VERSION,
|
|
ReportDescriptor,
|
|
ReportParameterDescriptor,
|
|
ReportParameterOption,
|
|
ReportPrivacyTransform,
|
|
ReportProviderRequest,
|
|
ReportProviderResult,
|
|
ReportResultField,
|
|
)
|
|
|
|
|
|
CAMPAIGN_AGGREGATE_REPORT_ID = "delivery-outcomes"
|
|
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS = (
|
|
"server_side_aggregation",
|
|
"small_cell_suppression",
|
|
"complementary_suppression",
|
|
"explicit_denominator",
|
|
"recipient_payload_exclusion",
|
|
)
|
|
|
|
|
|
class CampaignAggregateReportProvider:
|
|
provider_id = "campaigns"
|
|
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
|
|
|
|
def list_reports(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
) -> tuple[ReportDescriptor, ...]:
|
|
del session
|
|
if not has_scope(principal, "campaigns:report:read"):
|
|
return ()
|
|
return (_descriptor(),)
|
|
|
|
def parameter_options(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
report_id: str,
|
|
parameter_key: str,
|
|
query: str,
|
|
limit: int,
|
|
) -> tuple[ReportParameterOption, ...]:
|
|
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or parameter_key != "campaign_id":
|
|
return ()
|
|
if not has_scope(principal, "campaigns:report:read"):
|
|
return ()
|
|
sql_session = _session(session)
|
|
rows = _campaign_query_for_principal(sql_session, principal)
|
|
clean_query = query.strip().casefold()
|
|
campaigns = (
|
|
rows.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
|
|
.limit(max(1, min(limit, 200)) if not clean_query else 500)
|
|
.all()
|
|
)
|
|
if clean_query:
|
|
campaigns = [
|
|
campaign
|
|
for campaign in campaigns
|
|
if clean_query in campaign.name.casefold()
|
|
][: max(1, min(limit, 200))]
|
|
return tuple(
|
|
ReportParameterOption(
|
|
value=campaign.id,
|
|
label=campaign.name,
|
|
description=campaign.status,
|
|
)
|
|
for campaign in campaigns
|
|
)
|
|
|
|
def execute_report(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
request: ReportProviderRequest,
|
|
) -> ReportProviderResult:
|
|
if request.report_id != CAMPAIGN_AGGREGATE_REPORT_ID:
|
|
raise LookupError("Campaign report provider does not know this report")
|
|
if not has_scope(principal, "campaigns:report:read"):
|
|
raise PermissionError("Missing scope: campaigns:report:read")
|
|
campaign_id = str(request.parameters.get("campaign_id") or "").strip()
|
|
if not campaign_id:
|
|
raise ValueError("campaign_id is required")
|
|
version_id = str(request.parameters.get("version_id") or "").strip() or None
|
|
sql_session = _session(session)
|
|
campaign = _get_campaign_for_principal(sql_session, campaign_id, principal)
|
|
report = generate_aggregate_campaign_report(
|
|
sql_session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign.id,
|
|
version_id=version_id,
|
|
)
|
|
policy = effective_campaign_report_privacy_policy(
|
|
sql_session,
|
|
tenant_id=principal.tenant_id,
|
|
)
|
|
selected_version_id = version_id or campaign.current_version_id
|
|
return ReportProviderResult(
|
|
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
|
generated_at=report.generated_at,
|
|
payload=report.model_dump(mode="json"),
|
|
source_revisions=(
|
|
{
|
|
"module_id": "campaigns",
|
|
"resource_type": "campaign",
|
|
"resource_id": campaign.id,
|
|
"revision_type": "campaign_version",
|
|
"revision_id": selected_version_id,
|
|
"version_number": report.version_number,
|
|
"updated_at": campaign.updated_at.isoformat(),
|
|
},
|
|
),
|
|
effective_scope={
|
|
"tenant_id": principal.tenant_id,
|
|
"campaign_id": campaign.id,
|
|
"campaign_version_id": selected_version_id,
|
|
"audience": dict(request.audience_scope),
|
|
},
|
|
applied_privacy_transforms=CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
|
|
provenance={
|
|
"provider": "campaigns",
|
|
"projection": "recipient-free-delivery-outcomes-v1",
|
|
"privacy_policy": policy.as_dict(),
|
|
"purpose": request.purpose,
|
|
},
|
|
)
|
|
|
|
def authorize_result(
|
|
self,
|
|
session: object,
|
|
principal: object,
|
|
*,
|
|
report_id: str,
|
|
source_revisions: tuple[Mapping[str, object], ...],
|
|
effective_scope: Mapping[str, object],
|
|
) -> bool:
|
|
del source_revisions
|
|
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or not has_scope(
|
|
principal, "campaigns:report:read"
|
|
):
|
|
return False
|
|
campaign_id = str(effective_scope.get("campaign_id") or "").strip()
|
|
tenant_id = str(effective_scope.get("tenant_id") or "").strip()
|
|
if not campaign_id or tenant_id != str(getattr(principal, "tenant_id", "")):
|
|
return False
|
|
return (
|
|
_campaign_query_for_principal(_session(session), principal)
|
|
.filter(Campaign.id == campaign_id)
|
|
.first()
|
|
is not None
|
|
)
|
|
|
|
|
|
def _descriptor() -> ReportDescriptor:
|
|
fields = (
|
|
("generated_at", "Generated", "datetime", "Campaign"),
|
|
("campaign.id", "Campaign ID", "string", "Campaign"),
|
|
("campaign.name", "Campaign", "string", "Campaign"),
|
|
("campaign.status", "Status", "string", "Campaign"),
|
|
("version_number", "Version", "integer", "Campaign"),
|
|
("completion_state", "Completion", "string", "Campaign"),
|
|
(
|
|
"population.denominator",
|
|
"Report denominator",
|
|
"suppressed_count",
|
|
"Population",
|
|
),
|
|
(
|
|
"population.denominator_definition",
|
|
"Denominator definition",
|
|
"string",
|
|
"Population",
|
|
),
|
|
(
|
|
"population.inactive_source_entries",
|
|
"Inactive source entries",
|
|
"suppressed_count",
|
|
"Population",
|
|
),
|
|
(
|
|
"population.excluded_or_blocked_jobs",
|
|
"Excluded or blocked jobs",
|
|
"suppressed_count",
|
|
"Population",
|
|
),
|
|
("outcomes.smtp_accepted", "SMTP accepted", "suppressed_count", "Outcomes"),
|
|
(
|
|
"outcomes.postbox_accepted",
|
|
"Postbox accepted",
|
|
"suppressed_count",
|
|
"Outcomes",
|
|
),
|
|
(
|
|
"outcomes.print_accepted",
|
|
"Printable output accepted",
|
|
"suppressed_count",
|
|
"Outcomes",
|
|
),
|
|
(
|
|
"outcomes.delivered",
|
|
"Both channels accepted",
|
|
"suppressed_count",
|
|
"Outcomes",
|
|
),
|
|
(
|
|
"outcomes.partially_accepted",
|
|
"Partially accepted",
|
|
"suppressed_count",
|
|
"Outcomes",
|
|
),
|
|
("outcomes.failed", "Failed", "suppressed_count", "Outcomes"),
|
|
("outcomes.outcome_unknown", "Outcome unknown", "suppressed_count", "Outcomes"),
|
|
(
|
|
"outcomes.queued_or_active",
|
|
"Queued or active",
|
|
"suppressed_count",
|
|
"Outcomes",
|
|
),
|
|
("outcomes.not_attempted", "Not attempted", "suppressed_count", "Outcomes"),
|
|
("outcomes.cancelled", "Cancelled", "suppressed_count", "Outcomes"),
|
|
("outcomes.excluded", "Excluded", "suppressed_count", "Outcomes"),
|
|
("time_range.first_activity_at", "First activity", "datetime", "Activity"),
|
|
("time_range.last_activity_at", "Last activity", "datetime", "Activity"),
|
|
("time_range.suppressed", "Activity range suppressed", "boolean", "Activity"),
|
|
("privacy.small_cell_threshold", "Small-cell threshold", "integer", "Privacy"),
|
|
("privacy.suppression_applied", "Suppression applied", "boolean", "Privacy"),
|
|
("privacy.rule", "Privacy rule", "string", "Privacy"),
|
|
)
|
|
return ReportDescriptor(
|
|
provider_id="campaigns",
|
|
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
|
revision="campaign.aggregate.v1",
|
|
title="Campaign delivery outcomes",
|
|
summary=(
|
|
"Privacy-protected delivery outcomes without recipient-level records."
|
|
),
|
|
parameters=(
|
|
ReportParameterDescriptor(
|
|
key="campaign_id",
|
|
label="Campaign",
|
|
type="reference",
|
|
required=True,
|
|
options_from_provider=True,
|
|
),
|
|
ReportParameterDescriptor(
|
|
key="version_id",
|
|
label="Campaign version",
|
|
type="string",
|
|
required=False,
|
|
description="Leave empty to use the current campaign version.",
|
|
),
|
|
),
|
|
result_schema=tuple(
|
|
ReportResultField(
|
|
path=path,
|
|
label=label,
|
|
type=field_type, # type: ignore[arg-type]
|
|
group=group,
|
|
nullable=path.startswith("time_range.") or path == "version_number",
|
|
)
|
|
for path, label, field_type, group in fields
|
|
),
|
|
privacy_transforms=tuple(
|
|
ReportPrivacyTransform(id=item, label=item.replace("_", " ").title())
|
|
for item in CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
|
|
),
|
|
retention_class="stored_report_detail",
|
|
export_formats=("json",),
|
|
reidentification_risk="low",
|
|
presentation={"kind": "metric_summary"},
|
|
)
|
|
|
|
|
|
def _session(value: object) -> Session:
|
|
if not isinstance(value, Session):
|
|
raise TypeError("Campaign report provider requires a SQLAlchemy Session")
|
|
return value
|
|
|
|
|
|
__all__ = [
|
|
"CAMPAIGN_AGGREGATE_REPORT_ID",
|
|
"CAMPAIGN_REPORT_PRIVACY_TRANSFORMS",
|
|
"CampaignAggregateReportProvider",
|
|
]
|