From 4eeba62bbca8da76b72f00648d597f185b20d964 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 05:29:34 +0200 Subject: [PATCH] feat: contribute governed campaign reports --- README.md | 7 +- docs/CAMPAIGN_HANDBOOK.md | 9 + src/govoplan_campaign/backend/manifest.py | 23 ++ .../backend/reports/provider.py | 302 ++++++++++++++++++ tests/test_aggregate_report_routes.py | 80 +++-- webui/src/module.ts | 2 - .../operator-queue-ui-structure.test.mjs | 10 +- 7 files changed, 404 insertions(+), 29 deletions(-) create mode 100644 src/govoplan_campaign/backend/reports/provider.py diff --git a/README.md b/README.md index c93b179..304fb99 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,17 @@ This repository owns: - campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths - WebUI package `@govoplan/campaign-webui` - route contributions for `/campaigns`, the integrated `/campaigns/queue` view, - `/campaigns/:campaignId/*`, `/reports`, and `/templates` + `/campaigns/reports`, `/campaigns/:campaignId/*`, and `/templates` Core owns the auth facade, RBAC/capability contracts, database/session primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an optional platform module for tenant administration and tenant resolver behavior. Files and mail own their respective storage and transport capabilities. +When the optional Reporting module is enabled, Campaign contributes its +recipient-free aggregate delivery report through the versioned Core report +provider contract. Reporting owns the global `/reports` route. Campaign keeps +its module-local `/campaigns/reports` view and does not claim the global route +when Reporting is absent. Generated EML is durable execution material, not a node-local runtime cache. Campaign stores it through Core's shared object-storage contract under opaque diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index e47876f..2275912 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -167,6 +167,15 @@ every action control that the actor lacks. The server authorizes each action, but permission-aware action visibility on that detailed surface remains open work; do not confuse it with the aggregate reader experience. +The module-local aggregate surface remains at `/campaigns/reports`. When the +optional Reporting module is enabled, Campaign also contributes the same +recipient-free projection as the `campaigns/delivery-outcomes` report provider. +Reporting owns `/reports`, records the run purpose, effective audience, source +campaign/version revision, privacy transformations, retention, actor/time, +output hash, and export history, and applies Policy before returning the +result. Campaign does not register a fallback `/reports` route when Reporting +is absent. + The detailed Campaign Report includes the selected campaign's effective retention policy, its system/tenant/owner/campaign provenance, and the current evidence state. It distinguishes retained, redacted, expired, partially diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 7432ae6..f6d99d8 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -20,6 +20,7 @@ from govoplan_core.core.module_guards import ( ) from govoplan_core.core.ownership import OwnershipProviderRegistration from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationCondition, DocumentationLink, DocumentationTopic, @@ -34,6 +35,7 @@ from govoplan_core.core.modules import ( PermissionDefinition, RoleTemplate, ) +from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX from govoplan_core.core.operations import OperationalCheckProviderRegistration from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.views import ViewSurface @@ -363,6 +365,7 @@ manifest = ModuleManifest( "addresses", "postbox", "approvals", + "reporting", ), provides_interfaces=( ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"), @@ -370,6 +373,10 @@ manifest = ModuleManifest( ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"), ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"), + ModuleInterfaceProvider( + name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns", + version="1.0.0", + ), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -1068,6 +1075,22 @@ manifest = ModuleManifest( "govoplan_campaign.backend.capabilities", fromlist=["retention_capability"], ).retention_capability(context), + REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__( + "govoplan_campaign.backend.reports.provider", + fromlist=["CampaignAggregateReportProvider"], + ).CampaignAggregateReportProvider(), + }, + capability_documentation={ + REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation( + label="Campaign aggregate report provider", + summary=( + "Publishes recipient-free campaign delivery outcomes with " + "small-cell and complementary suppression." + ), + contract_version="1.0", + documentation_types=("admin", "user"), + audience=("user", "reporting_analyst", "privacy_officer"), + ), }, operational_check_providers=( OperationalCheckProviderRegistration( diff --git a/src/govoplan_campaign/backend/reports/provider.py b/src/govoplan_campaign/backend/reports/provider.py new file mode 100644 index 0000000..a7ac28e --- /dev/null +++ b/src/govoplan_campaign/backend/reports/provider.py @@ -0,0 +1,302 @@ +"""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.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", +] diff --git a/tests/test_aggregate_report_routes.py b/tests/test_aggregate_report_routes.py index 302c605..b8b0d68 100644 --- a/tests/test_aggregate_report_routes.py +++ b/tests/test_aggregate_report_routes.py @@ -18,6 +18,11 @@ from govoplan_campaign.backend.reports.aggregate import ( AggregateCampaignReportError, generate_aggregate_campaign_report, ) +from govoplan_campaign.backend.reports.provider import ( + CAMPAIGN_REPORT_PRIVACY_TRANSFORMS, + CampaignAggregateReportProvider, +) +from govoplan_core.core.reporting import ReportProviderRequest from govoplan_campaign.backend.schemas import ReportEmailRequest from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base @@ -40,7 +45,9 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None: campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1") with ( - patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign), + patch.object( + report_routes, "_get_campaign_for_principal", return_value=campaign + ), pytest.raises(HTTPException) as full_report_denied, ): report_routes.campaign_report( @@ -64,7 +71,9 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None: assert job_detail_denied.value.status_code == 403 with ( - patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign), + patch.object( + report_routes, "_get_campaign_for_principal", return_value=campaign + ), pytest.raises(HTTPException) as report_email_denied, ): report_routes.email_campaign_report( @@ -119,10 +128,15 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None: ) -@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"]) +@pytest.mark.parametrize( + "path", + ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"], +) def test_aggregate_routes_require_report_read_permission(path: str) -> None: route = next(item for item in campaign_api.router.routes if item.path == path) - dependency = next(item for item in route.dependant.dependencies if item.name == "principal") + dependency = next( + item for item in route.dependant.dependencies if item.name == "principal" + ) with pytest.raises(HTTPException) as denied: dependency.call(_Principal()) @@ -148,7 +162,9 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module() ], ) with Session(engine) as session: - session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={})) + session.add( + Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}) + ) campaign = Campaign( id="campaign-1", tenant_id="tenant-1", @@ -168,20 +184,22 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module() campaign.current_version_id = version.id session.add_all([campaign, version]) for index in range(5): - session.add(CampaignJob( - id=f"job-{index}", - tenant_id="tenant-1", - campaign_id=campaign.id, - campaign_version_id=version.id, - entry_index=index, - recipient_email=f"private-{index}@example.test", - subject="Private", - build_status="built", - validation_status="ready", - queue_status="queued", - send_status="smtp_accepted", - imap_status="not_requested", - )) + session.add( + CampaignJob( + id=f"job-{index}", + tenant_id="tenant-1", + campaign_id=campaign.id, + campaign_version_id=version.id, + entry_index=index, + recipient_email=f"private-{index}@example.test", + subject="Private", + build_status="built", + validation_status="ready", + queue_status="queued", + send_status="smtp_accepted", + imap_status="not_requested", + ) + ) session.commit() report = generate_aggregate_campaign_report( @@ -192,6 +210,30 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module() assert report.population.denominator.value == 5 assert report.outcomes.smtp_accepted.value == 5 + provider = CampaignAggregateReportProvider() + provider_principal = _Principal( + "campaigns:report:read", + "tenant:*", + ) + descriptors = provider.list_reports(session, provider_principal) + assert descriptors[0].report_id == "delivery-outcomes" + assert descriptors[0].reidentification_risk == "low" + provided = provider.execute_report( + session, + provider_principal, + request=ReportProviderRequest( + report_id="delivery-outcomes", + parameters={"campaign_id": campaign.id}, + purpose="Tenant delivery overview", + audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"}, + ), + ) + assert set(provided.applied_privacy_transforms) == set( + CAMPAIGN_REPORT_PRIVACY_TRANSFORMS + ) + assert "recipient_email" not in repr(provided.payload) + assert provided.source_revisions[0]["revision_id"] == version.id + with pytest.raises(AggregateCampaignReportError): generate_aggregate_campaign_report( session, diff --git a/webui/src/module.ts b/webui/src/module.ts index 650f86b..7bb314c 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -24,7 +24,6 @@ const reportRead = ["campaigns:report:read"]; const campaignModuleRead = [...campaignRead, ...reportRead]; // Preserve the former /operator route identity for saved View projections. const operatorQueueSurface = "campaigns.route.operator"; -// Preserve the former /reports route identity for saved View projections. const reportsSurface = "campaigns.route.reports"; const translations = { en: generatedTranslations.en, @@ -99,7 +98,6 @@ export const campaignModule: PlatformWebModule = { { path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) }, { path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) }, { path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) }, - { path: "/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: () => createElement(Navigate, { to: "/campaigns/reports", replace: true }) }, { path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) }, { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, { path: "/templates", order: 90, render: () => createElement(TemplatesPage) }], diff --git a/webui/tests/operator-queue-ui-structure.test.mjs b/webui/tests/operator-queue-ui-structure.test.mjs index 33dacde..92a4af8 100644 --- a/webui/tests/operator-queue-ui-structure.test.mjs +++ b/webui/tests/operator-queue-ui-structure.test.mjs @@ -77,7 +77,7 @@ assert.match(campaignModulePage, /active === "reports"[\s\S]*