feat: reconcile scheduling calendar holds
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user