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
+180 -2
View File
@@ -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,