From cc93945f7962f04ce14f3f0b69b86234a369fe3c Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 29 Jul 2026 22:19:24 +0200 Subject: [PATCH] feat: integrate reports into Campaign navigation --- .../backend/documentation.py | 4 +- src/govoplan_campaign/backend/manifest.py | 26 ++++++++----- tests/test_documentation.py | 2 +- tests/test_manifest_navigation.py | 31 +++++++++++++++ .../features/campaigns/CampaignModulePage.tsx | 39 ++++++++++++++----- webui/src/module.ts | 21 +++++++--- .../operator-queue-ui-structure.test.mjs | 28 ++++++++++++- 7 files changed, 123 insertions(+), 28 deletions(-) diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index fdb9df2..1e421b9 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -338,7 +338,7 @@ CAMPAIGN_USER_DOCUMENTATION = ( order=37, audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"), required_scopes=("campaigns:report:read",), - route="/reports", + route="/campaigns/reports", screen="Reports", help_contexts=("campaign.report",), prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",), @@ -351,7 +351,7 @@ CAMPAIGN_USER_DOCUMENTATION = ( 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"),), + links=(DocumentationLink(label="Aggregate Campaign reports", href="/campaigns/reports", kind="runtime"),), ), _workflow_topic( topic_id="campaigns.workflow.view-delivery-report", diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index a4b1ecc..5061d2b 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -88,6 +88,13 @@ OPERATOR_QUEUE_REQUIRED_ANY = ( ) # Preserve the former /operator route identity for saved View projections. OPERATOR_QUEUE_SURFACE_ID = "campaigns.route.operator" +# Preserve the former /reports route identity for saved View projections. +REPORTS_SURFACE_ID = "campaigns.route.reports" +REPORTS_REQUIRED_ANY = ("campaigns:report:read",) +CAMPAIGN_MODULE_REQUIRED_ANY = ( + "campaigns:campaign:read", + *REPORTS_REQUIRED_ANY, +) ROLE_TEMPLATES = ( RoleTemplate( @@ -230,8 +237,7 @@ manifest = ModuleManifest( role_templates=ROLE_TEMPLATES, tenant_summary_providers=(_tenant_summary,), nav_items=( - NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), - NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70), + NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20), ), frontend=FrontendModule( module_id="campaigns", @@ -239,8 +245,8 @@ manifest = ModuleManifest( routes=( FrontendRoute( path="/campaigns", - component="CampaignListPage", - required_any=("campaigns:campaign:read",), + component="CampaignModulePage", + required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20, ), FrontendRoute( @@ -258,16 +264,16 @@ manifest = ModuleManifest( order=22, ), FrontendRoute( - path="/reports", + path="/campaigns/reports", component="AggregateReportsPage", - required_any=("campaigns:report:read",), - order=70, + required_any=REPORTS_REQUIRED_ANY, + order=22, + surface_id=REPORTS_SURFACE_ID, ), FrontendRoute(path="/templates", component="TemplatesPage", order=90), ), nav_items=( - NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), - NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70), + NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20), NavItem(path="/templates", label="Templates", icon="layout-template", order=90), ), view_surfaces=( @@ -461,7 +467,7 @@ manifest = ModuleManifest( ), links=( DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"), - DocumentationLink(label="Campaign reports", href="/reports", kind="runtime"), + DocumentationLink(label="Campaign reports", href="/campaigns/reports", kind="runtime"), DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"), ), related_modules=("mail", "audit"), diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 441d603..f62dd9e 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -361,7 +361,7 @@ def test_aggregate_report_task_never_implies_recipient_detail_or_export_authorit item for item in CAMPAIGN_USER_DOCUMENTATION if item.id == "campaigns.workflow.view-aggregate-delivery-report" ) - assert topic.metadata["route"] == "/reports" + assert topic.metadata["route"] == "/campaigns/reports" assert "export" in topic.metadata["verification"].lower() diff --git a/tests/test_manifest_navigation.py b/tests/test_manifest_navigation.py index a9d4ed4..c2bad5d 100644 --- a/tests/test_manifest_navigation.py +++ b/tests/test_manifest_navigation.py @@ -1,8 +1,11 @@ from __future__ import annotations from govoplan_campaign.backend.manifest import ( + CAMPAIGN_MODULE_REQUIRED_ANY, OPERATOR_QUEUE_REQUIRED_ANY, OPERATOR_QUEUE_SURFACE_ID, + REPORTS_REQUIRED_ANY, + REPORTS_SURFACE_ID, get_manifest, ) from govoplan_core.core.registry import manifest_view_surfaces @@ -31,3 +34,31 @@ def test_operator_queue_is_an_integrated_campaign_view() -> None: ] assert len(queue_surfaces) == 1 assert queue_surfaces[0].description == "/campaigns/queue" + + +def test_aggregate_reports_are_an_integrated_campaign_view() -> None: + manifest = get_manifest() + assert manifest.frontend is not None + + backend_nav = {item.path: item for item in manifest.nav_items} + frontend_nav = {item.path: item for item in manifest.frontend.nav_items} + assert "/reports" not in backend_nav + assert "/reports" not in frontend_nav + assert backend_nav["/campaigns"].required_any == CAMPAIGN_MODULE_REQUIRED_ANY + assert frontend_nav["/campaigns"].required_any == CAMPAIGN_MODULE_REQUIRED_ANY + + routes = {route.path: route for route in manifest.frontend.routes} + assert "/reports" not in routes + report = routes["/campaigns/reports"] + assert report.component == "AggregateReportsPage" + assert report.required_any == REPORTS_REQUIRED_ANY + assert REPORTS_SURFACE_ID == "campaigns.route.reports" + assert report.surface_id == REPORTS_SURFACE_ID + + report_surfaces = [ + surface + for surface in manifest_view_surfaces(manifest) + if surface.id == REPORTS_SURFACE_ID + ] + assert len(report_surfaces) == 1 + assert report_surfaces[0].description == "/campaigns/reports" diff --git a/webui/src/features/campaigns/CampaignModulePage.tsx b/webui/src/features/campaigns/CampaignModulePage.tsx index bb40ae6..46f0f62 100644 --- a/webui/src/features/campaigns/CampaignModulePage.tsx +++ b/webui/src/features/campaigns/CampaignModulePage.tsx @@ -11,8 +11,9 @@ import { OPERATOR_QUEUE_ROUTE_SCOPES } from "../operator/operatorQueueAccess"; const CampaignListPage = lazy(() => import("./CampaignListPage")); const OperatorQueuePage = lazy(() => import("../operator/OperatorQueuePage")); +const AggregateReportsPage = lazy(() => import("../reports/AggregateReportsPage")); -export type CampaignModuleSection = "campaigns" | "queue"; +export type CampaignModuleSection = "campaigns" | "queue" | "reports"; export default function CampaignModulePage({ active, @@ -24,24 +25,42 @@ export default function CampaignModulePage({ auth: AuthInfo; }) { const navigate = useGuardedNavigate(); + const canReadCampaigns = hasAnyScope(auth, ["campaigns:campaign:read"]); + const canUseQueue = + canReadCampaigns && hasAnyScope(auth, OPERATOR_QUEUE_ROUTE_SCOPES); + const canReadReports = hasAnyScope(auth, ["campaigns:report:read"]); const groups: ModuleSubnavGroup[] = [{ items: [ - { - id: "campaigns", - label: "i18n:govoplan-campaign.all_campaigns.2bd1ee3a", - primary: true - }, - ...(hasAnyScope(auth, OPERATOR_QUEUE_ROUTE_SCOPES) + ...(canReadCampaigns + ? [{ + id: "campaigns" as const, + label: "i18n:govoplan-campaign.all_campaigns.2bd1ee3a", + primary: true + }] + : []), + ...(canUseQueue ? [{ id: "queue" as const, label: "i18n:govoplan-campaign.operator_queue.72492fb5" }] + : []), + ...(canReadReports + ? [{ + id: "reports" as const, + label: "i18n:govoplan-campaign.reports.88bc3fe3", + primary: !canReadCampaigns + }] : []) ] }]; function select(section: CampaignModuleSection) { - navigate(section === "queue" ? "/campaigns/queue" : "/campaigns"); + const routes: Record = { + campaigns: "/campaigns", + queue: "/campaigns/queue", + reports: "/campaigns/reports" + }; + navigate(routes[section]); } return ( @@ -50,7 +69,9 @@ export default function CampaignModulePage({
{active === "queue" ? - : } + : active === "reports" + ? + : }
); diff --git a/webui/src/module.ts b/webui/src/module.ts index a2b451c..bb3b18e 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -2,6 +2,7 @@ import { createElement, lazy, useCallback } from "react"; import { Navigate, useParams } from "react-router-dom"; import { ResourceAccessBoundary, + hasAnyScope, type ApiSettings, type AuthInfo, type DashboardWidgetsUiCapability, @@ -15,12 +16,15 @@ import "./styles/campaign-workspace.css"; const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage")); const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace")); -const AggregateReportsPage = lazy(() => import("./features/reports/AggregateReportsPage")); const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage")); const campaignRead = ["campaigns:campaign:read"]; +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, de: generatedTranslations.de @@ -87,16 +91,16 @@ export const campaignModule: PlatformWebModule = { } ], navItems: [ - { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 }, - { to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 }, + { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 }, { to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }], routes: [ - { path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "campaigns", settings, auth }) }, + { 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: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) }, { path: "/templates", order: 90, render: () => createElement(TemplatesPage) }], uiCapabilities: { "dashboard.widgets": campaignDashboardWidgets @@ -104,6 +108,13 @@ export const campaignModule: PlatformWebModule = { }; +function CampaignModuleLandingRoute({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { + if (!hasAnyScope(auth, campaignRead)) { + return createElement(Navigate, { to: "/campaigns/reports", replace: true }); + } + return createElement(CampaignModulePage, { active: "campaigns", settings, auth }); +} + function CampaignResourceRoute({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { const { campaignId = "" } = useParams(); const tenantId = (auth.active_tenant ?? auth.tenant).id; diff --git a/webui/tests/operator-queue-ui-structure.test.mjs b/webui/tests/operator-queue-ui-structure.test.mjs index f40e8ec..33dacde 100644 --- a/webui/tests/operator-queue-ui-structure.test.mjs +++ b/webui/tests/operator-queue-ui-structure.test.mjs @@ -67,11 +67,19 @@ assert.doesNotMatch(source, /humanize\(/); assert.match(campaignModulePage, /