"""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", ]