From 79cfaa951a15a57ba5f131deb954910d8d68c7ec Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 07:44:15 +0200 Subject: [PATCH] feat: reconcile tentative calendar holds --- src/govoplan_scheduling/backend/manifest.py | 5 +- src/govoplan_scheduling/backend/router.py | 8 +- src/govoplan_scheduling/backend/service.py | 309 ++++++++++++++++-- tests/test_service.py | 182 ++++++++++- .../test-scheduling-page-structure.mjs | 5 +- .../features/scheduling/SchedulingPage.tsx | 38 ++- webui/src/i18n/generatedTranslations.ts | 4 + 7 files changed, 522 insertions(+), 29 deletions(-) diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 38a8222..0bdd3f8 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -147,6 +147,9 @@ DOCUMENTATION = ( body=( "Calendar coordination remains optional. It is available only when Calendar contributes its picker capability and the actor can read calendars and availability and write events. " "A disabled Calendar control therefore names the missing integration or authority instead of silently accepting a configuration that cannot run. " + "At decision time, a selected tentative hold is promoted in place while every unused hold is submitted to Calendar for durable release. Cancellation likewise waits until Calendar accepts every release. " + "Partial or unavailable Calendar effects keep the Scheduling lifecycle incomplete and expose retry-required cleanup state; an exact retry reuses the recorded event and operation identities instead of duplicating effects. " + "Administrators recover synchronized failures through Calendar's outbound-change reconciliation and then repeat the Scheduling action. Scheduling stores only operation identifiers, last-known state, and pending slot references; Calendar remains authoritative for event and outbox evidence. " "Administrators should enable the Calendar module and grant the bounded calendar, availability, and event permissions needed by the organizer; Scheduling never imports Calendar internals." ), layer="configured", @@ -290,7 +293,7 @@ manifest = ModuleManifest( ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), - ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.9", version_max_exclusive="0.2.0", optional=True), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 018eac9..a8c506d 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -739,7 +739,13 @@ def api_cancel_scheduling_request( ) -> SchedulingStatusResponse: _require_request_editor(session, principal=principal, request_id=request_id) try: - request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id) + request = cancel_scheduling_request( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + user_id=principal.account_id, + allow_calendar_cleanup=has_scope(principal, CALENDAR_EVENT_WRITE_SCOPE), + ) except SchedulingError as exc: raise _scheduling_http_error(exc) from exc response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 4423fe4..7d4cd46 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -623,7 +623,6 @@ def create_tentative_calendar_holds( slot.metadata_ = { **(slot.metadata_ or {}), "calendar_hold": { - "event_id": event.id, # This is a creation-time snapshot. Calendar remains the # authority for later outbox delivery transitions. "last_known_external_state": event.external_state, @@ -635,6 +634,104 @@ def create_tentative_calendar_holds( return request, created_event_ids, warnings +def _calendar_cleanup_metadata( + slot: SchedulingCandidateSlot, + *, + state: str, + operation_id: str | None = None, +) -> None: + slot.metadata_ = { + **(slot.metadata_ or {}), + "calendar_hold": { + "last_known_external_state": state, + "outbox_operation_id": operation_id, + }, + } + + +def _release_tentative_calendar_holds( + session: Session, + *, + request: SchedulingRequest, + user_id: str | None, + include_selected: bool, +) -> list[str]: + candidates = [ + slot + for slot in _active_slots(request) + if slot.tentative_hold_event_id + and (include_selected or slot.id != request.selected_slot_id) + ] + if not candidates: + return [] + try: + provider = _calendar_provider() + except SchedulingError as exc: + for slot in candidates: + _calendar_cleanup_metadata(slot, state="retry_required") + return [str(exc)] + warnings: list[str] = [] + for slot in candidates: + event_id = slot.tentative_hold_event_id + if not event_id: + continue + try: + released = provider.release_event( + session, + tenant_id=request.tenant_id, + user_id=user_id, + event_id=event_id, + ) + except CalendarCapabilityError as exc: + _calendar_cleanup_metadata(slot, state="retry_required") + warnings.append(f"Slot {slot.id}: {exc}") + continue + if not released.accepted: + _calendar_cleanup_metadata( + slot, + state=released.external_state or "retry_required", + operation_id=released.outbox_operation_id, + ) + warnings.append(f"Slot {slot.id}: Calendar did not accept the release operation") + continue + _calendar_cleanup_metadata( + slot, + state=released.external_state, + operation_id=released.outbox_operation_id, + ) + slot.tentative_hold_event_id = None + session.flush() + return warnings + + +def _record_calendar_cleanup_state( + request: SchedulingRequest, + *, + warnings: list[str], + target_state: str | None = None, +) -> None: + current_cleanup = (request.metadata_ or {}).get("calendar_cleanup") + inherited_target = ( + current_cleanup.get("target_state") + if isinstance(current_cleanup, dict) + else None + ) + pending_slot_ids = [ + slot.id + for slot in _active_slots(request) + if slot.tentative_hold_event_id + ] + request.metadata_ = { + **(request.metadata_ or {}), + "calendar_cleanup": { + "status": "retry_required" if warnings or pending_slot_ids else "accepted", + "target_state": target_state or inherited_target, + "pending_slot_ids": pending_slot_ids, + "warnings": warnings[:20], + }, + } + + def create_final_calendar_event( session: Session, *, @@ -646,53 +743,120 @@ def create_final_calendar_event( session, tenant_id=tenant_id, request_id=request_id, + lock_slots=True, ) _require_calendar_enabled(request) if not request.create_calendar_event_on_decision: raise SchedulingError("Final calendar event creation is not enabled for this scheduling request") - if request.status == "handed_off" and request.selected_slot_id and request.calendar_event_id: + if ( + request.status == "handed_off" + and request.selected_slot_id + and request.calendar_event_id + and not any(slot.tentative_hold_event_id for slot in _active_slots(request)) + ): return request, request.calendar_event_id, [] - if request.status != "decided" or not request.selected_slot_id: + if request.status not in {"decided", "handed_off"} or not request.selected_slot_id: raise SchedulingError( "A final calendar event can only be created for a decided scheduling request with a selected slot" ) - if request.calendar_event_id: - return request, request.calendar_event_id, [] slot = _selected_slot(request, slot_id=request.selected_slot_id) provider = _calendar_provider() - try: - event = provider.create_event( + warnings: list[str] = [] + event = None + if not request.calendar_event_id: + try: + if slot.tentative_hold_event_id: + event = provider.promote_event( + session, + tenant_id=tenant_id, + user_id=user_id, + event_id=slot.tentative_hold_event_id, + request=_calendar_event_payload(request, slot, status="CONFIRMED"), + ) + _calendar_cleanup_metadata( + slot, + state=event.external_state, + operation_id=event.outbox_operation_id, + ) + slot.tentative_hold_event_id = None + else: + event = provider.create_event( + session, + tenant_id=tenant_id, + user_id=user_id, + request=_calendar_event_payload(request, slot, status="CONFIRMED"), + ) + request.calendar_event_id = event.id + except CalendarCapabilityError as exc: + _calendar_cleanup_metadata(slot, state="retry_required") + warnings.append(str(exc)) + warnings.extend( + _release_tentative_calendar_holds( session, - tenant_id=tenant_id, + request=request, user_id=user_id, - request=_calendar_event_payload(request, slot, status="CONFIRMED"), + include_selected=False, + ) + ) + _record_calendar_cleanup_state( + request, + warnings=warnings, + target_state="handed_off", + ) + if warnings or not request.calendar_event_id: + previous_handoff = (request.metadata_ or {}).get("calendar_handoff") + previous_handoff = ( + previous_handoff if isinstance(previous_handoff, dict) else {} ) - except CalendarCapabilityError as exc: request.metadata_ = { **(request.metadata_ or {}), - "calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id}, + "calendar_handoff": { + "status": "retry_required", + "last_known_external_state": ( + event.external_state + if event + else previous_handoff.get("last_known_external_state", "retry_required") + ), + "outbox_operation_id": ( + event.outbox_operation_id + if event + else previous_handoff.get("outbox_operation_id") + ), + "event_id": request.calendar_event_id, + "slot_id": slot.id, + "calendar_id": request.calendar_id, + }, } session.flush() - return request, None, [str(exc)] - request.calendar_event_id = event.id + return request, request.calendar_event_id, warnings request.handed_off_at = _now() request.status = "handed_off" + previous_handoff = (request.metadata_ or {}).get("calendar_handoff") + previous_handoff = previous_handoff if isinstance(previous_handoff, dict) else {} request.metadata_ = { **(request.metadata_ or {}), "calendar_handoff": { "status": "accepted", # Scheduling stores identifiers plus an explicitly named snapshot; # consumers must resolve the Calendar event/outbox for live state. - "last_known_external_state": event.external_state, - "outbox_operation_id": event.outbox_operation_id, - "event_id": event.id, + "last_known_external_state": ( + event.external_state + if event + else previous_handoff.get("last_known_external_state", "accepted") + ), + "outbox_operation_id": ( + event.outbox_operation_id + if event + else previous_handoff.get("outbox_operation_id") + ), + "event_id": request.calendar_event_id, "slot_id": slot.id, "calendar_id": request.calendar_id, }, } _sync_poll_workflow(session, tenant_id=tenant_id, request=request) session.flush() - return request, event.id, [] + return request, request.calendar_event_id, [] def create_scheduling_notification_jobs( @@ -3917,6 +4081,7 @@ def decide_scheduling_request( session, tenant_id=tenant_id, request_id=request_id, + lock_slots=True, ) if request.poll_id is None: raise SchedulingError("Scheduling request has no backing poll") @@ -3927,6 +4092,44 @@ def decide_scheduling_request( "A different slot cannot be selected after a scheduling decision; " "explicit rescheduling semantics are required" ) + handoff_state = (request.metadata_ or {}).get("calendar_handoff") + cleanup_state = (request.metadata_ or {}).get("calendar_cleanup") + if ( + request.status == "decided" + and isinstance(handoff_state, dict) + and handoff_state.get("status") == "retry_required" + ): + if not allow_calendar_handoff: + raise SchedulingPermissionError( + "Calendar event promotion or release requires calendar:event:write" + ) + return create_final_calendar_event( + session, + tenant_id=tenant_id, + user_id=user_id, + request_id=request.id, + )[0] + if ( + request.status == "decided" + and isinstance(cleanup_state, dict) + and cleanup_state.get("status") == "retry_required" + ): + if not allow_calendar_handoff: + raise SchedulingPermissionError( + "Calendar event release requires calendar:event:write" + ) + cleanup_warnings = _release_tentative_calendar_holds( + session, + request=request, + user_id=user_id, + include_selected=True, + ) + _record_calendar_cleanup_state( + request, + warnings=cleanup_warnings, + target_state="decided", + ) + session.flush() return request if request.status != "closed": raise SchedulingError("Only closed scheduling requests can be decided") @@ -3939,9 +4142,12 @@ def decide_scheduling_request( and request.calendar_integration_enabled and request.create_calendar_event_on_decision ) - if creates_calendar_event and not allow_calendar_handoff: + has_calendar_holds = any( + slot.tentative_hold_event_id for slot in _active_slots(request) + ) + if (creates_calendar_event or has_calendar_holds) and not allow_calendar_handoff: raise SchedulingPermissionError( - "Calendar event creation requires calendar:event:write" + "Calendar event promotion or release requires calendar:event:write" ) try: _poll_provider().decide_poll( @@ -3958,6 +4164,28 @@ def decide_scheduling_request( if request.calendar_integration_enabled and request.create_calendar_event_on_decision: create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id) else: + cleanup_warnings = _release_tentative_calendar_holds( + session, + request=request, + user_id=user_id, + include_selected=True, + ) + _record_calendar_cleanup_state( + request, + warnings=cleanup_warnings, + target_state="handed_off", + ) + if cleanup_warnings: + request.metadata_ = { + **(request.metadata_ or {}), + "calendar_handoff": { + "status": "retry_required", + "slot_id": slot.id, + "calendar_id": request.calendar_id, + }, + } + session.flush() + return request request.handed_off_at = _now() request.status = "handed_off" request.metadata_ = { @@ -3969,17 +4197,37 @@ def decide_scheduling_request( "calendar_id": request.calendar_id, }, } + elif has_calendar_holds: + cleanup_warnings = _release_tentative_calendar_holds( + session, + request=request, + user_id=user_id, + include_selected=True, + ) + _record_calendar_cleanup_state( + request, + warnings=cleanup_warnings, + target_state="decided", + ) create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="decision") _sync_poll_workflow(session, tenant_id=tenant_id, request=request) session.flush() return request -def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: +def cancel_scheduling_request( + session: Session, + *, + tenant_id: str, + request_id: str, + user_id: str | None = None, + allow_calendar_cleanup: bool = False, +) -> SchedulingRequest: request = _lock_scheduling_request( session, tenant_id=tenant_id, request_id=request_id, + lock_slots=True, ) if request.status == "cancelled": return request @@ -3987,6 +4235,27 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s raise SchedulingError( "Only draft, collecting, or closed scheduling requests can be cancelled" ) + has_calendar_holds = any( + slot.tentative_hold_event_id for slot in _active_slots(request) + ) + if has_calendar_holds and not allow_calendar_cleanup: + raise SchedulingPermissionError( + "Calendar event release requires calendar:event:write" + ) + cleanup_warnings = _release_tentative_calendar_holds( + session, + request=request, + user_id=user_id, + include_selected=True, + ) + _record_calendar_cleanup_state( + request, + warnings=cleanup_warnings, + target_state="cancelled", + ) + if cleanup_warnings: + session.flush() + return request cancelled_at = _now() request.status = "cancelled" request.cancelled_at = cancelled_at diff --git a/tests/test_service.py b/tests/test_service.py index beaa90d..9ee0447 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef -from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE +from govoplan_core.core.calendar import CalendarCapabilityError, CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE from govoplan_core.db.base import Base from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.modules import ModuleContext @@ -25,6 +25,7 @@ from govoplan_calendar.backend.db.models import ( CalendarSyncSource, ) from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest +from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider from govoplan_poll.backend.db.models import ( Poll, PollInvitation, @@ -533,6 +534,7 @@ class SchedulingServiceTests(unittest.TestCase): request_id=request.id, payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False), user_id="user-1", + allow_calendar_handoff=True, ) with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"): evaluate_calendar_freebusy( @@ -579,12 +581,17 @@ class SchedulingServiceTests(unittest.TestCase): user_id="user-1", request_id=decided.id, ) + self.assertTrue(all(slot.tentative_hold_event_id is None for slot in request.slots)) self.assertTrue( all( - slot.metadata_["calendar_hold"]["last_known_external_state"] == "queued" + set(slot.metadata_["calendar_hold"]) == { + "last_known_external_state", + "outbox_operation_id", + } for slot in request.slots ) ) + self.assertEqual(request.metadata_["calendar_cleanup"]["status"], "accepted") generated_events = ( self.session.query(CalendarEvent) .filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id) @@ -632,6 +639,177 @@ class SchedulingServiceTests(unittest.TestCase): user_id="user-1", ) + def test_decision_promotes_selected_hold_and_releases_the_rest(self) -> None: + self._calendar() + self.session.add( + CalendarSyncSource( + id="source-decision-cleanup", + tenant_id="tenant-1", + calendar_id="calendar-1", + source_kind="caldav", + collection_url="https://dav.example.test/cal/", + auth_type="none", + sync_enabled=True, + sync_interval_seconds=900, + sync_direction="two_way", + conflict_policy="etag", + metadata_={}, + ) + ) + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + request, hold_ids, warnings = create_tentative_calendar_holds( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=request.id, + ) + selected_slot = request.slots[0] + released_slot = request.slots[1] + selected_hold_id = selected_slot.tentative_hold_event_id + released_hold_id = released_slot.tentative_hold_event_id + self.assertEqual(len(hold_ids), 2) + self.assertEqual(warnings, []) + + close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id) + decided = decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + payload=SchedulingDecisionRequest(slot_id=selected_slot.id, handoff_to_calendar=True), + user_id="user-1", + allow_calendar_handoff=True, + ) + + self.assertEqual(decided.status, "handed_off") + self.assertEqual(decided.calendar_event_id, selected_hold_id) + self.assertEqual(decided.metadata_["calendar_cleanup"]["status"], "accepted") + self.assertIsNone(selected_slot.tentative_hold_event_id) + self.assertIsNone(released_slot.tentative_hold_event_id) + self.assertEqual( + set(selected_slot.metadata_["calendar_hold"]), + {"last_known_external_state", "outbox_operation_id"}, + ) + self.assertEqual( + set(released_slot.metadata_["calendar_hold"]), + {"last_known_external_state", "outbox_operation_id"}, + ) + self.assertIsNotNone(selected_slot.metadata_["calendar_hold"]["outbox_operation_id"]) + self.assertIsNotNone(released_slot.metadata_["calendar_hold"]["outbox_operation_id"]) + promoted = self.session.get(CalendarEvent, selected_hold_id) + released = self.session.get(CalendarEvent, released_hold_id) + self.assertEqual(promoted.status, "CONFIRMED") + self.assertIsNone(promoted.deleted_at) + self.assertIsNotNone(released.deleted_at) + + replayed = decide_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + payload=SchedulingDecisionRequest(slot_id=selected_slot.id, handoff_to_calendar=True), + user_id="user-1", + allow_calendar_handoff=True, + ) + self.assertEqual(replayed.calendar_event_id, selected_hold_id) + self.assertEqual( + self.session.query(CalendarEvent) + .filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id) + .count(), + 2, + ) + + def test_cancellation_keeps_partial_calendar_cleanup_retryable(self) -> None: + self._calendar() + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + request, _hold_ids, _warnings = create_tentative_calendar_holds( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=request.id, + ) + failed_event_id = request.slots[1].tentative_hold_event_id + original_release = SqlCalendarSchedulingProvider.release_event + + def flaky_release(provider, session, *, tenant_id, user_id, event_id): + if event_id == failed_event_id: + raise CalendarCapabilityError("temporary Calendar failure") + return original_release( + provider, + session, + tenant_id=tenant_id, + user_id=user_id, + event_id=event_id, + ) + + with patch.object(SqlCalendarSchedulingProvider, "release_event", new=flaky_release): + pending = cancel_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + user_id="user-1", + allow_calendar_cleanup=True, + ) + self.assertEqual(pending.status, "collecting") + self.assertEqual(pending.metadata_["calendar_cleanup"]["status"], "retry_required") + self.assertEqual( + [slot.tentative_hold_event_id for slot in request.slots], + [None, failed_event_id], + ) + + cancelled = cancel_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + user_id="user-1", + allow_calendar_cleanup=True, + ) + self.assertEqual(cancelled.status, "cancelled") + self.assertEqual(cancelled.metadata_["calendar_cleanup"]["status"], "accepted") + self.assertTrue(all(slot.tentative_hold_event_id is None for slot in request.slots)) + + def test_cancellation_waits_when_calendar_capability_is_unavailable(self) -> None: + self._calendar() + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload(), + ) + request, hold_ids, _warnings = create_tentative_calendar_holds( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request_id=request.id, + ) + + with patch( + "govoplan_scheduling.backend.service._calendar_provider", + side_effect=SchedulingError("Calendar scheduling capability is unavailable"), + ): + pending = cancel_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + user_id="user-1", + allow_calendar_cleanup=True, + ) + + self.assertEqual(pending.status, "collecting") + self.assertEqual(pending.metadata_["calendar_cleanup"]["status"], "retry_required") + self.assertEqual( + [slot.tentative_hold_event_id for slot in request.slots], + hold_ids, + ) + def test_notification_outbox_jobs_are_created_and_listed(self) -> None: request, _tokens = create_scheduling_request( self.session, diff --git a/webui/scripts/test-scheduling-page-structure.mjs b/webui/scripts/test-scheduling-page-structure.mjs index d2a7003..1986c89 100644 --- a/webui/scripts/test-scheduling-page-structure.mjs +++ b/webui/scripts/test-scheduling-page-structure.mjs @@ -39,7 +39,7 @@ const editorStart = page.indexOf('
'); const editorEnd = page.indexOf('', editorStart); const editor = page.slice(editorStart, editorEnd); assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save")); -assert.match(editor, /form="scheduling-editor-form"/); +assert.match(editor, /form:\s*"scheduling-editor-form"/); assert.match(editor, //); assert.match(editor, //); assert.match(page, //); @@ -55,6 +55,7 @@ assert.match(editor, //); assert.match(page, /useUnsavedDraftGuard\(/); assert.match(page, /requestDiscard\(exitEditor\)/); assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/); +assert.match(page, /calendarCleanup\?\.status === "retry_required"[\s\S]*I18N\.calendarCleanupRetryTitle/); assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/); for (const setting of [ @@ -92,7 +93,7 @@ assert.match(page, /search=\{participantSearch\}/); assert.doesNotMatch(page, / requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister), [canAdminister, groups, requests, selectedId] ); + const calendarCleanup = selected ? schedulingCalendarCleanup(selected) : null; const selectedParticipant = useMemo( () => selected ? schedulingParticipantForActor(selected, actor) : null, [actor, selected] @@ -368,7 +375,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin selectedPhase !== "past" && selected.status === "decided" && selected.selected_slot_id && - !selected.calendar_event_id && + (!selected.calendar_event_id || ( + calendarCleanup?.status === "retry_required" + && calendarCleanup.targetState === "handed_off" + )) && selected.create_calendar_event_on_decision ); const optionResultById = useMemo(() => { @@ -1298,7 +1308,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} /> - {selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? ( + {selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id || calendarCleanup?.status === "retry_required") ? ( ) :

{I18N.configuredCalendar}

} + {calendarCleanup?.status === "retry_required" ? ( + + {I18N.calendarCleanupRetryTitle}{" "} + {i18nMessage(I18N.calendarCleanupRetryMessage, { + value0: calendarCleanup.pendingCount + })} + + ) : null}
) : null} @@ -1714,6 +1732,20 @@ function ParticipationStats({ ); } +function schedulingCalendarCleanup(request: SchedulingRequest): SchedulingCalendarCleanup | null { + const value = request.metadata?.calendar_cleanup; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const record = value as Record; + const status = record.status === "retry_required" ? "retry_required" : record.status === "accepted" ? "accepted" : null; + if (!status) return null; + const pending = Array.isArray(record.pending_slot_ids) ? record.pending_slot_ids : []; + return { + status, + targetState: typeof record.target_state === "string" ? record.target_state : null, + pendingCount: pending.length + }; +} + function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): { title: string; message: string; diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 90ea7cc..677d44a 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -1,5 +1,7 @@ export const generatedTranslations = { en: { + "i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Calendar cleanup requires attention.", + "i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} tentative hold operations remain. Reconcile failed Calendar outbound changes if necessary, then repeat the original decision or cancellation action.", "i18n:govoplan-scheduling.access_details.79c06b89": "Access details", "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.", "i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until", @@ -180,6 +182,8 @@ export const generatedTranslations = { "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded." }, de: { + "i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Die Kalenderbereinigung erfordert Aufmerksamkeit.", + "i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} Vorgänge für vorläufige Reservierungen stehen noch aus. Gleichen Sie fehlgeschlagene ausgehende Kalenderänderungen bei Bedarf ab und wiederholen Sie anschließend die ursprüngliche Entscheidungs- oder Abbruchaktion.", "i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten", "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.", "i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",