From 93fb8aeff8d7d04b68c3ea9d8685b09553427150 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 07:44:15 +0200 Subject: [PATCH] feat: reconcile scheduling calendar holds --- src/govoplan_calendar/backend/capabilities.py | 92 +++++++++++++++++++ src/govoplan_calendar/backend/manifest.py | 4 +- tests/test_capabilities.py | 80 +++++++++++++++- 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/src/govoplan_calendar/backend/capabilities.py b/src/govoplan_calendar/backend/capabilities.py index 71a067e..c2106ac 100644 --- a/src/govoplan_calendar/backend/capabilities.py +++ b/src/govoplan_calendar/backend/capabilities.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from govoplan_core.core.calendar import ( CalendarCapabilityError, CalendarEventRef, + CalendarEventReleaseRef, CalendarEventRequest, CalendarExternalProfileProvider, CalendarExternalProfileRef, @@ -37,6 +38,7 @@ from govoplan_calendar.backend.service import ( CalendarError, create_sync_source, create_event, + delete_event, list_calendars as list_calendar_collections, list_freebusy, update_event, @@ -312,6 +314,96 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider): outbox_operation_id=outbox_operation_id, ) + def promote_event( + self, + session: object, + *, + tenant_id: str, + user_id: str | None, + event_id: str, + request: CalendarEventRequest, + ) -> CalendarEventRef: + try: + payload = CalendarEventUpdateRequest( + calendar_id=request.calendar_id, + summary=request.summary, + description=request.description, + location=request.location, + status=request.status, + transparency=request.transparency, + classification=request.classification, + start_at=request.start_at, + end_at=request.end_at, + timezone=request.timezone, + attendees=[dict(item) for item in request.attendees], + categories=list(request.categories), + related_to=[dict(item) for item in request.related_to], + metadata=dict(request.metadata), + ) + event = update_event( + session, + tenant_id=tenant_id, + user_id=user_id, + event_id=event_id, + payload=payload, + ) + except (CalendarError, TypeError, ValueError) as exc: + raise CalendarCapabilityError(str(exc)) from exc + external_state, outbox_operation_id = _external_state(event) + return CalendarEventRef( + id=event.id, + calendar_id=event.calendar_id, + uid=event.uid, + external_state=external_state, + outbox_operation_id=outbox_operation_id, + ) + + def release_event( + self, + session: object, + *, + tenant_id: str, + user_id: str | None, + event_id: str, + ) -> CalendarEventReleaseRef: + if not isinstance(session, Session): + raise CalendarCapabilityError("Calendar release requires a database session") + event = ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.id == event_id, + ) + .first() + ) + if event is None: + return CalendarEventReleaseRef( + event_id=event_id, + already_released=True, + external_state="not_found", + ) + was_released = event.deleted_at is not None + if not was_released: + try: + delete_event( + session, + tenant_id=tenant_id, + event_id=event_id, + user_id=user_id, + ) + except CalendarError as exc: + raise CalendarCapabilityError(str(exc)) from exc + session.flush() + external_state, outbox_operation_id = _external_state(event) + return CalendarEventReleaseRef( + event_id=event_id, + already_released=was_released, + external_state=( + external_state if external_state != "local" else "local_released" + ), + outbox_operation_id=outbox_operation_id, + ) + class SqlCalendarExternalProfileProvider(CalendarExternalProfileProvider): """Configure groupware profiles while Calendar retains event semantics.""" diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index badc48f..59e0038 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -504,7 +504,7 @@ manifest = ModuleManifest( optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors", "search"), provides_interfaces=( ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"), - ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"), + ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.9"), ModuleInterfaceProvider(name="calendar.invitations", version="0.2.0"), ModuleInterfaceProvider(name="calendar.external_profiles", version="0.1.0"), ), @@ -608,7 +608,7 @@ manifest = ModuleManifest( id="calendar.manage-calendars-and-events", title="Use calendars and events", summary="Create calendar collections and work with all-day or timed events in continuous, month, week, workweek, and day views.", - body="Calendar remembers the selected view and preferences. Events can be created, edited, moved, resized, repeated, imported, exported, or deleted when the current account has the corresponding permission. All-day events use dates rather than local clock times; timed events retain their timezone-aware start and end values.", + body="Calendar remembers the selected view and preferences. Events can be created, edited, moved, resized, repeated, imported, exported, or deleted when the current account has the corresponding permission. All-day events use dates rather than local clock times; timed events retain their timezone-aware start and end values. Scheduling may promote a selected tentative hold in place and submit unused holds for idempotent release through the neutral Calendar capability. Calendar owns the resulting local tombstone, synchronized outbox operation, retry and reconciliation evidence; an already released or absent event is an accepted replay rather than a duplicate failure.", documentation_types=("user",), audience=("user", "calendar_manager"), related_modules=("scheduling", "notifications"), diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index b27ce92..b75d9fd 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -1,7 +1,7 @@ from __future__ import annotations import unittest -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -28,11 +28,29 @@ from govoplan_tenancy.backend.db.models import Tenant class CalendarSchedulingCapabilityTests(unittest.TestCase): - def test_event_request_validation_is_exposed_as_capability_error(self) -> None: - provider = SqlCalendarSchedulingProvider() + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + create_scope_tables(self.engine) + Base.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine) + self.session = self.Session() + self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + self.calendar = create_calendar( + self.session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Scheduling"), + ) + self.provider = SqlCalendarSchedulingProvider() + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def test_event_request_validation_is_exposed_as_capability_error(self) -> None: with self.assertRaises(CalendarCapabilityError): - provider.create_event( + self.provider.create_event( object(), tenant_id="tenant-1", user_id="user-1", @@ -42,6 +60,60 @@ class CalendarSchedulingCapabilityTests(unittest.TestCase): ), ) + def test_hold_promotion_and_release_are_idempotent(self) -> None: + start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) + hold = self.provider.create_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + request=CalendarEventRequest( + calendar_id=self.calendar.id, + summary="Tentative hold", + status="TENTATIVE", + start_at=start, + end_at=start + timedelta(hours=1), + metadata={"scheduling_request_id": "request-1"}, + ), + ) + promoted = self.provider.promote_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + event_id=hold.id, + request=CalendarEventRequest( + calendar_id=self.calendar.id, + summary="Confirmed meeting", + status="CONFIRMED", + start_at=start, + end_at=start + timedelta(hours=1), + metadata={"scheduling_request_id": "request-1"}, + ), + ) + released = self.provider.release_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + event_id=promoted.id, + ) + replayed = self.provider.release_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + event_id=promoted.id, + ) + absent = self.provider.release_event( + self.session, + tenant_id="tenant-1", + user_id="user-1", + event_id="missing-event", + ) + + self.assertEqual(promoted.id, hold.id) + self.assertEqual("CONFIRMED", self.session.get(CalendarEvent, hold.id).status) + self.assertFalse(released.already_released) + self.assertTrue(replayed.already_released) + self.assertEqual("not_found", absent.external_state) + class CalendarExternalProfileCapabilityTests(unittest.TestCase): def setUp(self) -> None: