Implement Open-Xchange calendar profiles

This commit is contained in:
2026-08-02 05:59:06 +02:00
parent 66a6df4f05
commit 4e05ab2c3e
11 changed files with 476 additions and 23 deletions
@@ -10,6 +10,9 @@ from govoplan_core.core.calendar import (
CalendarCapabilityError,
CalendarEventRef,
CalendarEventRequest,
CalendarExternalProfileProvider,
CalendarExternalProfileRef,
CalendarExternalProfileRequest,
CalendarInvitationAttendeeRequest,
CalendarInvitationProvider,
CalendarInvitationRef,
@@ -21,9 +24,11 @@ from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import (
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
CalendarSyncSourceCreateRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
create_sync_source,
create_event,
list_freebusy,
update_event,
@@ -37,6 +42,7 @@ _PARTICIPATION_STATUSES = {
"TENTATIVE",
"DELEGATED",
}
_OPEN_XCHANGE_PROFILE_KIND = "open_xchange"
def _external_state(event: CalendarEvent) -> tuple[str, str | None]:
@@ -223,6 +229,118 @@ class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
)
class SqlCalendarExternalProfileProvider(CalendarExternalProfileProvider):
"""Configure groupware profiles while Calendar retains event semantics."""
def supported_profiles(self) -> tuple[Mapping[str, object], ...]:
return (
{
"profile_kind": _OPEN_XCHANGE_PROFILE_KIND,
"transport_kind": "caldav",
"operations": (
"discover",
"read",
"write",
"delete",
"freebusy",
),
"resource_calendars": True,
"recurrence": True,
"conflict_tokens": ("sync-token", "ctag", "etag"),
},
)
def configure_profile(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarExternalProfileRequest,
) -> CalendarExternalProfileRef:
db = self._session(session)
profile_kind = request.profile_kind.strip().lower().replace("-", "_")
if profile_kind != _OPEN_XCHANGE_PROFILE_KIND:
raise CalendarCapabilityError(
f"Unsupported external calendar profile: {request.profile_kind}"
)
metadata = dict(request.metadata)
metadata.update(
{
"integration_profile": _OPEN_XCHANGE_PROFILE_KIND,
"transport_kind": "caldav",
"connector_profile_ref": self._reference(
request.connector_profile_ref,
label="connector_profile_ref",
),
"identity_mapping_ref": self._reference(
request.identity_mapping_ref,
label="identity_mapping_ref",
),
"resource_calendar_ref": self._reference(
request.resource_calendar_ref,
label="resource_calendar_ref",
),
}
)
metadata = {key: value for key, value in metadata.items() if value is not None}
try:
payload = CalendarSyncSourceCreateRequest(
source_kind="caldav",
calendar_id=request.calendar_id,
collection_url=request.endpoint_url,
display_name=request.display_name,
auth_type=request.auth_type,
username=request.username,
credential_ref=request.credential_ref,
sync_enabled=request.sync_enabled,
sync_interval_seconds=request.sync_interval_seconds,
sync_direction=request.sync_direction,
conflict_policy=request.conflict_policy,
metadata=metadata,
)
source = create_sync_source(
db,
tenant_id=tenant_id,
user_id=user_id,
payload=payload,
)
except (CalendarError, TypeError, ValueError) as exc:
raise CalendarCapabilityError(str(exc)) from exc
if request.resource_calendar_ref:
source.calendar.owner_type = "resource"
source.calendar.owner_id = request.resource_calendar_ref.strip()
db.flush()
return CalendarExternalProfileRef(
source_id=source.id,
calendar_id=source.calendar_id,
profile_kind=_OPEN_XCHANGE_PROFILE_KIND,
transport_kind="caldav",
connector_profile_ref=request.connector_profile_ref,
identity_mapping_ref=request.identity_mapping_ref,
resource_calendar_ref=request.resource_calendar_ref,
)
@staticmethod
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise CalendarCapabilityError(
"External calendar profiles require a SQLAlchemy Session."
)
return session
@staticmethod
def _reference(value: str | None, *, label: str) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
return None
if len(normalized) > 255:
raise CalendarCapabilityError(f"{label} must not exceed 255 characters.")
return normalized
class SqlCalendarInvitationProvider(CalendarInvitationProvider):
def upsert_invitation(
self,
+72 -2
View File
@@ -10,6 +10,7 @@ from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPA
from govoplan_core.core.calendar import (
CALENDAR_AVAILABILITY_READ_SCOPE,
CALENDAR_EVENT_WRITE_SCOPE,
CAPABILITY_CALENDAR_EXTERNAL_PROFILES,
CAPABILITY_CALENDAR_INVITATIONS,
CAPABILITY_CALENDAR_OUTBOX,
CAPABILITY_CALENDAR_SCHEDULING,
@@ -44,6 +45,7 @@ CALDAV_PROVIDER_ID = "calendar.caldav_sync"
ICS_PROVIDER_ID = "calendar.ics_subscription"
GRAPH_PROVIDER_ID = "calendar.microsoft_graph"
EWS_PROVIDER_ID = "calendar.exchange_ews"
OPEN_XCHANGE_PROVIDER_ID = "calendar.open_xchange"
CALENDAR_ARCHITECTURE = ModuleArchitectureDeclaration(
layer="communication_participation",
@@ -228,6 +230,54 @@ CALENDAR_EXTERNAL_PROVIDERS = (
interface_names=("calendar.outbox",),
documentation_topic_ids=("calendar.external-sources-and-sync",),
),
ExternalProviderDeclaration(
id=OPEN_XCHANGE_PROVIDER_ID,
module_id="calendar",
label="Open-Xchange calendar synchronization",
maturity="synchronize",
operations=("discover", "read", "write", "delete", "synchronize", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="calendar_collection",
field_groups=("identity", "display", "sync_state", "resource_mapping"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="governed_sync",
),
ProviderObjectDeclaration(
object_type="calendar_event",
field_groups=("identity", "schedule", "recurrence", "participants", "content"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Open-Xchange CalDAV sync tokens, collection tags, and per-resource ETags are retained.",
concurrency="Conditional CalDAV requests reject stale ETags and expose conflicts for reconciliation.",
freshness="Last attempt, last success, sync token, and pending outbox work are visible.",
health="Discovery, authentication, transport, collection, and reconciliation failures are recorded.",
max_read_items=5000,
idempotency="Stable VEVENT UID, source id, and durable operation keys suppress duplicate effects.",
retry="Only classified transient failures receive bounded backoff retries.",
timeout_seconds=30,
conflicts="Stale revisions and concurrent Open-Xchange changes enter conflict or reconciliation state.",
outcome_unknown="A timed-out remote write is outcome-unknown and is reconciled before retry.",
outcome_unknown_supported=True,
evidence="The Open-Xchange profile reference, mapping references, operation intent, ETag, and reconciliation result are retained.",
audit_event_types=("calendar.sync.requested", "calendar.sync.completed", "calendar.sync.conflict", "calendar.sync.reconciled"),
correction="A later conditional update or tombstone corrects external state after reconciliation.",
rollback="Remote effects are not treated as transactionally rollback-safe.",
compensation="A compensating event update or delete may be queued after remote state is known.",
reconciliation="Read by CalDAV href and VEVENT UID, compare ETag and content, then classify the result.",
outage="The local projection remains available with stale markers and durable pending writes.",
classifications=("internal", "confidential", "restricted"),
purposes=("calendar collaboration", "availability", "resource booking", "meeting coordination"),
retention="Calendar event, outbox, audit, profile-binding, and credential retention remain independent policies.",
secret_handling="Calendar stores only scoped credential references or encrypted Calendar-owned credentials.",
),
capability_names=(CAPABILITY_CALENDAR_EXTERNAL_PROFILES, CAPABILITY_CALENDAR_OUTBOX, CAPABILITY_CALENDAR_SCHEDULING),
interface_names=("calendar.external_profiles", "calendar.outbox", "calendar.scheduling"),
documentation_topic_ids=("calendar.external-sources-and-sync",),
),
_read_only_calendar_provider(
provider_id=ICS_PROVIDER_ID,
label="ICS and webcal subscription",
@@ -401,6 +451,13 @@ def _calendar_outbox_provider(context: ModuleContext) -> object:
)
def _calendar_external_profile_provider(context: ModuleContext) -> object:
del context
from govoplan_calendar.backend.capabilities import SqlCalendarExternalProfileProvider
return SqlCalendarExternalProfileProvider()
def _caldav_provider_states(context):
from govoplan_calendar.backend.provider_state import caldav_provider_states
@@ -425,6 +482,12 @@ def _ews_provider_states(context):
return ews_provider_states(context)
def _open_xchange_provider_states(context):
from govoplan_calendar.backend.provider_state import open_xchange_provider_states
return open_xchange_provider_states(context)
manifest = ModuleManifest(
id="calendar",
name="Calendar",
@@ -435,6 +498,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.invitations", version="0.1.0"),
ModuleInterfaceProvider(name="calendar.external_profiles", version="0.1.0"),
),
permissions=PERMISSIONS,
route_factory=_calendar_router,
@@ -463,6 +527,11 @@ manifest = ModuleManifest(
provider_id=EWS_PROVIDER_ID,
provider=_ews_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=OPEN_XCHANGE_PROVIDER_ID,
provider=_open_xchange_provider_states,
),
),
documentation=(
DocumentationTopic(
@@ -478,8 +547,8 @@ manifest = ModuleManifest(
DocumentationTopic(
id="calendar.external-sources-and-sync",
title="Connect and synchronize external calendars",
summary="Calendar supports local collections, two-way CalDAV, and read-only ICS/webcal, Microsoft Graph, and Exchange Web Services sources.",
body="Each external source keeps its URL, synchronization direction, status, and credential reference with the Calendar collection. Manual or scheduled synchronization records bounded outcomes. CalDAV writes use conditional requests and durable outbox state; conflicts and unknown outcomes require synchronization or explicit reconciliation instead of blind repetition. Removing an external source removes the connection, while deleting a local calendar deletes its owned events after confirmation or transfer.",
summary="Calendar supports local collections, two-way CalDAV and Open-Xchange profiles, and read-only ICS/webcal, Microsoft Graph, and Exchange Web Services sources.",
body="Each external source keeps its URL, synchronization direction, status, and credential reference with the Calendar collection. Open-Xchange uses the proven CalDAV transport while retaining connector-profile, identity/group-mapping, and resource-calendar references. Manual or scheduled synchronization records bounded outcomes. CalDAV writes use conditional requests and durable outbox state; conflicts and unknown outcomes require synchronization or explicit reconciliation instead of blind repetition. Removing an external source removes the connection, while deleting a local calendar deletes its owned events after confirmation or transfer.",
documentation_types=("admin", "user"),
audience=("user", "calendar_manager", "operator"),
related_modules=("connectors", "audit", "ops"),
@@ -490,6 +559,7 @@ manifest = ModuleManifest(
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
CAPABILITY_CALENDAR_INVITATIONS: _calendar_invitation_provider,
CAPABILITY_CALENDAR_EXTERNAL_PROFILES: _calendar_external_profile_provider,
},
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
frontend=FrontendModule(
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from govoplan_calendar.backend.db.models import (
@@ -20,6 +20,8 @@ CALDAV_PROVIDER_ID = "calendar.caldav_sync"
ICS_PROVIDER_ID = "calendar.ics_subscription"
GRAPH_PROVIDER_ID = "calendar.microsoft_graph"
EWS_PROVIDER_ID = "calendar.exchange_ews"
OPEN_XCHANGE_PROVIDER_ID = "calendar.open_xchange"
OPEN_XCHANGE_PROFILE_KIND = "open_xchange"
_HEALTHY_STATUSES = frozenset({"ok", "outbound_ok"})
_ERROR_STATUSES = frozenset({"error", "outbound_error"})
_WARNING_STATUSES = frozenset(
@@ -36,6 +38,19 @@ def caldav_provider_states(
provider_id=CALDAV_PROVIDER_ID,
source_kinds=("caldav",),
include_outbox=True,
exclude_profile_kinds=(OPEN_XCHANGE_PROFILE_KIND,),
)
def open_xchange_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=OPEN_XCHANGE_PROVIDER_ID,
source_kinds=("caldav",),
include_outbox=True,
profile_kind=OPEN_XCHANGE_PROFILE_KIND,
)
@@ -75,6 +90,8 @@ def _provider_states(
provider_id: str,
source_kinds: tuple[str, ...],
include_outbox: bool = False,
profile_kind: str | None = None,
exclude_profile_kinds: tuple[str, ...] = (),
) -> tuple[ExternalProviderRuntimeState, ...]:
if not isinstance(context.session, Session):
raise RuntimeError("Calendar provider state requires a database session.")
@@ -86,6 +103,17 @@ def _provider_states(
statement = statement.where(
CalendarSyncSource.tenant_id == context.tenant_id
)
profile_expression = CalendarSyncSource.metadata_["integration_profile"].as_string()
if profile_kind is not None:
statement = statement.where(profile_expression == profile_kind)
elif exclude_profile_kinds:
statement = statement.where(
or_(
CalendarSyncSource.metadata_.is_(None),
profile_expression.is_(None),
profile_expression.notin_(exclude_profile_kinds),
)
)
sources = tuple(
context.session.scalars(
statement.order_by(
+20
View File
@@ -3654,6 +3654,26 @@ def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSync
metadata["sync_source_id"] = source.id
metadata["sync_href"] = source.collection_url
metadata["sync_direction"] = source.sync_direction
source_metadata = source.metadata_ if isinstance(source.metadata_, dict) else {}
if source_metadata.get("integration_profile") == "open_xchange":
for key in (
"integration_profile",
"transport_kind",
"connector_profile_ref",
"identity_mapping_ref",
"resource_calendar_ref",
):
if source_metadata.get(key):
metadata[key] = source_metadata[key]
else:
metadata.pop(key, None)
resource_ref = source_metadata.get("resource_calendar_ref")
if resource_ref:
calendar.owner_type = "resource"
calendar.owner_id = str(resource_ref)
elif calendar.owner_type == "resource":
calendar.owner_type = "tenant"
calendar.owner_id = None
calendar.metadata_ = metadata