feat(campaign): surface aggregate reports
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user