2 Commits

7 changed files with 299 additions and 32 deletions

View File

@@ -8,6 +8,7 @@ from govoplan_core.core.module_guards import drop_table_retirement_provider, per
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
@@ -25,6 +26,7 @@ from govoplan_core.core.people import (
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY 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.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
@@ -171,6 +173,14 @@ manifest = ModuleManifest(
frontend=FrontendModule( frontend=FrontendModule(
module_id=MODULE_ID, module_id=MODULE_ID,
package_name="@govoplan/scheduling-webui", package_name="@govoplan/scheduling-webui",
routes=(
FrontendRoute(
path="/scheduling",
component="SchedulingPage",
required_any=(READ_SCOPE,),
order=56,
),
),
public_routes=( public_routes=(
PublicFrontendRoute( PublicFrontendRoute(
path="/scheduling/public/:requestId/:token", path="/scheduling/public/:requestId/:token",
@@ -179,6 +189,15 @@ manifest = ModuleManifest(
), ),
), ),
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), 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, route_factory=_scheduling_router,
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),

View File

@@ -297,6 +297,7 @@ def api_search_scheduling_people(
@router.get("/requests", response_model=SchedulingRequestListResponse) @router.get("/requests", response_model=SchedulingRequestListResponse)
def api_list_scheduling_requests( def api_list_scheduling_requests(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),
limit: int = 100,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestListResponse: ) -> SchedulingRequestListResponse:
@@ -307,21 +308,11 @@ def api_list_scheduling_requests(
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal), can_manage=_can_manage_scheduling(principal),
status=status_filter, status=status_filter,
limit=limit,
) )
actor_ids = _principal_actor_ids(principal) return SchedulingRequestListResponse(
for request in requests:
refresh_participant_response_state(
session,
request=request,
actor_ids=actor_ids,
)
response = SchedulingRequestListResponse(
requests=[_request_response(request, principal=principal) for request in requests] 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) @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), actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal), can_manage=_can_manage_scheduling(principal),
) )
refresh_participant_response_state(
session,
request=request,
actor_ids=_principal_actor_ids(principal),
)
except SchedulingError as exc: except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal) return _request_response(request, principal=principal)
session.commit()
return response
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse) @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 functools import lru_cache
from typing import Any, cast 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 ( from govoplan_core.core.calendar import (
CalendarCapabilityError, CalendarCapabilityError,
@@ -1303,11 +1304,34 @@ def create_scheduling_request(
return request, {} return request, {}
def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | None = None) -> list[SchedulingRequest]: def list_scheduling_requests(
query = session.query(SchedulingRequest).filter(SchedulingRequest.tenant_id == tenant_id, SchedulingRequest.deleted_at.is_(None)) 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: if status:
query = query.filter(SchedulingRequest.status == 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, ...]: def _actor_ids(values: tuple[str, ...]) -> tuple[str, ...]:
@@ -1369,12 +1393,53 @@ def list_visible_scheduling_requests(
actor_ids: tuple[str, ...], actor_ids: tuple[str, ...],
can_manage: bool = False, can_manage: bool = False,
status: str | None = None, status: str | None = None,
limit: int = 100,
) -> list[SchedulingRequest]: ) -> list[SchedulingRequest]:
return [ query = (
request session.query(SchedulingRequest)
for request in list_scheduling_requests(session, tenant_id=tenant_id, status=status) .options(
if scheduling_request_is_visible(request, actor_ids=actor_ids, can_manage=can_manage) 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: 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): class SchedulingServiceTests(unittest.TestCase):
def setUp(self) -> None: 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 = PlatformRegistry()
registry.register(get_poll_manifest()) registry.register(get_poll_manifest())
registry.register(get_calendar_manifest()) registry.register(get_calendar_manifest())
@@ -125,6 +142,8 @@ class SchedulingServiceTests(unittest.TestCase):
self.session: Session = self.Session() self.session: Session = self.Session()
def tearDown(self) -> None: def tearDown(self) -> None:
for now_patch in reversed(self.now_patches):
now_patch.stop()
self.session.close() self.session.close()
Base.metadata.drop_all( Base.metadata.drop_all(
self.engine, self.engine,

View File

@@ -22,7 +22,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "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 { 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 { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/scheduling.css"; import "./styles/scheduling.css";
@@ -7,6 +11,50 @@ const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage")
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage")); const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
const scheduleRead = ["scheduling:schedule:read"]; 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 = { export const schedulingModule: PlatformWebModule = {
id: "scheduling", id: "scheduling",
@@ -15,6 +63,15 @@ export const schedulingModule: PlatformWebModule = {
dependencies: ["poll"], dependencies: ["poll"],
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"], optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations, 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 }], navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
routes: [ routes: [
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) } { path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
@@ -25,7 +82,10 @@ export const schedulingModule: PlatformWebModule = {
order: 10, order: 10,
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth }) render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
} }
] ],
uiCapabilities: {
"dashboard.widgets": schedulingDashboardWidgets
}
}; };
export default schedulingModule; export default schedulingModule;