perf: batch scheduling reconciliation and add widget
This commit is contained in:
@@ -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,),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
120
webui/src/features/scheduling/SchedulingRequestsWidget.tsx
Normal file
120
webui/src/features/scheduling/SchedulingRequestsWidget.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user