Compare commits
4 Commits
b328df67a3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b029c8edf | |||
| b9f557e95b | |||
| 60b01e5a16 | |||
| ffb30a7aa3 |
@@ -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,),
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ from govoplan_scheduling.backend.service import (
|
|||||||
list_visible_scheduling_requests,
|
list_visible_scheduling_requests,
|
||||||
issue_scheduling_participant_invitation,
|
issue_scheduling_participant_invitation,
|
||||||
open_scheduling_request,
|
open_scheduling_request,
|
||||||
refresh_participant_response_state,
|
|
||||||
require_visible_scheduling_results,
|
require_visible_scheduling_results,
|
||||||
revoke_scheduling_participant_invitation,
|
revoke_scheduling_participant_invitation,
|
||||||
scheduling_notification_response,
|
scheduling_notification_response,
|
||||||
@@ -297,6 +296,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 +307,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 +397,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)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
283
tests/test_reconciliation_plans.py
Normal file
283
tests/test_reconciliation_plans.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_scheduling.backend.db.models import (
|
||||||
|
SchedulingCandidateSlot,
|
||||||
|
SchedulingParticipant,
|
||||||
|
SchedulingRequest,
|
||||||
|
)
|
||||||
|
from govoplan_scheduling.backend.schemas import (
|
||||||
|
SchedulingCandidateSlotReconcileInput,
|
||||||
|
SchedulingParticipantReconcileInput,
|
||||||
|
SchedulingRequestUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_scheduling.backend.service import (
|
||||||
|
SchedulingError,
|
||||||
|
_plan_scheduling_participant_reconciliation,
|
||||||
|
_plan_scheduling_request_update,
|
||||||
|
_plan_scheduling_slot_reconciliation,
|
||||||
|
scheduling_participant_revision,
|
||||||
|
scheduling_slot_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _request() -> SchedulingRequest:
|
||||||
|
return SchedulingRequest(
|
||||||
|
id="request-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
title="Steering group",
|
||||||
|
timezone="Europe/Berlin",
|
||||||
|
status="collecting",
|
||||||
|
poll_id="poll-1",
|
||||||
|
allow_external_participants=True,
|
||||||
|
allow_participant_updates=True,
|
||||||
|
result_visibility="after_close",
|
||||||
|
participant_visibility="aggregates_only",
|
||||||
|
notify_on_answers=True,
|
||||||
|
single_choice=False,
|
||||||
|
max_participants_per_option=None,
|
||||||
|
allow_maybe=True,
|
||||||
|
allow_comments=False,
|
||||||
|
participant_email_required=False,
|
||||||
|
anonymous_password_protection_enabled=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _slot(
|
||||||
|
request: SchedulingRequest,
|
||||||
|
*,
|
||||||
|
slot_id: str,
|
||||||
|
position: int,
|
||||||
|
start_offset: int,
|
||||||
|
) -> SchedulingCandidateSlot:
|
||||||
|
slot = SchedulingCandidateSlot(
|
||||||
|
id=slot_id,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
request=request,
|
||||||
|
poll_option_id=f"option-{slot_id}",
|
||||||
|
label=f"Slot {position + 1}",
|
||||||
|
start_at=NOW + timedelta(hours=start_offset),
|
||||||
|
end_at=NOW + timedelta(hours=start_offset + 1),
|
||||||
|
timezone=request.timezone,
|
||||||
|
position=position,
|
||||||
|
freebusy_conflicts=[],
|
||||||
|
metadata_={},
|
||||||
|
)
|
||||||
|
return slot
|
||||||
|
|
||||||
|
|
||||||
|
def _slot_input(
|
||||||
|
slot: SchedulingCandidateSlot,
|
||||||
|
*,
|
||||||
|
label: str | None = None,
|
||||||
|
) -> SchedulingCandidateSlotReconcileInput:
|
||||||
|
return SchedulingCandidateSlotReconcileInput(
|
||||||
|
id=slot.id,
|
||||||
|
revision=scheduling_slot_revision(slot),
|
||||||
|
label=label or slot.label,
|
||||||
|
start_at=slot.start_at,
|
||||||
|
end_at=slot.end_at,
|
||||||
|
timezone=slot.timezone,
|
||||||
|
location=slot.location,
|
||||||
|
metadata=slot.metadata_ or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _participant(
|
||||||
|
request: SchedulingRequest,
|
||||||
|
*,
|
||||||
|
participant_id: str,
|
||||||
|
respondent_id: str,
|
||||||
|
email: str,
|
||||||
|
required: bool,
|
||||||
|
status: str = "invited",
|
||||||
|
invitation_id: str | None = None,
|
||||||
|
) -> SchedulingParticipant:
|
||||||
|
return SchedulingParticipant(
|
||||||
|
id=participant_id,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
request=request,
|
||||||
|
respondent_id=respondent_id,
|
||||||
|
display_name=participant_id.title(),
|
||||||
|
email=email,
|
||||||
|
participant_type="internal",
|
||||||
|
required=required,
|
||||||
|
status=status,
|
||||||
|
poll_invitation_id=invitation_id,
|
||||||
|
participation_gateway="scheduling" if invitation_id else None,
|
||||||
|
metadata_={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _participant_input(
|
||||||
|
participant: SchedulingParticipant,
|
||||||
|
**changes: object,
|
||||||
|
) -> SchedulingParticipantReconcileInput:
|
||||||
|
values = {
|
||||||
|
"id": participant.id,
|
||||||
|
"revision": scheduling_participant_revision(participant),
|
||||||
|
"respondent_id": participant.respondent_id,
|
||||||
|
"display_name": participant.display_name,
|
||||||
|
"email": participant.email,
|
||||||
|
"participant_type": participant.participant_type,
|
||||||
|
"required": participant.required,
|
||||||
|
"metadata": participant.metadata_ or {},
|
||||||
|
}
|
||||||
|
values.update(changes)
|
||||||
|
return SchedulingParticipantReconcileInput.model_validate(values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_plan_is_inspectable_and_does_not_mutate_models() -> None:
|
||||||
|
request = _request()
|
||||||
|
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
|
||||||
|
second = _slot(request, slot_id="slot-2", position=1, start_offset=3)
|
||||||
|
supplied = [
|
||||||
|
_slot_input(first, label="Updated first slot"),
|
||||||
|
SchedulingCandidateSlotReconcileInput(
|
||||||
|
label="New slot",
|
||||||
|
start_at=NOW + timedelta(hours=5),
|
||||||
|
end_at=NOW + timedelta(hours=6),
|
||||||
|
timezone=request.timezone,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
plan = _plan_scheduling_slot_reconciliation(
|
||||||
|
request=request,
|
||||||
|
supplied_slots=supplied,
|
||||||
|
option_mutation_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [update.slot_id for update in plan.updates] == ["slot-1"]
|
||||||
|
assert plan.updates[0].changes.label == "Updated first slot"
|
||||||
|
assert len(plan.additions) == 1
|
||||||
|
assert plan.removals == ("slot-2",)
|
||||||
|
assert plan.changed is True
|
||||||
|
assert first.label == "Slot 1"
|
||||||
|
assert second.deleted_at is None
|
||||||
|
assert len(request.slots) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_slot_replay_produces_a_noop_plan() -> None:
|
||||||
|
request = _request()
|
||||||
|
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
|
||||||
|
|
||||||
|
plan = _plan_scheduling_slot_reconciliation(
|
||||||
|
request=request,
|
||||||
|
supplied_slots=[_slot_input(first)],
|
||||||
|
option_mutation_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.changed is False
|
||||||
|
assert plan.updates == ()
|
||||||
|
assert plan.additions == ()
|
||||||
|
assert plan.removals == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_slot_plan_rejects_removal_of_a_tentative_calendar_hold() -> None:
|
||||||
|
request = _request()
|
||||||
|
held = _slot(request, slot_id="slot-held", position=0, start_offset=1)
|
||||||
|
held.tentative_hold_event_id = "event-1"
|
||||||
|
|
||||||
|
with pytest.raises(SchedulingError, match="tentative calendar hold"):
|
||||||
|
_plan_scheduling_slot_reconciliation(
|
||||||
|
request=request,
|
||||||
|
supplied_slots=[],
|
||||||
|
option_mutation_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_participant_plan_distinguishes_updates_additions_and_retirements() -> None:
|
||||||
|
request = _request()
|
||||||
|
alice = _participant(
|
||||||
|
request,
|
||||||
|
participant_id="alice",
|
||||||
|
respondent_id="user-alice",
|
||||||
|
email="alice@example.test",
|
||||||
|
required=True,
|
||||||
|
invitation_id="invitation-alice",
|
||||||
|
)
|
||||||
|
bob = _participant(
|
||||||
|
request,
|
||||||
|
participant_id="bob",
|
||||||
|
respondent_id="user-bob",
|
||||||
|
email="bob@example.test",
|
||||||
|
required=False,
|
||||||
|
status="responded",
|
||||||
|
)
|
||||||
|
supplied = [
|
||||||
|
_participant_input(alice, email="alice.new@example.test", required=False),
|
||||||
|
SchedulingParticipantReconcileInput(
|
||||||
|
respondent_id="user-charlie",
|
||||||
|
display_name="Charlie",
|
||||||
|
email="charlie@example.test",
|
||||||
|
participant_type="internal",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
plan = _plan_scheduling_participant_reconciliation(
|
||||||
|
request=request,
|
||||||
|
supplied_participants=supplied,
|
||||||
|
participation_available=True,
|
||||||
|
retirement_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(plan.updates) == 1
|
||||||
|
assert plan.updates[0].participant_id == "alice"
|
||||||
|
assert plan.updates[0].revoke_invitation is True
|
||||||
|
assert set(plan.updates[0].changed_fields) == {"email", "required"}
|
||||||
|
assert plan.creations[0].replaces_participant_id is None
|
||||||
|
assert plan.creations[0].supplied.required is True
|
||||||
|
assert plan.retirements == ("bob",)
|
||||||
|
assert alice.email == "alice@example.test"
|
||||||
|
assert alice.required is True
|
||||||
|
assert bob.status == "responded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_plan_records_invitation_expiry_work_without_tokens() -> None:
|
||||||
|
request = _request()
|
||||||
|
participant = _participant(
|
||||||
|
request,
|
||||||
|
participant_id="alice",
|
||||||
|
respondent_id="user-alice",
|
||||||
|
email="alice@example.test",
|
||||||
|
required=True,
|
||||||
|
invitation_id="invitation-alice",
|
||||||
|
)
|
||||||
|
deadline = NOW + timedelta(days=2)
|
||||||
|
|
||||||
|
plan = _plan_scheduling_request_update(
|
||||||
|
request=request,
|
||||||
|
payload=SchedulingRequestUpdateRequest(deadline_at=deadline),
|
||||||
|
participation_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert plan.deadline_changed is True
|
||||||
|
assert plan.retained_invitation_ids == ((participant.id, "invitation-alice"),)
|
||||||
|
assert "token" not in repr(plan).casefold()
|
||||||
|
assert request.deadline_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_plan_rejects_policy_change_after_link_issuance() -> None:
|
||||||
|
request = _request()
|
||||||
|
_participant(
|
||||||
|
request,
|
||||||
|
participant_id="alice",
|
||||||
|
respondent_id="user-alice",
|
||||||
|
email="alice@example.test",
|
||||||
|
required=True,
|
||||||
|
invitation_id="invitation-alice",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SchedulingError, match="cannot change"):
|
||||||
|
_plan_scheduling_request_update(
|
||||||
|
request=request,
|
||||||
|
payload=SchedulingRequestUpdateRequest(single_choice=True),
|
||||||
|
participation_available=True,
|
||||||
|
)
|
||||||
@@ -1708,10 +1708,20 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
|||||||
notice_until,
|
notice_until,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(
|
with (
|
||||||
scheduling_service,
|
patch.object(
|
||||||
"_now",
|
scheduling_service,
|
||||||
return_value=cancelled_at + timedelta(days=1),
|
"_now",
|
||||||
|
return_value=cancelled_at + timedelta(days=1),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_poll.backend.service._now",
|
||||||
|
return_value=cancelled_at + timedelta(days=1),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_poll.backend.participation_service._now",
|
||||||
|
return_value=cancelled_at + timedelta(days=1),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
notice = get_public_scheduling_participation(
|
notice = get_public_scheduling_participation(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ assert.match(api, /method: "PATCH"/);
|
|||||||
assert.match(api, /\/api\/v1\/scheduling\/people\?/);
|
assert.match(api, /\/api\/v1\/scheduling\/people\?/);
|
||||||
assert.doesNotMatch(api, /address-lookup/);
|
assert.doesNotMatch(api, /address-lookup/);
|
||||||
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
|
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
|
||||||
assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/);
|
assert.match(page, /participants: participants\.map\(participantPayload\)/);
|
||||||
|
assert.doesNotMatch(page, /create_participant_invitations/);
|
||||||
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
|
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
|
||||||
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
|
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
|
||||||
assert.match(api, /issueSchedulingParticipantInvitation\([\s\S]*json\(\{ action, participant_revision: participantRevision \}\)/);
|
assert.match(api, /issueSchedulingParticipantInvitation\([\s\S]*json\(\{ action, participant_revision: participantRevision \}\)/);
|
||||||
|
|||||||
@@ -643,8 +643,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
location: location.trim() || null,
|
location: location.trim() || null,
|
||||||
metadata: slot.metadata ?? {}
|
metadata: slot.metadata ?? {}
|
||||||
})),
|
})),
|
||||||
participants: participants.map(participantPayload),
|
participants: participants.map(participantPayload)
|
||||||
create_participant_invitations: true
|
|
||||||
});
|
});
|
||||||
setRequests((items) => [request, ...items]);
|
setRequests((items) => [request, ...items]);
|
||||||
} else {
|
} else {
|
||||||
@@ -664,8 +663,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
|||||||
location: location.trim() || null,
|
location: location.trim() || null,
|
||||||
metadata: slot.metadata ?? {}
|
metadata: slot.metadata ?? {}
|
||||||
})),
|
})),
|
||||||
participants: participants.map(participantPayload),
|
participants: participants.map(participantPayload)
|
||||||
create_participant_invitations: true
|
|
||||||
});
|
});
|
||||||
replaceRequest(request);
|
replaceRequest(request);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user