diff --git a/README.md b/README.md index 48a8af7..eca70a7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ This repository owns: - campaign/version/job/issue/send-attempt/append-attempt models and migrations - 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`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, and `/templates` +- route contributions for `/campaigns`, the integrated `/campaigns/queue` view, + `/campaigns/:campaignId/*`, `/reports`, and `/templates` Core owns the auth facade, RBAC/capability contracts, database/session primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index c5996e6..fdb9df2 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -292,7 +292,7 @@ CAMPAIGN_USER_DOCUMENTATION = ( related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"), links=( DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), - DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"), + DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"), ), related_modules=("mail", "notifications"), ), diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 678d038..a4b1ecc 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -80,6 +80,15 @@ PERMISSIONS = ( _permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"), ) +OPERATOR_QUEUE_REQUIRED_ANY = ( + "campaigns:campaign:queue", + "campaigns:campaign:retry", + "campaigns:campaign:reconcile", + "campaigns:campaign:control", +) +# Preserve the former /operator route identity for saved View projections. +OPERATOR_QUEUE_SURFACE_ID = "campaigns.route.operator" + ROLE_TEMPLATES = ( RoleTemplate( slug="campaign_aggregate_reader", @@ -222,19 +231,6 @@ manifest = ModuleManifest( tenant_summary_providers=(_tenant_summary,), nav_items=( NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), - NavItem( - path="/operator", - label="Operator Queue", - icon="radio-tower", - required_all=("campaigns:campaign:read",), - required_any=( - "campaigns:campaign:queue", - "campaigns:campaign:retry", - "campaigns:campaign:reconcile", - "campaigns:campaign:control", - ), - order=30, - ), NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70), ), frontend=FrontendModule( @@ -247,23 +243,19 @@ manifest = ModuleManifest( required_any=("campaigns:campaign:read",), order=20, ), + FrontendRoute( + path="/campaigns/queue", + component="OperatorQueuePage", + required_all=("campaigns:campaign:read",), + required_any=OPERATOR_QUEUE_REQUIRED_ANY, + order=21, + surface_id=OPERATOR_QUEUE_SURFACE_ID, + ), FrontendRoute( path="/campaigns/:campaignId/*", component="CampaignWorkspace", required_any=("campaigns:campaign:read",), - order=21, - ), - FrontendRoute( - path="/operator", - component="OperatorQueuePage", - required_all=("campaigns:campaign:read",), - required_any=( - "campaigns:campaign:queue", - "campaigns:campaign:retry", - "campaigns:campaign:reconcile", - "campaigns:campaign:control", - ), - order=30, + order=22, ), FrontendRoute( path="/reports", @@ -275,19 +267,6 @@ manifest = ModuleManifest( ), nav_items=( NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20), - NavItem( - path="/operator", - label="Operator Queue", - icon="radio-tower", - required_all=("campaigns:campaign:read",), - required_any=( - "campaigns:campaign:queue", - "campaigns:campaign:retry", - "campaigns:campaign:reconcile", - "campaigns:campaign:control", - ), - order=30, - ), NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70), NavItem(path="/templates", label="Templates", icon="layout-template", order=90), ), @@ -481,7 +460,7 @@ manifest = ModuleManifest( ), ), links=( - DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"), + DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"), DocumentationLink(label="Campaign reports", href="/reports", kind="runtime"), DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"), ), @@ -489,7 +468,7 @@ manifest = ModuleManifest( unlocks=("Fail-closed recovery without exposing Mail credentials.",), metadata={ "kind": "reference", - "route": "/operator", + "route": "/campaigns/queue", "screen": "Campaign operator queue", "section": "Profile-backed delivery recovery", "related_topic_ids": [ @@ -619,14 +598,14 @@ manifest = ModuleManifest( ), ), links=( - DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"), + DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"), DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"), ), related_modules=("mail", "audit"), unlocks=("Evidence-backed recovery without accidental duplicate external effects.",), metadata={ "kind": "workflow", - "route": "/operator", + "route": "/campaigns/queue", "screen": "Campaign operator queue", "help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"], "prerequisites": [ diff --git a/tests/test_access_provider.py b/tests/test_access_provider.py index 724fa0d..41f08a9 100644 --- a/tests/test_access_provider.py +++ b/tests/test_access_provider.py @@ -9,6 +9,7 @@ from govoplan_access.backend.db.models import Account, Group, User from govoplan_campaign.backend.capabilities import CampaignAccessService from govoplan_campaign.backend.db.models import Campaign, CampaignShare from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base @@ -72,7 +73,17 @@ class CampaignAccessProviderTests(unittest.TestCase): def _session(): engine = create_engine("sqlite:///:memory:", future=True) - Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__]) + Base.metadata.create_all( + bind=engine, + tables=[ + Account.__table__, + User.__table__, + Group.__table__, + Campaign.__table__, + CampaignShare.__table__, + ChangeSequenceEntry.__table__, + ], + ) return sessionmaker(bind=engine, future=True)() diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 712beb3..441d603 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -289,7 +289,7 @@ def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_auth topic for topic in CAMPAIGN_USER_DOCUMENTATION if topic.id == "campaigns.workflow.queue-delivery" ) - assert any(link.href == "/operator" for link in queue_topic.links) + assert any(link.href == "/campaigns/queue" for link in queue_topic.links) def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None: diff --git a/tests/test_manifest_navigation.py b/tests/test_manifest_navigation.py new file mode 100644 index 0000000..a9d4ed4 --- /dev/null +++ b/tests/test_manifest_navigation.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from govoplan_campaign.backend.manifest import ( + OPERATOR_QUEUE_REQUIRED_ANY, + OPERATOR_QUEUE_SURFACE_ID, + get_manifest, +) +from govoplan_core.core.registry import manifest_view_surfaces + + +def test_operator_queue_is_an_integrated_campaign_view() -> None: + manifest = get_manifest() + assert manifest.frontend is not None + + assert "/operator" not in {item.path for item in manifest.nav_items} + assert "/operator" not in {item.path for item in manifest.frontend.nav_items} + + routes = {route.path: route for route in manifest.frontend.routes} + assert "/operator" not in routes + queue = routes["/campaigns/queue"] + assert queue.component == "OperatorQueuePage" + assert queue.required_all == ("campaigns:campaign:read",) + assert queue.required_any == OPERATOR_QUEUE_REQUIRED_ANY + assert OPERATOR_QUEUE_SURFACE_ID == "campaigns.route.operator" + assert queue.surface_id == OPERATOR_QUEUE_SURFACE_ID + + queue_surfaces = [ + surface + for surface in manifest_view_surfaces(manifest) + if surface.id == OPERATOR_QUEUE_SURFACE_ID + ] + assert len(queue_surfaces) == 1 + assert queue_surfaces[0].description == "/campaigns/queue" diff --git a/webui/src/features/campaigns/CampaignModulePage.tsx b/webui/src/features/campaigns/CampaignModulePage.tsx new file mode 100644 index 0000000..bb40ae6 --- /dev/null +++ b/webui/src/features/campaigns/CampaignModulePage.tsx @@ -0,0 +1,57 @@ +import { lazy } from "react"; +import { + ModuleSubnav, + hasAnyScope, + useGuardedNavigate, + type ApiSettings, + type AuthInfo, + type ModuleSubnavGroup +} from "@govoplan/core-webui"; +import { OPERATOR_QUEUE_ROUTE_SCOPES } from "../operator/operatorQueueAccess"; + +const CampaignListPage = lazy(() => import("./CampaignListPage")); +const OperatorQueuePage = lazy(() => import("../operator/OperatorQueuePage")); + +export type CampaignModuleSection = "campaigns" | "queue"; + +export default function CampaignModulePage({ + active, + settings, + auth +}: { + active: CampaignModuleSection; + settings: ApiSettings; + auth: AuthInfo; +}) { + const navigate = useGuardedNavigate(); + const groups: ModuleSubnavGroup[] = [{ + items: [ + { + id: "campaigns", + label: "i18n:govoplan-campaign.all_campaigns.2bd1ee3a", + primary: true + }, + ...(hasAnyScope(auth, OPERATOR_QUEUE_ROUTE_SCOPES) + ? [{ + id: "queue" as const, + label: "i18n:govoplan-campaign.operator_queue.72492fb5" + }] + : []) + ] + }]; + + function select(section: CampaignModuleSection) { + navigate(section === "queue" ? "/campaigns/queue" : "/campaigns"); + } + + return ( +
+ +
+ {active === "queue" + ? + : } +
+
+ ); +} diff --git a/webui/src/features/operator/operatorQueueAccess.ts b/webui/src/features/operator/operatorQueueAccess.ts new file mode 100644 index 0000000..e0f0c77 --- /dev/null +++ b/webui/src/features/operator/operatorQueueAccess.ts @@ -0,0 +1,6 @@ +export const OPERATOR_QUEUE_ROUTE_SCOPES = [ + "campaigns:campaign:queue", + "campaigns:campaign:retry", + "campaigns:campaign:reconcile", + "campaigns:campaign:control" +]; diff --git a/webui/src/module.ts b/webui/src/module.ts index 935cb30..a2b451c 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,5 +1,5 @@ import { createElement, lazy, useCallback } from "react"; -import { useParams } from "react-router-dom"; +import { Navigate, useParams } from "react-router-dom"; import { ResourceAccessBoundary, type ApiSettings, @@ -9,17 +9,18 @@ import { } from "@govoplan/core-webui"; import { getCampaign } from "./api/campaigns"; import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget"; +import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess"; import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/campaign-workspace.css"; -const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage")); +const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage")); 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"]; -const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control"]; +// Preserve the former /operator route identity for saved View projections. +const operatorQueueSurface = "campaigns.route.operator"; const translations = { en: generatedTranslations.en, de: generatedTranslations.de @@ -87,21 +88,14 @@ export const campaignModule: PlatformWebModule = { ], navItems: [ { to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignRead, order: 20 }, - { - to: "/operator", - label: "i18n:govoplan-campaign.operator_queue.ddf23260", - iconName: "radio-tower", - anyOf: operatorScopes, - allOf: campaignRead, - order: 30 - }, { to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 }, { to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }], routes: [ - { 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: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "campaigns", 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: "/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: { diff --git a/webui/tests/operator-queue-ui-structure.test.mjs b/webui/tests/operator-queue-ui-structure.test.mjs index 72f9228..f40e8ec 100644 --- a/webui/tests/operator-queue-ui-structure.test.mjs +++ b/webui/tests/operator-queue-ui-structure.test.mjs @@ -5,8 +5,10 @@ import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8"); +const campaignModulePage = readFileSync(resolve(here, "../src/features/campaigns/CampaignModulePage.tsx"), "utf8"); +const moduleSource = readFileSync(resolve(here, "../src/module.ts"), "utf8"); const translations = readFileSync(resolve(here, "../src/i18n/generatedTranslations.ts"), "utf8"); -const backendRouter = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/router.py"), "utf8"); +const jobQueries = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/services/job_queries.py"), "utf8"); const responseSecurity = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/response_security.py"), "utf8"); assert.match(source, /