perf: batch scheduling reconciliation and add widget

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 60b01e5a16
commit b9f557e95b
7 changed files with 290 additions and 32 deletions

View File

@@ -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,),

View File

@@ -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)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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"

View File

@@ -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 (
<LoadingFrame loading={loading} label="Loading scheduling requests">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No scheduling requests are awaiting responses."
items={(requests ?? []).map((request) => ({
id: request.id,
title: request.title,
detail: responseLabel(request),
meta: deadlineLabel(request),
leading: <CalendarClock size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={request.status}
label={request.status === "collecting" ? "Open" : "Draft"}
/>
),
to: "/scheduling"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/scheduling">
Open scheduling
</Link>
</div>
</LoadingFrame>
);
}
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;
}

View File

@@ -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;