Implement Open-Xchange calendar profiles
This commit is contained in:
@@ -48,6 +48,13 @@ environment and external-provider references are rejected. Trusted deployment
|
||||
code may resolve an `env:NAME` reference only through the separate deployment
|
||||
configuration helper.
|
||||
|
||||
Open-Xchange is available as an explicit profile over that CalDAV engine. The
|
||||
calendar dialog can retain optional connector-profile, identity/group-mapping,
|
||||
and resource-calendar references. Resource bindings mark the collection as a
|
||||
resource calendar. Optional connector modules can configure the same profile
|
||||
through the Core-mediated `calendar.externalProfiles` capability; Calendar has
|
||||
no hard dependency on Connectors, IDM, or Access mapping implementations.
|
||||
|
||||
Deleting a source or its calendar immediately scrubs Calendar-owned ciphertext
|
||||
and provider references and emits non-secret audit evidence. If an external
|
||||
secret provider cannot confirm deletion, the operation fails closed without
|
||||
|
||||
@@ -32,11 +32,41 @@ The first standalone module provides:
|
||||
deterministic hrefs, conditional requests, expiring leases, bounded
|
||||
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
||||
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
||||
- an Open-Xchange profile over the CalDAV transport, with explicit connector,
|
||||
IDM/group-mapping, and resource-calendar references
|
||||
|
||||
The implementation is not yet a full CalDAV network server. It is the internal
|
||||
calendar storage, recurrence, sync, availability, and UI foundation on which
|
||||
Open-Xchange integration, scheduling inbox/outbox behavior, and CalDAV server
|
||||
endpoints can be built.
|
||||
scheduling inbox/outbox behavior and CalDAV server endpoints can be built.
|
||||
|
||||
## Open-Xchange profile
|
||||
|
||||
Open-Xchange is an explicit external profile backed by Calendar's CalDAV
|
||||
engine. A profile therefore receives the same bounded discovery, VEVENT
|
||||
round-trip, RRULE/RDATE/EXDATE and detached-exception handling, sync-token/ctag
|
||||
tracking, per-resource ETag conflict detection, and durable two-way outbox as a
|
||||
generic CalDAV source. Calendar's availability capability reads the resulting
|
||||
projection, so free/busy and collision checks use the same recurrence-aware
|
||||
event set. Availability is current as of the source's visible last successful
|
||||
sync; GovOPlaN does not claim live Open-Xchange state while a source is stale.
|
||||
|
||||
The source metadata retains three optional, non-secret bindings:
|
||||
|
||||
- `connector_profile_ref` links the endpoint to a Connectors-owned inventory or
|
||||
deployment profile.
|
||||
- `identity_mapping_ref` links attendee/group identifiers to an IDM- or
|
||||
Access-governed mapping without importing either module.
|
||||
- `resource_calendar_ref` identifies an Open-Xchange room/equipment calendar;
|
||||
the corresponding Calendar collection is owned as a `resource`.
|
||||
|
||||
The `calendar.externalProfiles` capability lets an optional connector configure
|
||||
the same profile through a Core contract. Direct Calendar setup remains
|
||||
available when Connectors is absent. The connector never supplies a Calendar
|
||||
event model and the references never contain credentials. Open-Xchange
|
||||
credentials remain a reusable credential-envelope reference or a
|
||||
Calendar-owned encrypted credential. The WebUI exposes Open-Xchange as a source
|
||||
type and reuses CalDAV discovery; deployments must enter a DAV endpoint that
|
||||
resolves to the intended collection.
|
||||
|
||||
## Outbound delivery operations
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_calendar.backend.capabilities import (
|
||||
SqlCalendarExternalProfileProvider,
|
||||
SqlCalendarInvitationProvider,
|
||||
SqlCalendarSchedulingProvider,
|
||||
)
|
||||
@@ -16,6 +17,7 @@ from govoplan_calendar.backend.service import create_calendar
|
||||
from govoplan_core.core.calendar import (
|
||||
CalendarCapabilityError,
|
||||
CalendarEventRequest,
|
||||
CalendarExternalProfileRequest,
|
||||
CalendarInvitationAttendeeRequest,
|
||||
CalendarInvitationRequest,
|
||||
)
|
||||
@@ -40,6 +42,64 @@ class CalendarSchedulingCapabilityTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class CalendarExternalProfileCapabilityTests(unittest.TestCase):
|
||||
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="Open-Xchange"),
|
||||
)
|
||||
self.provider = SqlCalendarExternalProfileProvider()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_open_xchange_profile_uses_caldav_and_retains_mapping_refs(self) -> None:
|
||||
configured = self.provider.configure_profile(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
request=CalendarExternalProfileRequest(
|
||||
profile_kind="open-xchange",
|
||||
calendar_id=self.calendar.id,
|
||||
endpoint_url="https://groupware.example.test/caldav/team/",
|
||||
connector_profile_ref="connector:ox-main",
|
||||
identity_mapping_ref="idm:ox-main",
|
||||
resource_calendar_ref="ox-resource:room-42",
|
||||
),
|
||||
)
|
||||
|
||||
source = self.calendar.metadata_["sync_source_id"]
|
||||
self.session.refresh(self.calendar)
|
||||
self.assertEqual(configured.source_id, source)
|
||||
self.assertEqual("caldav", configured.transport_kind)
|
||||
self.assertEqual("resource", self.calendar.owner_type)
|
||||
self.assertEqual("ox-resource:room-42", self.calendar.owner_id)
|
||||
self.assertEqual("open_xchange", self.calendar.metadata_["integration_profile"])
|
||||
|
||||
def test_unsupported_profile_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(CalendarCapabilityError, "Unsupported"):
|
||||
self.provider.configure_profile(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
request=CalendarExternalProfileRequest(
|
||||
profile_kind="unknown",
|
||||
calendar_id=self.calendar.id,
|
||||
endpoint_url="https://groupware.example.test/caldav/team/",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CalendarInvitationCapabilityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
@@ -13,6 +13,7 @@ from govoplan_calendar.backend.manifest import (
|
||||
EWS_PROVIDER_ID,
|
||||
GRAPH_PROVIDER_ID,
|
||||
ICS_PROVIDER_ID,
|
||||
OPEN_XCHANGE_PROVIDER_ID,
|
||||
manifest,
|
||||
)
|
||||
from govoplan_calendar.backend.provider_state import (
|
||||
@@ -20,6 +21,7 @@ from govoplan_calendar.backend.provider_state import (
|
||||
ews_provider_states,
|
||||
graph_provider_states,
|
||||
ics_provider_states,
|
||||
open_xchange_provider_states,
|
||||
)
|
||||
from govoplan_calendar.backend.schemas import (
|
||||
CalendarCalDavSourceCreateRequest,
|
||||
@@ -117,11 +119,11 @@ class CalendarProviderStateTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
{CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID},
|
||||
{CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID, OPEN_XCHANGE_PROVIDER_ID},
|
||||
{item.id for item in manifest.external_providers},
|
||||
)
|
||||
self.assertEqual(
|
||||
{CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID},
|
||||
{CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID, OPEN_XCHANGE_PROVIDER_ID},
|
||||
{
|
||||
item.provider_id
|
||||
for item in manifest.external_provider_state_providers
|
||||
@@ -169,6 +171,30 @@ class CalendarProviderStateTests(unittest.TestCase):
|
||||
self.assertNotIn("feeds.example.test", rendered)
|
||||
self.assertNotIn("exchange.example.test", rendered)
|
||||
|
||||
def test_open_xchange_profile_has_distinct_provider_state(self) -> None:
|
||||
self.source.metadata_ = {
|
||||
"integration_profile": "open_xchange",
|
||||
"connector_profile_ref": "connector:secret-detail",
|
||||
"identity_mapping_ref": "idm:secret-detail",
|
||||
}
|
||||
self.source.last_status = "ok"
|
||||
self.source.last_synced_at = datetime.now(UTC)
|
||||
self.session.flush()
|
||||
|
||||
self.assertEqual(
|
||||
(),
|
||||
caldav_provider_states(
|
||||
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
|
||||
),
|
||||
)
|
||||
state = open_xchange_provider_states(
|
||||
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
|
||||
)[0]
|
||||
|
||||
self.assertEqual(OPEN_XCHANGE_PROVIDER_ID, state.provider_id)
|
||||
self.assertEqual("healthy", state.health)
|
||||
self.assertNotIn("secret-detail", str(state.to_dict()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -42,10 +42,11 @@ import {
|
||||
normalizeHexColor,
|
||||
} from "./calendarViewModel";
|
||||
|
||||
export type CalendarSourceMode = "local" | CalendarSyncSourceKind;
|
||||
export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange";
|
||||
type CalendarSourceSwitchMode =
|
||||
| "local"
|
||||
| "caldav"
|
||||
| "open_xchange"
|
||||
| "ics"
|
||||
| "graph"
|
||||
| "ews";
|
||||
@@ -75,6 +76,9 @@ export type CalendarCalDavFormPayload = {
|
||||
sync_interval_seconds: number;
|
||||
sync_direction: CalendarCalDavSyncDirection;
|
||||
conflict_policy: CalendarCalDavConflictPolicy;
|
||||
connector_profile_ref: string;
|
||||
identity_mapping_ref: string;
|
||||
resource_calendar_ref: string;
|
||||
};
|
||||
export type CalendarCollectionFormPayload = {
|
||||
sourceMode: CalendarSourceMode;
|
||||
@@ -115,7 +119,7 @@ export function CalendarCollectionDialog({
|
||||
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
|
||||
const calendar = state.kind === "edit" ? state.calendar : null;
|
||||
const isEdit = Boolean(calendar);
|
||||
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
|
||||
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
|
||||
const [name, setName] = useState(calendar?.name ?? "");
|
||||
const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR);
|
||||
const [davUrl, setDavUrl] = useState(source?.collection_url ?? "");
|
||||
@@ -132,6 +136,9 @@ export function CalendarCollectionDialog({
|
||||
const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60)));
|
||||
const [syncDirection, setSyncDirection] = useState<CalendarCalDavSyncDirection>(source?.sync_direction ?? "two_way");
|
||||
const [conflictPolicy, setConflictPolicy] = useState<CalendarCalDavConflictPolicy>(source?.conflict_policy ?? "etag");
|
||||
const [connectorProfileRef, setConnectorProfileRef] = useState(metadataValue(source?.metadata, "connector_profile_ref"));
|
||||
const [identityMappingRef, setIdentityMappingRef] = useState(metadataValue(source?.metadata, "identity_mapping_ref"));
|
||||
const [resourceCalendarRef, setResourceCalendarRef] = useState(metadataValue(source?.metadata, "resource_calendar_ref"));
|
||||
const [discoveredCalendars, setDiscoveredCalendars] = useState<CalendarCalDavDiscoveryCandidate[]>([]);
|
||||
const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? "");
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
@@ -173,7 +180,10 @@ export function CalendarCollectionDialog({
|
||||
syncEnabled,
|
||||
syncIntervalMinutes,
|
||||
syncDirection,
|
||||
conflictPolicy
|
||||
conflictPolicy,
|
||||
connectorProfileRef,
|
||||
identityMappingRef,
|
||||
resourceCalendarRef
|
||||
};
|
||||
const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []);
|
||||
const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey;
|
||||
@@ -223,7 +233,10 @@ export function CalendarCollectionDialog({
|
||||
sync_enabled: syncEnabled,
|
||||
sync_interval_seconds: syncIntervalMinutes * 60,
|
||||
sync_direction: syncDirection,
|
||||
conflict_policy: conflictPolicy
|
||||
conflict_policy: conflictPolicy,
|
||||
connector_profile_ref: connectorProfileRef,
|
||||
identity_mapping_ref: identityMappingRef,
|
||||
resource_calendar_ref: resourceCalendarRef
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -259,7 +272,8 @@ export function CalendarCollectionDialog({
|
||||
setSyncDirection("inbound");
|
||||
return;
|
||||
}
|
||||
if (next === "caldav") {
|
||||
if (next === "caldav" || next === "open_xchange") {
|
||||
if (next === "open_xchange") setAuthType("basic");
|
||||
setSyncDirection("two_way");
|
||||
}
|
||||
}
|
||||
@@ -355,6 +369,7 @@ export function CalendarCollectionDialog({
|
||||
options={[
|
||||
{ id: "local", label: "i18n:govoplan-calendar.local.dc99d54d" },
|
||||
{ id: "caldav", label: "i18n:govoplan-calendar.caldav.64f9720e", disabled: !canManageSources },
|
||||
{ id: "open_xchange", label: "i18n:govoplan-calendar.open_xchange.637e5b7b", disabled: !canManageSources },
|
||||
{ id: "ics", label: "i18n:govoplan-calendar.ics_webcal.9c55b570", disabled: !canManageSources },
|
||||
{ id: "graph", label: "i18n:govoplan-calendar.graph.9a7405eb", disabled: !canManageSources },
|
||||
{ id: "ews", label: "i18n:govoplan-calendar.exchange.5b13eac7", disabled: !canManageSources }
|
||||
@@ -455,8 +470,24 @@ export function CalendarCollectionDialog({
|
||||
<PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
|
||||
</label>
|
||||
}
|
||||
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
|
||||
{sourceMode === "caldav" &&
|
||||
{sourceMode === "open_xchange" &&
|
||||
<>
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.connector_profile_reference.e7697602</span>
|
||||
<input value={connectorProfileRef} onChange={(item) => setConnectorProfileRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
|
||||
</label>
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.identity_group_mapping_reference.40c3da81</span>
|
||||
<input value={identityMappingRef} onChange={(item) => setIdentityMappingRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
|
||||
</label>
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.resource_calendar_reference.4df67054</span>
|
||||
<input value={resourceCalendarRef} onChange={(item) => setResourceCalendarRef(item.target.value)} maxLength={255} disabled={saving || !canEditSource} />
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
<div className={calendarSourceUsesCalDav(sourceMode) ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
|
||||
{calendarSourceUsesCalDav(sourceMode) &&
|
||||
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}>
|
||||
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
|
||||
</Button>
|
||||
@@ -465,7 +496,7 @@ export function CalendarCollectionDialog({
|
||||
</div>
|
||||
</div>
|
||||
{discoveryError && <p className="calendar-form-error">{discoveryError}</p>}
|
||||
{sourceMode === "caldav" && discoveredCalendars.length > 0 &&
|
||||
{calendarSourceUsesCalDav(sourceMode) && discoveredCalendars.length > 0 &&
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.calendar.adab5090</span>
|
||||
<select value={selectedDiscoveredUrl} onChange={handleDiscoveredCalendarChange} disabled={saving || !canEditSource}>
|
||||
@@ -493,14 +524,14 @@ export function CalendarCollectionDialog({
|
||||
</label>
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.direction.fd8e45ba</span>
|
||||
<select value={sourceMode === "caldav" ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
|
||||
<select value={calendarSourceUsesCalDav(sourceMode) ? syncDirection : "inbound"} onChange={(item) => setSyncDirection(item.target.value as CalendarCalDavSyncDirection)} disabled={saving || !canEditMutableSourceSettings || !calendarSourceUsesCalDav(sourceMode)}>
|
||||
<option value="two_way">i18n:govoplan-calendar.two_way.ee50a3e6</option>
|
||||
<option value="inbound">i18n:govoplan-calendar.inbound_only.bf4269b0</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>i18n:govoplan-calendar.conflict_policy.5810e150</span>
|
||||
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || sourceMode !== "caldav"}>
|
||||
<select value={conflictPolicy} onChange={(item) => setConflictPolicy(item.target.value as CalendarCalDavConflictPolicy)} disabled={saving || !canEditMutableSourceSettings || !calendarSourceUsesCalDav(sourceMode)}>
|
||||
<option value="etag">i18n:govoplan-calendar.require_matching_etag.0ab1ffe1</option>
|
||||
<option value="overwrite">i18n:govoplan-calendar.overwrite_remote.39625e32</option>
|
||||
</select>
|
||||
@@ -667,9 +698,9 @@ export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload:
|
||||
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
|
||||
sync_enabled: payload.sync_enabled,
|
||||
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
|
||||
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
|
||||
sync_direction: calendarSourceUsesCalDav(sourceMode) ? payload.sync_direction : "inbound",
|
||||
conflict_policy: payload.conflict_policy,
|
||||
metadata: {}
|
||||
metadata: calendarSourceMetadata(sourceMode, payload)
|
||||
};
|
||||
if (payload.credential_envelope_id) {
|
||||
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
|
||||
@@ -681,11 +712,19 @@ export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload:
|
||||
return result;
|
||||
}
|
||||
|
||||
export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload {
|
||||
export function syncSourceConnectionUpdatePayload(
|
||||
sourceMode: CalendarSourceMode,
|
||||
payload: CalendarCalDavFormPayload,
|
||||
currentMetadata: Record<string, unknown> | null | undefined,
|
||||
): CalendarSyncSourceUpdatePayload {
|
||||
const result: CalendarSyncSourceUpdatePayload = {
|
||||
collection_url: payload.collection_url.trim(),
|
||||
auth_type: payload.auth_type,
|
||||
username: payload.auth_type === "basic" ? payload.username.trim() || null : null
|
||||
username: payload.auth_type === "basic" ? payload.username.trim() || null : null,
|
||||
metadata: {
|
||||
...(currentMetadata ?? {}),
|
||||
...calendarSourceMetadata(sourceMode, payload)
|
||||
}
|
||||
};
|
||||
if (payload.credential_envelope_id) {
|
||||
result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
|
||||
@@ -703,6 +742,7 @@ function credentialEnvelopeRef(credentialId: string): string {
|
||||
|
||||
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
|
||||
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
|
||||
if (sourceMode === "open_xchange") return "caldav";
|
||||
return sourceMode === "local" ? "caldav" : sourceMode;
|
||||
}
|
||||
|
||||
@@ -718,6 +758,7 @@ function syncTransientPayload(authType: CalendarCalDavAuthType, password: string
|
||||
|
||||
function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
|
||||
if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a";
|
||||
if (sourceMode === "open_xchange") return "i18n:govoplan-calendar.open_xchange_source.4cc03862";
|
||||
if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63";
|
||||
if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383";
|
||||
if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3";
|
||||
@@ -726,6 +767,7 @@ function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string {
|
||||
|
||||
function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
|
||||
if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180";
|
||||
if (sourceMode === "open_xchange") return "i18n:govoplan-calendar.open_xchange_dav_url.9d1adeaf";
|
||||
if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3";
|
||||
if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983";
|
||||
return "i18n:govoplan-calendar.subscription_url.b8b6a1a0";
|
||||
@@ -733,11 +775,41 @@ function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string {
|
||||
|
||||
function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string {
|
||||
if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav";
|
||||
if (sourceMode === "open_xchange") return "https://groupware.example.org/caldav/";
|
||||
if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta";
|
||||
if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx";
|
||||
return "webcal://example.org/calendar.ics or https://example.org/calendar.ics";
|
||||
}
|
||||
|
||||
function calendarSourceUsesCalDav(sourceMode: CalendarSourceMode): boolean {
|
||||
return sourceMode === "caldav" || sourceMode === "open_xchange";
|
||||
}
|
||||
|
||||
function calendarSourceModeForSource(source: CalendarSyncSource): CalendarSourceMode {
|
||||
return source.source_kind === "caldav" && metadataValue(source.metadata, "integration_profile") === "open_xchange"
|
||||
? "open_xchange"
|
||||
: source.source_kind;
|
||||
}
|
||||
|
||||
function calendarSourceMetadata(
|
||||
sourceMode: CalendarSourceMode,
|
||||
payload: CalendarCalDavFormPayload,
|
||||
): Record<string, unknown> {
|
||||
if (sourceMode !== "open_xchange") return {};
|
||||
return {
|
||||
integration_profile: "open_xchange",
|
||||
transport_kind: "caldav",
|
||||
connector_profile_ref: payload.connector_profile_ref.trim() || null,
|
||||
identity_mapping_ref: payload.identity_mapping_ref.trim() || null,
|
||||
resource_calendar_ref: payload.resource_calendar_ref.trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
function metadataValue(metadata: Record<string, unknown> | null | undefined, key: string): string {
|
||||
const value = metadata?.[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function calendarDeleteActionLabel(calendar: CalendarCollection): string {
|
||||
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48";
|
||||
}
|
||||
|
||||
@@ -327,7 +327,15 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR
|
||||
});
|
||||
if (source && canDeleteCalendars) {
|
||||
const updatedSource = await updateSyncSource(settings, source.id, syncSourceConnectionUpdatePayload(payload.caldav));
|
||||
const updatedSource = await updateSyncSource(
|
||||
settings,
|
||||
source.id,
|
||||
syncSourceConnectionUpdatePayload(
|
||||
payload.sourceMode,
|
||||
payload.caldav,
|
||||
source.metadata,
|
||||
),
|
||||
);
|
||||
setSyncSources((current) => current.map((item) => item.id === source.id ? updatedSource : item));
|
||||
reloadSources = true;
|
||||
}
|
||||
@@ -336,7 +344,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
const calendar = await createCalendar(settings, {
|
||||
name,
|
||||
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
owner_type: payload.sourceMode === "open_xchange" && payload.caldav.resource_calendar_ref.trim() ? "resource" : "tenant",
|
||||
owner_id: payload.sourceMode === "open_xchange" ? payload.caldav.resource_calendar_ref.trim() || null : null
|
||||
});
|
||||
let createdSource: CalendarSyncSource | null = null;
|
||||
if (payload.sourceMode !== "local") {
|
||||
|
||||
@@ -38,6 +38,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
|
||||
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
||||
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
||||
"i18n:govoplan-calendar.connector_profile_reference.e7697602": "Connector profile reference",
|
||||
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
||||
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
||||
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
||||
@@ -83,6 +84,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
|
||||
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
|
||||
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
|
||||
"i18n:govoplan-calendar.identity_group_mapping_reference.40c3da81": "Identity/group mapping reference",
|
||||
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
|
||||
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
|
||||
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
|
||||
@@ -120,6 +122,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
|
||||
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
|
||||
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
|
||||
"i18n:govoplan-calendar.open_xchange_dav_url.9d1adeaf": "Open-Xchange DAV URL",
|
||||
"i18n:govoplan-calendar.open_xchange_source.4cc03862": "Open-Xchange source",
|
||||
"i18n:govoplan-calendar.open_xchange.637e5b7b": "Open-Xchange",
|
||||
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
|
||||
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
|
||||
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
|
||||
@@ -133,6 +138,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
|
||||
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Recurrence",
|
||||
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
|
||||
"i18n:govoplan-calendar.resource_calendar_reference.4df67054": "Resource calendar reference",
|
||||
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
|
||||
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
|
||||
"i18n:govoplan-calendar.related.917df91e": "Related",
|
||||
@@ -226,6 +232,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
|
||||
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
|
||||
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
|
||||
"i18n:govoplan-calendar.connector_profile_reference.e7697602": "Connector-Profilreferenz",
|
||||
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
|
||||
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
|
||||
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
|
||||
@@ -271,6 +278,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
|
||||
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
|
||||
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
|
||||
"i18n:govoplan-calendar.identity_group_mapping_reference.40c3da81": "Identitäts-/Gruppenzuordnungsreferenz",
|
||||
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
|
||||
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
|
||||
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
|
||||
@@ -308,6 +316,9 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
|
||||
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
|
||||
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
|
||||
"i18n:govoplan-calendar.open_xchange_dav_url.9d1adeaf": "Open-Xchange-DAV-URL",
|
||||
"i18n:govoplan-calendar.open_xchange_source.4cc03862": "Open-Xchange-Quelle",
|
||||
"i18n:govoplan-calendar.open_xchange.637e5b7b": "Open-Xchange",
|
||||
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
|
||||
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
|
||||
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
|
||||
@@ -321,6 +332,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
|
||||
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Wiederholung",
|
||||
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
|
||||
"i18n:govoplan-calendar.resource_calendar_reference.4df67054": "Ressourcenkalenderreferenz",
|
||||
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
|
||||
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
|
||||
"i18n:govoplan-calendar.related.917df91e": "Related",
|
||||
|
||||
Reference in New Issue
Block a user