From 8ee87b755811ca05060464810fba3a04624b36d5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 08:49:38 +0200 Subject: [PATCH] feat(campaign): surface aggregate reports --- .../backend/documentation.py | 34 +++++++++++++++---- src/govoplan_campaign/backend/manifest.py | 8 +++++ .../backend/reports/aggregate.py | 5 +-- tests/test_documentation.py | 12 +++++++ webui/package.json | 3 +- webui/src/module.ts | 19 ++--------- 6 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index 6e64827..c5996e6 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -330,12 +330,35 @@ CAMPAIGN_USER_DOCUMENTATION = ( related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"), related_modules=("mail",), ), + _workflow_topic( + topic_id="campaigns.workflow.view-aggregate-delivery-report", + title="Review aggregate campaign outcomes", + summary="Inspect privacy-protected Campaign totals without receiving recipient rows, message content, delivery diagnostics, or export authority.", + body="The aggregate Reports view exposes only approved campaign-level business outcomes. Positive cells below the configured threshold are suppressed together with a complementary value or the denominator when needed, so totals cannot be subtracted to recover a small group.", + order=37, + audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"), + required_scopes=("campaigns:report:read",), + route="/reports", + screen="Reports", + help_contexts=("campaign.report",), + prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",), + steps=( + "Open Reports and select a campaign available to you.", + "Read the stated denominator before comparing accepted, failed, unknown, active, excluded, cancelled, and unattempted outcomes.", + "Treat Suppressed as an intentional privacy boundary rather than zero or missing data.", + "Request separately authorized recipient-level access only when the task genuinely requires individual evidence.", + ), + outcome="A business-level Campaign outcome view with small-group and recipient privacy preserved.", + verification="No row, address, message, attachment, diagnostic, filter, drill-down, or export action is available from the aggregate view.", + related_topic_ids=("campaigns.workflow.view-delivery-report",), + links=(DocumentationLink(label="Aggregate Campaign reports", href="/reports", kind="runtime"),), + ), _workflow_topic( topic_id="campaigns.workflow.view-delivery-report", title="Review campaign delivery details", summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.", - body="The current Report UI requires recipient-read authority in addition to report access. Infrastructure diagnostics remain separately authorized, and every report action is still checked by the server even where the current UI has not yet hidden an unavailable control.", - order=37, + body="The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface.", + order=38, audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"), required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"), route="/campaigns/{campaign_id}/report", @@ -344,21 +367,20 @@ CAMPAIGN_USER_DOCUMENTATION = ( prerequisites=("You may read the selected campaign and its report.",), steps=( "Open the campaign and select Report.", - "Review totals for queued, accepted, failed, cancelled, unattempted, and outcome-unknown jobs.", + "Review totals for queued, accepted, failed, cancelled, excluded/skipped, unattempted, and outcome-unknown jobs.", "Inspect the authorized recipient-level rows and attempt history.", "Treat outcome-unknown jobs as unresolved and hand them to an authorized operator for evidence-backed reconciliation.", ), outcome="A recipient-aware view of delivery progress and outcomes with diagnostics still separately protected.", verification="The report totals and job rows match the selected campaign version, and infrastructure diagnostics are absent unless separately authorized.", - related_topic_ids=("campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"), - limitations=("An aggregate-only Report Web UI for readers without recipient-read authority is not implemented yet.",), + related_topic_ids=("campaigns.workflow.view-aggregate-delivery-report", "campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"), ), _workflow_topic( topic_id="campaigns.workflow.export-delivery-report", title="Export recipient delivery results", summary="Download an authorized CSV snapshot of recipient-level campaign delivery results for controlled downstream use.", body="A report export contains personal and delivery evidence. Store, transmit, retain, and delete it according to the campaign's purpose and the applicable export and retention policy.", - order=38, + order=39, audience=("campaign_report_exporter", "campaign_auditor"), required_scopes=( "campaigns:campaign:read", diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 7e2453a..38d8619 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -75,6 +75,14 @@ PERMISSIONS = ( ) ROLE_TEMPLATES = ( + RoleTemplate( + slug="campaign_aggregate_reader", + name="Campaign aggregate reader", + description="View privacy-protected outcome totals without recipient or delivery diagnostics.", + permissions=( + "campaigns:report:read", + ), + ), RoleTemplate( slug="campaign_manager", name="Campaign manager", diff --git a/src/govoplan_campaign/backend/reports/aggregate.py b/src/govoplan_campaign/backend/reports/aggregate.py index 4e82f7c..8aad36b 100644 --- a/src/govoplan_campaign/backend/reports/aggregate.py +++ b/src/govoplan_campaign/backend/reports/aggregate.py @@ -304,8 +304,9 @@ def _build_aggregate_campaign_report( population=AggregatePopulation( denominator=denominator, denominator_definition=( - "All recipient delivery jobs built for the selected campaign version. " - "Inactive source entries are excluded and reported separately." + "All persisted recipient delivery jobs for the selected campaign version, " + "including excluded or blocked jobs. Inactive source entries without a job " + "record are excluded and reported separately." ), inactive_source_entries=standalone_counts["inactive_source_entries"], excluded_or_blocked_jobs=standalone_counts["excluded_or_blocked_jobs"], diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 80eb48f..a61578a 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -352,6 +352,18 @@ def test_report_export_and_lifecycle_tasks_are_independently_permission_gated() assert "campaigns.workflow.export-delivery-report" not in custodian +def test_aggregate_report_task_never_implies_recipient_detail_or_export_authority() -> None: + visible = _visible_static_topics({"campaigns:report:read"}) + + assert visible == {"campaigns.workflow.view-aggregate-delivery-report"} + topic = next( + item for item in CAMPAIGN_USER_DOCUMENTATION + if item.id == "campaigns.workflow.view-aggregate-delivery-report" + ) + assert topic.metadata["route"] == "/reports" + assert "export" in topic.metadata["verification"].lower() + + def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None: from govoplan_campaign.backend.manifest import get_manifest diff --git a/webui/package.json b/webui/package.json index 32b35d6..6d7b5d0 100644 --- a/webui/package.json +++ b/webui/package.json @@ -29,7 +29,8 @@ "test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js", "test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js", "test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs", - "test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs" + "test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs", + "test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/src/module.ts b/webui/src/module.ts index a5e9e08..2d4d80a 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,6 +1,6 @@ import { createElement, lazy, useCallback } from "react"; import { useParams } from "react-router-dom"; -import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui"; +import { ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui"; import { getCampaign } from "./api/campaigns"; import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/campaign-workspace.css"; @@ -8,6 +8,7 @@ import "./styles/campaign-workspace.css"; const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage")); const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace")); const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage")); +const AggregateReportsPage = lazy(() => import("./features/reports/AggregateReportsPage")); const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage")); const campaignRead = ["campaigns:campaign:read"]; @@ -41,7 +42,7 @@ export const campaignModule: PlatformWebModule = { { path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) }, { path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, { path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) }, - { path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) }, + { path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) }, { path: "/templates", order: 90, render: () => createElement(TemplatesPage) }] }; @@ -59,18 +60,4 @@ function CampaignResourceRoute({ settings, auth }: {settings: ApiSettings;auth: }); } -function ReportsPage() { - return createElement( - "div", - { className: "content-pad workspace-data-page" }, - createElement( - "div", - { className: "page-heading workspace-heading" }, - createElement("h1", null, "i18n:govoplan-campaign.reports"), - createElement("p", null, "i18n:govoplan-campaign.reports_module_prepared") - ), - createElement(Card, null, createElement("p", { className: "muted" }, "i18n:govoplan-campaign.next_passes_will_add_functionality_here")) - ); -} - export default campaignModule;