feat(campaign): surface aggregate reports

This commit is contained in:
2026-07-22 08:49:38 +02:00
parent 7229fb8e3d
commit 8ee87b7558
6 changed files with 56 additions and 25 deletions

View File

@@ -330,12 +330,35 @@ CAMPAIGN_USER_DOCUMENTATION = (
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"), related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
related_modules=("mail",), 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( _workflow_topic(
topic_id="campaigns.workflow.view-delivery-report", topic_id="campaigns.workflow.view-delivery-report",
title="Review campaign delivery details", title="Review campaign delivery details",
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.", 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.", 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=37, order=38,
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"), audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"), required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
route="/campaigns/{campaign_id}/report", route="/campaigns/{campaign_id}/report",
@@ -344,21 +367,20 @@ CAMPAIGN_USER_DOCUMENTATION = (
prerequisites=("You may read the selected campaign and its report.",), prerequisites=("You may read the selected campaign and its report.",),
steps=( steps=(
"Open the campaign and select Report.", "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.", "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.", "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.", 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.", 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"), related_topic_ids=("campaigns.workflow.view-aggregate-delivery-report", "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.",),
), ),
_workflow_topic( _workflow_topic(
topic_id="campaigns.workflow.export-delivery-report", topic_id="campaigns.workflow.export-delivery-report",
title="Export recipient delivery results", title="Export recipient delivery results",
summary="Download an authorized CSV snapshot of recipient-level campaign delivery results for controlled downstream use.", 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.", 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"), audience=("campaign_report_exporter", "campaign_auditor"),
required_scopes=( required_scopes=(
"campaigns:campaign:read", "campaigns:campaign:read",

View File

@@ -75,6 +75,14 @@ PERMISSIONS = (
) )
ROLE_TEMPLATES = ( 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( RoleTemplate(
slug="campaign_manager", slug="campaign_manager",
name="Campaign manager", name="Campaign manager",

View File

@@ -304,8 +304,9 @@ def _build_aggregate_campaign_report(
population=AggregatePopulation( population=AggregatePopulation(
denominator=denominator, denominator=denominator,
denominator_definition=( denominator_definition=(
"All recipient delivery jobs built for the selected campaign version. " "All persisted recipient delivery jobs for the selected campaign version, "
"Inactive source entries are excluded and reported separately." "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"], inactive_source_entries=standalone_counts["inactive_source_entries"],
excluded_or_blocked_jobs=standalone_counts["excluded_or_blocked_jobs"], excluded_or_blocked_jobs=standalone_counts["excluded_or_blocked_jobs"],

View File

@@ -352,6 +352,18 @@ def test_report_export_and_lifecycle_tasks_are_independently_permission_gated()
assert "campaigns.workflow.export-delivery-report" not in custodian 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: def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None:
from govoplan_campaign.backend.manifest import get_manifest from govoplan_campaign.backend.manifest import get_manifest

View File

@@ -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: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: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: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": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"

View File

@@ -1,6 +1,6 @@
import { createElement, lazy, useCallback } from "react"; import { createElement, lazy, useCallback } from "react";
import { useParams } from "react-router-dom"; 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 { getCampaign } from "./api/campaigns";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css"; import "./styles/campaign-workspace.css";
@@ -8,6 +8,7 @@ import "./styles/campaign-workspace.css";
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage")); const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace")); const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage")); const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
const AggregateReportsPage = lazy(() => import("./features/reports/AggregateReportsPage"));
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage")); const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
const campaignRead = ["campaigns:campaign:read"]; 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", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }, { 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: "/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) }] { 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; export default campaignModule;