feat: reconcile tentative calendar holds

This commit is contained in:
2026-08-20 07:44:15 +02:00
parent fb8d674637
commit 79cfaa951a
7 changed files with 522 additions and 29 deletions
+4 -1
View File
@@ -147,6 +147,9 @@ DOCUMENTATION = (
body=( 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. " "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. " "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." "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", 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="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_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=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, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
+7 -1
View File
@@ -739,7 +739,13 @@ def api_cancel_scheduling_request(
) -> SchedulingStatusResponse: ) -> SchedulingStatusResponse:
_require_request_editor(session, principal=principal, request_id=request_id) _require_request_editor(session, principal=principal, request_id=request_id)
try: 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: except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc raise _scheduling_http_error(exc) from exc
response = SchedulingStatusResponse(request=_request_response(request, principal=principal)) response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
+284 -15
View File
@@ -623,7 +623,6 @@ def create_tentative_calendar_holds(
slot.metadata_ = { slot.metadata_ = {
**(slot.metadata_ or {}), **(slot.metadata_ or {}),
"calendar_hold": { "calendar_hold": {
"event_id": event.id,
# This is a creation-time snapshot. Calendar remains the # This is a creation-time snapshot. Calendar remains the
# authority for later outbox delivery transitions. # authority for later outbox delivery transitions.
"last_known_external_state": event.external_state, "last_known_external_state": event.external_state,
@@ -635,6 +634,104 @@ def create_tentative_calendar_holds(
return request, created_event_ids, warnings 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( def create_final_calendar_event(
session: Session, session: Session,
*, *,
@@ -646,53 +743,120 @@ def create_final_calendar_event(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
request_id=request_id, request_id=request_id,
lock_slots=True,
) )
_require_calendar_enabled(request) _require_calendar_enabled(request)
if not request.create_calendar_event_on_decision: if not request.create_calendar_event_on_decision:
raise SchedulingError("Final calendar event creation is not enabled for this scheduling request") 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, [] 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( raise SchedulingError(
"A final calendar event can only be created for a decided scheduling request with a selected slot" "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) slot = _selected_slot(request, slot_id=request.selected_slot_id)
provider = _calendar_provider() provider = _calendar_provider()
warnings: list[str] = []
event = None
if not request.calendar_event_id:
try: 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( event = provider.create_event(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
user_id=user_id, user_id=user_id,
request=_calendar_event_payload(request, slot, status="CONFIRMED"), request=_calendar_event_payload(request, slot, status="CONFIRMED"),
) )
request.calendar_event_id = event.id
except CalendarCapabilityError as exc: except CalendarCapabilityError as exc:
_calendar_cleanup_metadata(slot, state="retry_required")
warnings.append(str(exc))
warnings.extend(
_release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
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 {}
)
request.metadata_ = { request.metadata_ = {
**(request.metadata_ or {}), **(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() session.flush()
return request, None, [str(exc)] return request, request.calendar_event_id, warnings
request.calendar_event_id = event.id
request.handed_off_at = _now() request.handed_off_at = _now()
request.status = "handed_off" 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_ = {
**(request.metadata_ or {}), **(request.metadata_ or {}),
"calendar_handoff": { "calendar_handoff": {
"status": "accepted", "status": "accepted",
# Scheduling stores identifiers plus an explicitly named snapshot; # Scheduling stores identifiers plus an explicitly named snapshot;
# consumers must resolve the Calendar event/outbox for live state. # consumers must resolve the Calendar event/outbox for live state.
"last_known_external_state": event.external_state, "last_known_external_state": (
"outbox_operation_id": event.outbox_operation_id, event.external_state
"event_id": event.id, 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, "slot_id": slot.id,
"calendar_id": request.calendar_id, "calendar_id": request.calendar_id,
}, },
} }
_sync_poll_workflow(session, tenant_id=tenant_id, request=request) _sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush() session.flush()
return request, event.id, [] return request, request.calendar_event_id, []
def create_scheduling_notification_jobs( def create_scheduling_notification_jobs(
@@ -3917,6 +4081,7 @@ def decide_scheduling_request(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
request_id=request_id, request_id=request_id,
lock_slots=True,
) )
if request.poll_id is None: if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll") 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; " "A different slot cannot be selected after a scheduling decision; "
"explicit rescheduling semantics are required" "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 return request
if request.status != "closed": if request.status != "closed":
raise SchedulingError("Only closed scheduling requests can be decided") raise SchedulingError("Only closed scheduling requests can be decided")
@@ -3939,9 +4142,12 @@ def decide_scheduling_request(
and request.calendar_integration_enabled and request.calendar_integration_enabled
and request.create_calendar_event_on_decision 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( raise SchedulingPermissionError(
"Calendar event creation requires calendar:event:write" "Calendar event promotion or release requires calendar:event:write"
) )
try: try:
_poll_provider().decide_poll( _poll_provider().decide_poll(
@@ -3958,6 +4164,28 @@ def decide_scheduling_request(
if request.calendar_integration_enabled and request.create_calendar_event_on_decision: 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) create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id)
else: 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.handed_off_at = _now()
request.status = "handed_off" request.status = "handed_off"
request.metadata_ = { request.metadata_ = {
@@ -3969,17 +4197,37 @@ def decide_scheduling_request(
"calendar_id": request.calendar_id, "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") 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) _sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush() session.flush()
return request 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( request = _lock_scheduling_request(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
request_id=request_id, request_id=request_id,
lock_slots=True,
) )
if request.status == "cancelled": if request.status == "cancelled":
return request return request
@@ -3987,6 +4235,27 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
raise SchedulingError( raise SchedulingError(
"Only draft, collecting, or closed scheduling requests can be cancelled" "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() cancelled_at = _now()
request.status = "cancelled" request.status = "cancelled"
request.cancelled_at = cancelled_at request.cancelled_at = cancelled_at
+180 -2
View File
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef 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.db.base import Base
from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
@@ -25,6 +25,7 @@ from govoplan_calendar.backend.db.models import (
CalendarSyncSource, CalendarSyncSource,
) )
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest 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 ( from govoplan_poll.backend.db.models import (
Poll, Poll,
PollInvitation, PollInvitation,
@@ -533,6 +534,7 @@ class SchedulingServiceTests(unittest.TestCase):
request_id=request.id, request_id=request.id,
payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False), payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False),
user_id="user-1", user_id="user-1",
allow_calendar_handoff=True,
) )
with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"): with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"):
evaluate_calendar_freebusy( evaluate_calendar_freebusy(
@@ -579,12 +581,17 @@ class SchedulingServiceTests(unittest.TestCase):
user_id="user-1", user_id="user-1",
request_id=decided.id, request_id=decided.id,
) )
self.assertTrue(all(slot.tentative_hold_event_id is None for slot in request.slots))
self.assertTrue( self.assertTrue(
all( 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 for slot in request.slots
) )
) )
self.assertEqual(request.metadata_["calendar_cleanup"]["status"], "accepted")
generated_events = ( generated_events = (
self.session.query(CalendarEvent) self.session.query(CalendarEvent)
.filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id) .filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id)
@@ -632,6 +639,177 @@ class SchedulingServiceTests(unittest.TestCase):
user_id="user-1", 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: def test_notification_outbox_jobs_are_created_and_listed(self) -> None:
request, _tokens = create_scheduling_request( request, _tokens = create_scheduling_request(
self.session, self.session,
@@ -39,7 +39,7 @@ const editorStart = page.indexOf('<div className="scheduling-editor-surface">');
const editorEnd = page.indexOf('</form>', editorStart); const editorEnd = page.indexOf('</form>', editorStart);
const editor = page.slice(editorStart, editorEnd); const editor = page.slice(editorStart, editorEnd);
assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save")); 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, /<Card title=\{I18N\.basicInformation\}>/); assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/); assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/); assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
@@ -55,6 +55,7 @@ assert.match(editor, /<FormField label=\{I18N\.title\}>/);
assert.match(page, /useUnsavedDraftGuard\(/); assert.match(page, /useUnsavedDraftGuard\(/);
assert.match(page, /requestDiscard\(exitEditor\)/); assert.match(page, /requestDiscard\(exitEditor\)/);
assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/); 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)\(/); assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/);
for (const setting of [ for (const setting of [
@@ -92,7 +93,7 @@ assert.match(page, /search=\{participantSearch\}/);
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/); assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/); assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
assert.match(page, /showDecisionAction=\{canManageSelected\}/); assert.match(page, /showDecisionAction=\{canManageSelected\}/);
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/); assert.match(page, /<WorkspaceActionBar[\s\S]*refreshable[\s\S]*reloadAction=\{\{[\s\S]*label: I18N\.refresh/);
assert.doesNotMatch(page, /AdminIconButton/); assert.doesNotMatch(page, /AdminIconButton/);
const participantGridStart = page.indexOf("function ParticipantsGrid("); const participantGridStart = page.indexOf("function ParticipantsGrid(");
@@ -27,7 +27,6 @@ import { ContentGrid, FormGrid,
DocumentationHelpLink, DocumentationHelpLink,
FormField, FormField,
MetricCard, MetricCard,
IconButton,
PageTitle, PageTitle,
PasswordField, PasswordField,
PeoplePicker, PeoplePicker,
@@ -127,6 +126,11 @@ type DecisionTarget = {
requestId: string; requestId: string;
slot: SchedulingCandidateSlot; slot: SchedulingCandidateSlot;
}; };
type SchedulingCalendarCleanup = {
status: "accepted" | "retry_required";
targetState: string | null;
pendingCount: number;
};
const I18N = { const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a", actions: "i18n:govoplan-core.actions.c3cd636a",
@@ -148,6 +152,8 @@ const I18N = {
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b", calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e", calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106", calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
calendarCleanupRetryTitle: "i18n:govoplan-scheduling.calendar_cleanup_retry_title",
calendarCleanupRetryMessage: "i18n:govoplan-scheduling.calendar_cleanup_retry_message",
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5", candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b", candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c", cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
@@ -342,6 +348,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
() => requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister), () => requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister),
[canAdminister, groups, requests, selectedId] [canAdminister, groups, requests, selectedId]
); );
const calendarCleanup = selected ? schedulingCalendarCleanup(selected) : null;
const selectedParticipant = useMemo( const selectedParticipant = useMemo(
() => selected ? schedulingParticipantForActor(selected, actor) : null, () => selected ? schedulingParticipantForActor(selected, actor) : null,
[actor, selected] [actor, selected]
@@ -368,7 +375,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
selectedPhase !== "past" && selectedPhase !== "past" &&
selected.status === "decided" && selected.status === "decided" &&
selected.selected_slot_id && 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 selected.create_calendar_event_on_decision
); );
const optionResultById = useMemo(() => { const optionResultById = useMemo(() => {
@@ -1298,7 +1308,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} /> onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
</Card> </Card>
{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") ? (
<Card <Card
title={I18N.calendarCoordination} title={I18N.calendarCoordination}
actions={( actions={(
@@ -1342,6 +1352,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.configuredCalendar} label={I18N.configuredCalendar}
disabled /> disabled />
) : <p>{I18N.configuredCalendar}</p>} ) : <p>{I18N.configuredCalendar}</p>}
{calendarCleanup?.status === "retry_required" ? (
<DismissibleAlert tone="warning" dismissible={false} compact>
<strong>{I18N.calendarCleanupRetryTitle}</strong>{" "}
{i18nMessage(I18N.calendarCleanupRetryMessage, {
value0: calendarCleanup.pendingCount
})}
</DismissibleAlert>
) : null}
</Card> </Card>
) : 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<string, unknown>;
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"]): { function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
title: string; title: string;
message: string; message: string;
+4
View File
@@ -1,5 +1,7 @@
export const generatedTranslations = { export const generatedTranslations = {
en: { 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.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.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", "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." "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
}, },
de: { 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.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.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", "i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",