diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index e3f709f..081ee39 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -26,6 +26,7 @@ from govoplan_core.core.people import ( from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY +from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata @@ -188,6 +189,15 @@ manifest = ModuleManifest( ), ), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), + view_surfaces=( + ViewSurface( + id="scheduling.widget.open-requests", + module_id=MODULE_ID, + kind="section", + label="Scheduling requests widget", + order=45, + ), + ), ), route_factory=_scheduling_router, tenant_summary_providers=(_tenant_summary,), diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 7791f76..d917351 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -297,6 +297,7 @@ def api_search_scheduling_people( @router.get("/requests", response_model=SchedulingRequestListResponse) def api_list_scheduling_requests( status_filter: str | None = Query(default=None, alias="status"), + limit: int = 100, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> SchedulingRequestListResponse: @@ -307,21 +308,11 @@ def api_list_scheduling_requests( actor_ids=_principal_actor_ids(principal), can_manage=_can_manage_scheduling(principal), status=status_filter, + limit=limit, ) - actor_ids = _principal_actor_ids(principal) - for request in requests: - refresh_participant_response_state( - session, - request=request, - actor_ids=actor_ids, - ) - response = SchedulingRequestListResponse( + return SchedulingRequestListResponse( requests=[_request_response(request, principal=principal) for request in requests] ) - # Poll responses are authoritative, while Scheduling keeps a durable - # participant projection used by its task-oriented list. - session.commit() - return response @router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED) @@ -407,16 +398,9 @@ def api_get_scheduling_request( actor_ids=_principal_actor_ids(principal), can_manage=_can_manage_scheduling(principal), ) - refresh_participant_response_state( - session, - request=request, - actor_ids=_principal_actor_ids(principal), - ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc - response = _request_response(request, principal=principal) - session.commit() - return response + return _request_response(request, principal=principal) @router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse) diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 1b8f889..195de17 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -7,7 +7,8 @@ from datetime import datetime, timedelta, timezone from functools import lru_cache from typing import Any, cast -from sqlalchemy.orm import Session, object_session +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session, object_session, selectinload from govoplan_core.core.calendar import ( CalendarCapabilityError, @@ -1303,11 +1304,34 @@ def create_scheduling_request( return request, {} -def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | None = None) -> list[SchedulingRequest]: - query = session.query(SchedulingRequest).filter(SchedulingRequest.tenant_id == tenant_id, SchedulingRequest.deleted_at.is_(None)) +def list_scheduling_requests( + session: Session, + *, + tenant_id: str, + status: str | None = None, + limit: int = 100, +) -> list[SchedulingRequest]: + query = ( + session.query(SchedulingRequest) + .options( + selectinload(SchedulingRequest.slots), + selectinload(SchedulingRequest.participants), + ) + .filter( + SchedulingRequest.tenant_id == tenant_id, + SchedulingRequest.deleted_at.is_(None), + ) + ) if status: query = query.filter(SchedulingRequest.status == status) - return query.order_by(SchedulingRequest.created_at.desc(), SchedulingRequest.title.asc()).all() + return ( + query.order_by( + SchedulingRequest.created_at.desc(), + SchedulingRequest.title.asc(), + ) + .limit(max(1, min(limit, 200))) + .all() + ) def _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]: @@ -1369,12 +1393,53 @@ def list_visible_scheduling_requests( actor_ids: tuple[str, ...], can_manage: bool = False, status: str | None = None, + limit: int = 100, ) -> list[SchedulingRequest]: - return [ - request - for request in list_scheduling_requests(session, tenant_id=tenant_id, status=status) - if scheduling_request_is_visible(request, actor_ids=actor_ids, can_manage=can_manage) - ] + query = ( + session.query(SchedulingRequest) + .options( + selectinload(SchedulingRequest.slots), + selectinload(SchedulingRequest.participants), + ) + .filter( + SchedulingRequest.tenant_id == tenant_id, + SchedulingRequest.deleted_at.is_(None), + ) + ) + if status: + query = query.filter(SchedulingRequest.status == status) + if not can_manage: + ids = _actor_ids(actor_ids) + email_ids = tuple(value.casefold() for value in ids if "@" in value) + participant_exists = ( + session.query(SchedulingParticipant.id) + .filter( + SchedulingParticipant.tenant_id == tenant_id, + SchedulingParticipant.request_id == SchedulingRequest.id, + SchedulingParticipant.deleted_at.is_(None), + or_( + SchedulingParticipant.respondent_id.in_(ids or ("",)), + func.lower(SchedulingParticipant.email).in_( + email_ids or ("",) + ), + ), + ) + .exists() + ) + query = query.filter( + or_( + SchedulingRequest.organizer_user_id.in_(ids or ("",)), + participant_exists, + ) + ) + return ( + query.order_by( + SchedulingRequest.created_at.desc(), + SchedulingRequest.title.asc(), + ) + .limit(max(1, min(limit, 200))) + .all() + ) def get_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: diff --git a/tests/test_service.py b/tests/test_service.py index c231f1f..dbc5f47 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -93,6 +93,23 @@ from govoplan_scheduling.backend.runtime import configure_runtime class SchedulingServiceTests(unittest.TestCase): def setUp(self) -> None: + fixed_now = datetime(2026, 7, 19, 12, tzinfo=timezone.utc) + self.now_patches = ( + patch( + "govoplan_scheduling.backend.service._now", + return_value=fixed_now, + ), + patch( + "govoplan_poll.backend.service._now", + return_value=fixed_now, + ), + patch( + "govoplan_poll.backend.participation_service._now", + return_value=fixed_now, + ), + ) + for now_patch in self.now_patches: + now_patch.start() registry = PlatformRegistry() registry.register(get_poll_manifest()) registry.register(get_calendar_manifest()) @@ -125,6 +142,8 @@ class SchedulingServiceTests(unittest.TestCase): self.session: Session = self.Session() def tearDown(self) -> None: + for now_patch in reversed(self.now_patches): + now_patch.stop() self.session.close() Base.metadata.drop_all( self.engine, diff --git a/webui/package.json b/webui/package.json index 6a42646..ac97637 100644 --- a/webui/package.json +++ b/webui/package.json @@ -22,7 +22,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1", + "react-router-dom": ">=7.18.2 <8", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.2", "vite": "^6.0.6" diff --git a/webui/src/features/scheduling/SchedulingRequestsWidget.tsx b/webui/src/features/scheduling/SchedulingRequestsWidget.tsx new file mode 100644 index 0000000..eef434e --- /dev/null +++ b/webui/src/features/scheduling/SchedulingRequestsWidget.tsx @@ -0,0 +1,120 @@ +import { useCallback } from "react"; +import { CalendarClock } from "lucide-react"; +import { Link } from "react-router-dom"; +import { + DashboardWidgetList, + DismissibleAlert, + LoadingFrame, + StatusBadge, + useDashboardWidgetData, + type ApiSettings, + type DashboardWidgetConfiguration +} from "@govoplan/core-webui"; +import { + listSchedulingRequests, + type SchedulingRequest +} from "../../api/scheduling"; + +export default function SchedulingRequestsWidget({ + settings, + refreshKey, + configuration +}: { + settings: ApiSettings; + refreshKey: number; + configuration: DashboardWidgetConfiguration; +}) { + const maxItems = numberSetting(configuration.maxItems, 5, 1, 12); + const includeDrafts = configuration.includeDrafts === true; + const load = useCallback(async () => { + const response = await listSchedulingRequests(settings); + return response.requests + .filter( + (request) => + request.status === "collecting" + || (includeDrafts && request.status === "draft") + ) + .sort(compareRequests) + .slice(0, maxItems); + }, [includeDrafts, maxItems, settings]); + const { data: requests, loading, error } = useDashboardWidgetData( + load, + refreshKey + ); + + return ( + + {error && ( + + {error} + + )} + ({ + id: request.id, + title: request.title, + detail: responseLabel(request), + meta: deadlineLabel(request), + leading: + ); +} + +function compareRequests( + left: SchedulingRequest, + right: SchedulingRequest +): number { + if (left.status !== right.status) { + return left.status === "collecting" ? -1 : 1; + } + const leftDeadline = left.deadline_at + ? new Date(left.deadline_at).getTime() + : Number.POSITIVE_INFINITY; + const rightDeadline = right.deadline_at + ? new Date(right.deadline_at).getTime() + : Number.POSITIVE_INFINITY; + return ( + leftDeadline - rightDeadline + || new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime() + ); +} + +function responseLabel(request: SchedulingRequest): string { + const responded = request.participant_aggregate.status_counts.responded ?? 0; + return `${responded} of ${request.participant_aggregate.total} responded`; +} + +function deadlineLabel(request: SchedulingRequest): string { + if (!request.deadline_at) return `${request.slots.length} options`; + return `Due ${new Intl.DateTimeFormat(undefined, { + day: "2-digit", + month: "short" + }).format(new Date(request.deadline_at))}`; +} + +function numberSetting( + value: unknown, + fallback: number, + minimum: number, + maximum: number +): number { + const numeric = typeof value === "number" ? value : Number(value); + return Number.isFinite(numeric) + ? Math.max(minimum, Math.min(maximum, Math.floor(numeric))) + : fallback; +} diff --git a/webui/src/module.ts b/webui/src/module.ts index d8f2c5b..db7dc8c 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,5 +1,9 @@ import { createElement, lazy } from "react"; -import type { PlatformWebModule } from "@govoplan/core-webui"; +import type { + DashboardWidgetsUiCapability, + PlatformWebModule +} from "@govoplan/core-webui"; +import SchedulingRequestsWidget from "./features/scheduling/SchedulingRequestsWidget"; import { generatedTranslations } from "./i18n/generatedTranslations"; import "./styles/scheduling.css"; @@ -7,6 +11,50 @@ const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage") const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage")); const scheduleRead = ["scheduling:schedule:read"]; +const schedulingDashboardWidgets: DashboardWidgetsUiCapability = { + widgets: [ + { + id: "scheduling.open-requests", + surfaceId: "scheduling.widget.open-requests", + title: "Scheduling requests", + description: "Open scheduling polls and their response progress.", + moduleId: "scheduling", + category: "Planning", + order: 45, + defaultVisible: false, + defaultSize: "medium", + supportedSizes: ["medium", "wide"], + anyOf: scheduleRead, + refreshIntervalMs: 60_000, + defaultConfiguration: { + maxItems: 5, + includeDrafts: false + }, + configurationFields: [ + { + id: "maxItems", + label: "Maximum requests", + kind: "number", + min: 1, + max: 12, + step: 1, + required: true + }, + { + id: "includeDrafts", + label: "Include drafts", + kind: "boolean" + } + ], + render: ({ settings, refreshKey, configuration }) => + createElement(SchedulingRequestsWidget, { + settings, + refreshKey, + configuration + }) + } + ] +}; export const schedulingModule: PlatformWebModule = { id: "scheduling", @@ -15,6 +63,15 @@ export const schedulingModule: PlatformWebModule = { dependencies: ["poll"], optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"], translations: generatedTranslations, + viewSurfaces: [ + { + id: "scheduling.widget.open-requests", + moduleId: "scheduling", + kind: "section", + label: "Scheduling requests widget", + order: 45 + } + ], navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }], routes: [ { path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) } @@ -25,7 +82,10 @@ export const schedulingModule: PlatformWebModule = { order: 10, render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth }) } - ] + ], + uiCapabilities: { + "dashboard.widgets": schedulingDashboardWidgets + } }; export default schedulingModule;