feat: reconcile tentative calendar holds
This commit is contained in:
+180
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user