feat: reconcile scheduling calendar holds

This commit is contained in:
2026-08-20 07:44:15 +02:00
parent 4bbf80e634
commit 93fb8aeff8
3 changed files with 170 additions and 6 deletions
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from govoplan_core.core.calendar import ( from govoplan_core.core.calendar import (
CalendarCapabilityError, CalendarCapabilityError,
CalendarEventRef, CalendarEventRef,
CalendarEventReleaseRef,
CalendarEventRequest, CalendarEventRequest,
CalendarExternalProfileProvider, CalendarExternalProfileProvider,
CalendarExternalProfileRef, CalendarExternalProfileRef,
@@ -37,6 +38,7 @@ from govoplan_calendar.backend.service import (
CalendarError, CalendarError,
create_sync_source, create_sync_source,
create_event, create_event,
delete_event,
list_calendars as list_calendar_collections, list_calendars as list_calendar_collections,
list_freebusy, list_freebusy,
update_event, update_event,
@@ -312,6 +314,96 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
outbox_operation_id=outbox_operation_id, 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): class SqlCalendarExternalProfileProvider(CalendarExternalProfileProvider):
"""Configure groupware profiles while Calendar retains event semantics.""" """Configure groupware profiles while Calendar retains event semantics."""
+2 -2
View File
@@ -504,7 +504,7 @@ manifest = ModuleManifest(
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors", "search"), optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors", "search"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"), 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.invitations", version="0.2.0"),
ModuleInterfaceProvider(name="calendar.external_profiles", version="0.1.0"), ModuleInterfaceProvider(name="calendar.external_profiles", version="0.1.0"),
), ),
@@ -608,7 +608,7 @@ manifest = ModuleManifest(
id="calendar.manage-calendars-and-events", id="calendar.manage-calendars-and-events",
title="Use 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.", 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",), documentation_types=("user",),
audience=("user", "calendar_manager"), audience=("user", "calendar_manager"),
related_modules=("scheduling", "notifications"), related_modules=("scheduling", "notifications"),
+76 -4
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -28,11 +28,29 @@ from govoplan_tenancy.backend.db.models import Tenant
class CalendarSchedulingCapabilityTests(unittest.TestCase): class CalendarSchedulingCapabilityTests(unittest.TestCase):
def test_event_request_validation_is_exposed_as_capability_error(self) -> None: def setUp(self) -> None:
provider = SqlCalendarSchedulingProvider() 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): with self.assertRaises(CalendarCapabilityError):
provider.create_event( self.provider.create_event(
object(), object(),
tenant_id="tenant-1", tenant_id="tenant-1",
user_id="user-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): class CalendarExternalProfileCapabilityTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None: