from __future__ import annotations import json import re import uuid import os import urllib.parse import urllib.error import urllib.request from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any, Callable from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from defusedxml import ElementTree as SafeElementTree from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource from govoplan_calendar.backend.ical import events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents from govoplan_calendar.backend.runtime import get_registry from govoplan_calendar.backend.schemas import ( CalendarCalDavDiscoveryRequest, CalendarCalDavSourceCreateRequest, CalendarCalDavSourceUpdateRequest, CalendarCollectionCreateRequest, CalendarCollectionDeleteRequest, CalendarCollectionUpdateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest, CalendarSyncSourceCreateRequest, CalendarSyncSourceUpdateRequest, ) from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider from govoplan_core.db.base import utcnow from govoplan_core.core.change_sequence import record_change from govoplan_core.security.secrets import CAPABILITY_SECURITY_SECRET_PROVIDER, decrypt_secret, encrypt_secret class CalendarError(ValueError): pass CALDAV_INTERNAL_CREDENTIAL_PREFIX = "calendar-sync-credential:" CALDAV_ENV_CREDENTIAL_PREFIX = "env:" CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900 SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"} READ_ONLY_SYNC_SOURCE_KINDS = {"ics", "webcal", "graph", "ews"} GRAPH_DEFAULT_BASE_URL = "https://graph.microsoft.com/v1.0/" EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/" EWS_MESSAGES_NS = "http://schemas.microsoft.com/exchange/services/2006/messages" EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types" CALENDAR_MODULE_ID = "calendar" CALENDAR_EVENTS_COLLECTION = "calendar.events" CALENDAR_EVENT_RESOURCE = "calendar_event" SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500 def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]: return { f"{prefix}calendar_id": event.calendar_id, f"{prefix}start_at": response_datetime(event.start_at).isoformat() if event.start_at else None, f"{prefix}end_at": response_datetime(event.end_at).isoformat() if event.end_at else None, f"{prefix}summary": event.summary, f"{prefix}status": event.status, } def record_calendar_event_change( session: Session, *, event: CalendarEvent, operation: str, user_id: str | None, previous: dict[str, Any] | None = None, ) -> None: payload = calendar_event_change_payload(event) if previous: payload.update(previous) record_change( session, module_id=CALENDAR_MODULE_ID, collection=CALENDAR_EVENTS_COLLECTION, resource_type=CALENDAR_EVENT_RESOURCE, resource_id=event.id, operation=operation, tenant_id=event.tenant_id, actor_type="user" if user_id else "system", actor_id=user_id, payload=payload, ) @dataclass(slots=True) class CalendarCalDavSyncStats: created: int = 0 updated: int = 0 deleted: int = 0 unchanged: int = 0 fetched: int = 0 full_sync: bool = False used_sync_token: bool = False sync_token: str | None = None ctag: str | None = None errors: list[str] = field(default_factory=list) @dataclass(slots=True) class CalendarCalDavDueSyncResult: source_id: str calendar_id: str status: str stats: CalendarCalDavSyncStats | None = None error: str | None = None def slugify(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug or "calendar" def normalize_source_kind(value: str) -> str: source_kind = (value or "").strip().lower() if source_kind not in SYNC_SOURCE_KINDS: raise CalendarError(f"Unsupported calendar sync source kind: {value}") return source_kind def normalize_sync_source_url(source_kind: str, value: str) -> str: url = value.strip() if not url: raise CalendarError("Calendar sync source URL is required") if source_kind == "caldav": return ensure_collection_url(url) if source_kind == "webcal": if url.lower().startswith("webcal://"): url = "https://" + url[len("webcal://"):] return validate_http_url(url, label="webcal URL") if source_kind == "ics": if url.lower().startswith("webcal://"): return normalize_sync_source_url("webcal", url) return validate_http_url(url, label="ICS URL") if source_kind == "graph": return normalize_graph_collection_url(url) if source_kind == "ews": return validate_http_url(url, label="Exchange Web Services URL") return url def validate_http_url(url: str, *, label: str) -> str: parsed = urllib.parse.urlparse(url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise CalendarError(f"{label} must be an absolute HTTP(S) URL") if parsed.username or parsed.password: raise CalendarError(f"{label} must not include embedded credentials") return urllib.parse.urlunparse(parsed) def normalize_graph_collection_url(value: str) -> str: url = value.strip() if url.startswith("/"): url = url[1:] parsed = urllib.parse.urlparse(url) if parsed.scheme: return validate_http_url(url, label="Microsoft Graph calendar URL") if url in {"me/calendar", "me/calendar/events", "me/calendar/events/delta"}: path = "me/calendar/events/delta" elif url in {"me/events", "me/events/delta"}: path = "me/events/delta" elif url.endswith("/events/delta") or url.endswith("/calendarView/delta"): path = url elif url.endswith("/events"): path = f"{url}/delta" else: path = f"{url.rstrip('/')}/events/delta" return urllib.parse.urljoin(GRAPH_DEFAULT_BASE_URL, path) def sync_source_label(source_kind: str) -> str: return { "caldav": "CalDAV", "ics": "ICS", "webcal": "webcal", "graph": "Microsoft Graph", "ews": "Exchange Web Services", }.get(source_kind, source_kind) def _sync_stats_payload(stats: CalendarCalDavSyncStats | None) -> dict[str, object]: if stats is None: return {} return { "created": stats.created, "updated": stats.updated, "deleted": stats.deleted, "unchanged": stats.unchanged, "fetched": stats.fetched, "full_sync": stats.full_sync, "used_sync_token": stats.used_sync_token, "errors": list(stats.errors), } def _emit_calendar_sync_notification( session: Session, *, source: CalendarSyncSource, status: str, previous_status: str | None, stats: CalendarCalDavSyncStats | None = None, error: str | None = None, ) -> None: if status == "ok" and previous_status != "error": return if status == "error" and previous_status == "error": return provider = notification_dispatch_provider(get_registry()) if provider is None: return label = source.display_name or sync_source_label(source.source_kind) calendar_name = source.calendar.name if source.calendar else "calendar" if status == "ok": subject = f"Calendar sync recovered: {calendar_name}" body_text = f"{label} synchronized successfully after a previous failure." priority = 1 else: subject = f"Calendar sync failed: {calendar_name}" body_text = f"{label} could not be synchronized. {error or 'No error detail was provided.'}" priority = 5 try: provider.enqueue_notification( session, NotificationDispatchRequest( tenant_id=source.tenant_id, source_module=CALENDAR_MODULE_ID, source_resource_type="calendar_sync_source", source_resource_id=source.id, event_kind=f"calendar.sync.{status}", channel="inbox", subject=subject, body_text=body_text, action_url="/calendar", priority=priority, payload={ "calendar_id": source.calendar_id, "source_id": source.id, "source_kind": source.source_kind, "status": status, "previous_status": previous_status, "error": error, "stats": _sync_stats_payload(stats), }, metadata={"calendar_id": source.calendar_id, "source_kind": source.source_kind}, ), enqueue_delivery=False, ) except Exception: return def _finalize_sync_success( session: Session, *, source: CalendarSyncSource, stats: CalendarCalDavSyncStats | None = None, mark_calendar: Callable[[CalendarCollection, CalendarSyncSource], None] | None = None, update_stats_tokens: bool = False, ) -> None: source.last_synced_at = utcnow() source.last_status = "ok" source.last_error = None schedule_next_caldav_sync(source) if stats is not None and update_stats_tokens: stats.sync_token = source.sync_token stats.ctag = source.ctag if mark_calendar is not None and source.calendar is not None: mark_calendar(source.calendar, source) session.flush() def _finalize_sync_error(session: Session, *, source: CalendarSyncSource, error: BaseException) -> None: source.last_attempt_at = utcnow() source.last_status = "error" source.last_error = str(error) schedule_next_caldav_sync(source) session.flush() def ensure_default_calendar(session: Session, *, tenant_id: str, user_id: str | None = None) -> CalendarCollection: existing = ( session.query(CalendarCollection) .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.is_default.is_(True), CalendarCollection.deleted_at.is_(None)) .order_by(CalendarCollection.created_at.asc()) .first() ) if existing: return existing calendar = CalendarCollection( tenant_id=tenant_id, slug="default", name="Calendar", timezone="UTC", color="#0f766e", owner_type="tenant", owner_id=None, visibility="tenant", is_default=True, created_by_user_id=user_id, metadata_={}, ) session.add(calendar) session.flush() return calendar def get_default_calendar(session: Session, *, tenant_id: str) -> CalendarCollection | None: return ( session.query(CalendarCollection) .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.is_default.is_(True), CalendarCollection.deleted_at.is_(None)) .order_by(CalendarCollection.created_at.asc()) .first() ) def list_calendars(session: Session, *, tenant_id: str, ensure_default: bool = False, user_id: str | None = None) -> list[CalendarCollection]: if ensure_default: ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id) return ( session.query(CalendarCollection) .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)) .order_by(CalendarCollection.is_default.desc(), CalendarCollection.name.asc()) .all() ) def create_calendar(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCollectionCreateRequest) -> CalendarCollection: slug = payload.slug or slugify(payload.name) if calendar_slug_exists(session, tenant_id=tenant_id, slug=slug): raise CalendarError(f"Calendar slug already exists: {slug}") if payload.is_default: clear_default_calendar(session, tenant_id=tenant_id) calendar = CalendarCollection( tenant_id=tenant_id, slug=slug, name=payload.name, description=payload.description, timezone=payload.timezone, color=payload.color, owner_type=payload.owner_type, owner_id=payload.owner_id, visibility=payload.visibility, is_default=payload.is_default, created_by_user_id=user_id, metadata_=payload.metadata, ) session.add(calendar) session.flush() return calendar def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionUpdateRequest) -> CalendarCollection: calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) if payload.is_default is True: clear_default_calendar(session, tenant_id=tenant_id) calendar.is_default = True elif payload.is_default is False: calendar.is_default = False for attr in ("name", "description", "timezone", "color", "visibility"): value = getattr(payload, attr) if value is not None: setattr(calendar, attr, value) if payload.metadata is not None: calendar.metadata_ = payload.metadata session.flush() return calendar def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None: payload = payload or CalendarCollectionDeleteRequest() calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) deleted_at = utcnow() if payload.event_action == "move": if not payload.target_calendar_id: raise CalendarError("Target calendar is required when moving events") if payload.target_calendar_id == calendar_id: raise CalendarError("Target calendar must be different from the deleted calendar") target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id) for event in calendar.events: if event.deleted_at is None: event.calendar_id = target_calendar.id if payload.make_target_default: clear_default_calendar(session, tenant_id=tenant_id) target_calendar.is_default = True elif payload.event_action != "delete": raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}") if calendar.is_default: calendar.is_default = False calendar.deleted_at = deleted_at retire_sync_sources_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar.id, deleted_at=deleted_at) for event in calendar.events: if payload.event_action == "delete" and event.deleted_at is None: event.deleted_at = deleted_at session.flush() def get_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarCollection: calendar = ( session.query(CalendarCollection) .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.id == calendar_id, CalendarCollection.deleted_at.is_(None)) .first() ) if not calendar: raise CalendarError("Calendar not found") return calendar def calendar_slug_exists(session: Session, *, tenant_id: str, slug: str) -> bool: return ( session.query(CalendarCollection.id) .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.slug == slug, CalendarCollection.deleted_at.is_(None)) .first() is not None ) def clear_default_calendar(session: Session, *, tenant_id: str) -> None: for calendar in ( session.query(CalendarCollection) .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.is_default.is_(True), CalendarCollection.deleted_at.is_(None)) .all() ): calendar.is_default = False def list_sync_sources(session: Session, *, tenant_id: str, calendar_id: str | None = None, source_kind: str | None = None) -> list[CalendarSyncSource]: query = session.query(CalendarSyncSource).join(CalendarCollection, CalendarCollection.id == CalendarSyncSource.calendar_id).filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None), CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None), ) if source_kind: query = query.filter(CalendarSyncSource.source_kind == source_kind) if calendar_id: query = query.filter(CalendarSyncSource.calendar_id == calendar_id) return query.order_by(CalendarSyncSource.source_kind.asc(), CalendarSyncSource.display_name.asc(), CalendarSyncSource.created_at.asc()).all() def list_caldav_sources(session: Session, *, tenant_id: str, calendar_id: str | None = None) -> list[CalendarSyncSource]: return list_sync_sources(session, tenant_id=tenant_id, calendar_id=calendar_id, source_kind="caldav") def get_sync_source(session: Session, *, tenant_id: str, source_id: str, source_kind: str | None = None) -> CalendarSyncSource: source = ( session.query(CalendarSyncSource) .filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.id == source_id, CalendarSyncSource.deleted_at.is_(None), ) .first() ) if source is not None and source_kind is not None and source.source_kind != source_kind: source = None if not source: raise CalendarError("Calendar sync source not found") return source def get_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> CalendarSyncSource: try: return get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") except CalendarError as exc: raise CalendarError("CalDAV sync source not found") from exc def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: CalendarCalDavDiscoveryRequest) -> list[dict[str, Any]]: source = get_caldav_source(session, tenant_id=tenant_id, source_id=payload.source_id) if payload.source_id else None auth_type = payload.auth_type or (source.auth_type if source else "none") username = payload.username if payload.username is not None else (source.username if source else None) credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None) secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token) if secret is None and credential_ref: secret = resolve_caldav_credential_ref(session, tenant_id=tenant_id, credential_ref=credential_ref) if secret is None and source is not None: secret = resolve_caldav_secret(session, source=source) if auth_type == "basic": if not username: raise CalendarError("CalDAV discovery with basic auth requires a username") if not secret: raise CalendarError("CalDAV discovery with basic auth requires a password or credential reference") client = CalDAVClient(collection_url=payload.url, username=username, password=secret) elif auth_type == "bearer": if not secret: raise CalendarError("CalDAV discovery with bearer auth requires a token or credential reference") client = CalDAVClient(collection_url=payload.url, bearer_token=secret) else: client = CalDAVClient(collection_url=payload.url) return [ { "collection_url": calendar.collection_url, "href": calendar.href, "display_name": calendar.display_name, "color": calendar.color, "ctag": calendar.ctag, "sync_token": calendar.sync_token, } for calendar in client.discover_calendars() ] def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource: calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) source_kind = normalize_source_kind(payload.source_kind) collection_url = normalize_sync_source_url(source_kind, payload.collection_url) retire_stale_sync_sources_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url) existing = active_sync_source_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url) if existing is not None: existing_calendar = existing.calendar calendar_name = existing_calendar.name if existing_calendar and existing_calendar.deleted_at is None else "another calendar" raise CalendarError(f"{sync_source_label(source_kind)} source is already linked to {calendar_name}") sync_direction = "inbound" if source_kind in READ_ONLY_SYNC_SOURCE_KINDS else payload.sync_direction if source_kind == "graph" and payload.auth_type != "bearer": raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication") if source_kind in {"ics", "webcal"} and payload.auth_type not in {"none", "basic", "bearer"}: raise CalendarError("ICS/webcal subscriptions support none, basic, or bearer authentication") if source_kind == "ews" and payload.auth_type not in {"basic", "bearer"}: raise CalendarError("Exchange Web Services sync requires basic or bearer authentication") source = CalendarSyncSource( tenant_id=tenant_id, calendar_id=calendar.id, source_kind=source_kind, collection_url=collection_url, display_name=payload.display_name, auth_type=payload.auth_type, username=payload.username, credential_ref=payload.credential_ref, sync_enabled=payload.sync_enabled, sync_interval_seconds=payload.sync_interval_seconds, sync_direction=sync_direction, conflict_policy=payload.conflict_policy, metadata_=payload.metadata, ) session.add(source) session.flush() credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token) if credential_value is not None: source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=user_id, source=source, secret=credential_value) source.next_sync_at = utcnow() if source.sync_enabled else None mark_calendar_sync_source(calendar, source) session.flush() return source def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCalDavSourceCreateRequest) -> CalendarSyncSource: return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload) def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) if payload.calendar_id is not None: calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) source.calendar_id = calendar.id else: calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) for field_name in ( "display_name", "auth_type", "username", "credential_ref", "sync_enabled", "sync_interval_seconds", "sync_direction", "conflict_policy", "sync_token", "ctag", ): value = getattr(payload, field_name) if value is not None: setattr(source, field_name, value) if payload.collection_url is not None: source.collection_url = normalize_sync_source_url(source.source_kind, payload.collection_url) if payload.metadata is not None: source.metadata_ = payload.metadata if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS: source.sync_direction = "inbound" if source.source_kind == "graph" and source.auth_type != "bearer": raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication") if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}: raise CalendarError("Exchange Web Services sync requires basic or bearer authentication") credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token) if credential_value is not None: source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=None, source=source, secret=credential_value) if payload.sync_enabled is not None or payload.sync_interval_seconds is not None: source.next_sync_at = utcnow() if source.sync_enabled else None mark_calendar_sync_source(calendar, source) session.flush() return source def update_caldav_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarCalDavSourceUpdateRequest) -> CalendarSyncSource: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") return update_sync_source(session, tenant_id=tenant_id, source_id=source.id, payload=payload) def delete_sync_source(session: Session, *, tenant_id: str, source_id: str) -> None: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) session.flush() def delete_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> None: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) session.flush() def retire_sync_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: if not hasattr(session, "query"): return sources = ( session.query(CalendarSyncSource) .filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.calendar_id == calendar_id, CalendarSyncSource.deleted_at.is_(None), ) .all() ) for source in sources: retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=deleted_at) def retire_caldav_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: retire_sync_sources_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id, deleted_at=deleted_at) def retire_stale_sync_sources_for_url(session: Session, *, tenant_id: str, source_kind: str, collection_url: str) -> None: sources = ( session.query(CalendarSyncSource) .outerjoin(CalendarCollection, CalendarCollection.id == CalendarSyncSource.calendar_id) .filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.source_kind == source_kind, CalendarSyncSource.collection_url == collection_url, CalendarSyncSource.deleted_at.is_(None), ) .all() ) now = utcnow() for source in sources: if source.calendar is None or source.calendar.deleted_at is not None: retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=now) session.flush() def retire_stale_caldav_sources_for_url(session: Session, *, tenant_id: str, collection_url: str) -> None: retire_stale_sync_sources_for_url(session, tenant_id=tenant_id, source_kind="caldav", collection_url=collection_url) def active_sync_source_for_url(session: Session, *, tenant_id: str, source_kind: str, collection_url: str) -> CalendarSyncSource | None: return ( session.query(CalendarSyncSource) .join(CalendarCollection, CalendarCollection.id == CalendarSyncSource.calendar_id) .filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.source_kind == source_kind, CalendarSyncSource.collection_url == collection_url, CalendarSyncSource.deleted_at.is_(None), CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None), ) .order_by(CalendarSyncSource.created_at.asc()) .first() ) def active_caldav_source_for_url(session: Session, *, tenant_id: str, collection_url: str) -> CalendarSyncSource | None: return active_sync_source_for_url(session, tenant_id=tenant_id, source_kind="caldav", collection_url=collection_url) def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: source.deleted_at = deleted_at delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=deleted_at) def caldav_secret_from_payload(*, auth_type: str, password: Any | None, bearer_token: Any | None) -> str | None: if auth_type == "basic" and password is not None: return secret_value(password) if auth_type == "bearer" and bearer_token is not None: return secret_value(bearer_token) if auth_type == "none": return None return None def secret_value(value: Any) -> str: if hasattr(value, "get_secret_value"): return str(value.get_secret_value()) return str(value) def store_caldav_credential( session: Session, *, tenant_id: str, user_id: str | None, source: CalendarSyncSource, secret: str, ) -> str: provider = secret_provider() name = f"caldav:{source.id}:{source.auth_type}" if provider is not None: return str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret)) existing = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) if existing is None: existing = CalendarSyncCredential( tenant_id=tenant_id, credential_kind=credential_kind_for_auth_type(source.auth_type), label=source.display_name or source.collection_url, created_by_user_id=user_id, metadata_={"source_id": source.id}, ) session.add(existing) session.flush() existing.credential_kind = credential_kind_for_auth_type(source.auth_type) existing.label = source.display_name or source.collection_url existing.secret_encrypted = encrypt_secret(secret) existing.deleted_at = None existing.metadata_ = {"source_id": source.id} session.flush() return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}" def delete_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> None: if not credential_ref: return provider = secret_provider() if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX) and not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): provider.delete_secret(credential_ref) return credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref) if credential is not None: credential.deleted_at = utcnow() def resolve_caldav_secret( session: Session, *, source: CalendarSyncSource, password: str | None = None, bearer_token: str | None = None, ) -> str | None: if source.auth_type == "none": return None if source.auth_type == "basic" and password: return password if source.auth_type == "bearer" and bearer_token: return bearer_token if not source.credential_ref: return None if source.credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): return os.environ.get(source.credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)) provider = secret_provider() if provider is not None and not source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX): secret = provider.read_secret(source.credential_ref) if secret: return secret credential = internal_caldav_credential(session, tenant_id=source.tenant_id, credential_ref=source.credential_ref) return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None def resolve_caldav_credential_ref(session: Session, *, tenant_id: str, credential_ref: str) -> str | None: if credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): return os.environ.get(credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)) provider = secret_provider() if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX): secret = provider.read_secret(credential_ref) if secret: return secret credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref) return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None def caldav_client_for_source( session: Session, source: CalendarSyncSource, *, password: str | None = None, bearer_token: str | None = None, ) -> CalDAVClient: secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token) if source.auth_type == "basic": if not source.username: raise CalendarError("CalDAV basic auth requires a username") if not secret: raise CalendarError("CalDAV basic auth requires a stored or transient password") return CalDAVClient(collection_url=source.collection_url, username=source.username, password=secret) if source.auth_type == "bearer": if not secret: raise CalendarError("CalDAV bearer auth requires a stored or transient token") return CalDAVClient(collection_url=source.collection_url, bearer_token=secret) return CalDAVClient(collection_url=source.collection_url) def sync_source( session: Session, *, tenant_id: str, user_id: str | None, source_id: str, password: str | None = None, bearer_token: str | None = None, force_full: bool = False, ) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) if source.source_kind == "caldav": return sync_caldav_source( session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full, ) get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) if source.source_kind in {"ics", "webcal"}: return sync_ics_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full) if source.source_kind == "graph": return sync_graph_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, bearer_token=bearer_token, force_full=force_full) if source.source_kind == "ews": return sync_ews_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full) raise CalendarError(f"Unsupported calendar sync source kind: {source.source_kind}") def http_request( url: str, *, method: str = "GET", headers: dict[str, str] | None = None, body: str | bytes | None = None, timeout: int = 30, ) -> tuple[int, dict[str, str], str]: data = body.encode("utf-8") if isinstance(body, str) else body url = validate_http_url(url, label="Calendar source URL") request = urllib.request.Request(url, data=data, method=method, headers=headers or {}) try: with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated admin-configured sync source URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected response_headers = {key.lower(): value for key, value in response.headers.items()} payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace") return int(response.status), response_headers, payload except urllib.error.HTTPError as exc: if exc.code == 304: return 304, {key.lower(): value for key, value in exc.headers.items()}, "" detail = exc.read().decode("utf-8", errors="replace") raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc except urllib.error.URLError as exc: raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc def source_auth_headers( session: Session, source: CalendarSyncSource, *, password: str | None = None, bearer_token: str | None = None, ) -> dict[str, str]: secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token) if source.auth_type == "basic": if not source.username: raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a username") if not secret: raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a stored or transient password") import base64 token = base64.b64encode(f"{source.username}:{secret}".encode("utf-8")).decode("ascii") return {"Authorization": f"Basic {token}"} if source.auth_type == "bearer": if not secret: raise CalendarError(f"{sync_source_label(source.source_kind)} bearer auth requires a stored or transient token") return {"Authorization": f"Bearer {secret}"} return {} def internal_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> CalendarSyncCredential | None: if not credential_ref: return None credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX) return ( session.query(CalendarSyncCredential) .filter( CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.id == credential_id, CalendarSyncCredential.deleted_at.is_(None), ) .first() ) def credential_kind_for_auth_type(auth_type: str) -> str: return "bearer_token" if auth_type == "bearer" else "password" def secret_provider() -> Any | None: registry = get_registry() if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_SECURITY_SECRET_PROVIDER): return None try: return registry.capability(CAPABILITY_SECURITY_SECRET_PROVIDER) except Exception: return None def sync_caldav_source( session: Session, *, tenant_id: str, user_id: str | None, source_id: str, client: CalDAVClient | None = None, password: str | None = None, bearer_token: str | None = None, force_full: bool = False, ) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id) get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) client = client or caldav_client_for_source(session, source, password=password, bearer_token=bearer_token) stats = CalendarCalDavSyncStats() collection_props = CalDAVReportResult() source.last_attempt_at = utcnow() try: try: collection_props = client.propfind_collection() except CalDAVError as exc: stats.errors.append(f"Collection PROPFIND failed; continuing with REPORT: {exc}") if source.sync_token and not force_full: try: report = client.sync_collection(source.sync_token) stats.used_sync_token = True except CalDAVSyncUnsupported as exc: stats.errors.append(f"Sync token REPORT failed; falling back to full sync: {exc}") report = client.list_objects() stats.full_sync = True else: report = client.list_objects() stats.full_sync = True apply_caldav_report(session, source=source, client=client, report=report, stats=stats, user_id=user_id) source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token source.ctag = report.ctag or collection_props.ctag or source.ctag _finalize_sync_success( session, source=source, stats=stats, mark_calendar=mark_calendar_caldav, update_stats_tokens=True, ) return source, stats except Exception as exc: _finalize_sync_error(session, source=source, error=exc) raise def sync_ics_source( session: Session, *, tenant_id: str, user_id: str | None, source_id: str, password: str | None = None, bearer_token: str | None = None, force_full: bool = False, ) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) if source.source_kind not in {"ics", "webcal"}: raise CalendarError("ICS/webcal sync source not found") stats = CalendarCalDavSyncStats(full_sync=True) source.last_attempt_at = utcnow() headers = {"Accept": "text/calendar, application/calendar+json;q=0.5, */*;q=0.1", "User-Agent": "govoplan-calendar-sync/0.1"} headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token)) if source.ctag and not force_full: headers["If-None-Match"] = source.ctag last_modified = (source.metadata_ or {}).get("last_modified") if isinstance(source.metadata_, dict) else None if isinstance(last_modified, str) and last_modified and not force_full: headers["If-Modified-Since"] = last_modified try: status_code, response_headers, body = http_request(source.collection_url, headers=headers) if status_code == 304: _finalize_sync_success(session, source=source, stats=stats) return source, stats stats.fetched = 1 stats.created, stats.updated, stats.unchanged, stats.deleted = import_ics_subscription( session, source=source, ics=body, etag=response_headers.get("etag"), user_id=user_id, ) metadata = dict(source.metadata_ or {}) if response_headers.get("last-modified"): metadata["last_modified"] = response_headers["last-modified"] source.metadata_ = metadata source.ctag = response_headers.get("etag") or source.ctag _finalize_sync_success(session, source=source, stats=stats, mark_calendar=mark_calendar_sync_source) return source, stats except Exception as exc: _finalize_sync_error(session, source=source, error=exc) raise def import_ics_subscription( session: Session, *, source: CalendarSyncSource, ics: str, etag: str | None, user_id: str | None, ) -> tuple[int, int, int, int]: parsed_events = parse_vevents(ics) remaining = { (event.uid, event.recurrence_id): event for event in session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_kind == source.source_kind, CalendarEvent.deleted_at.is_(None), ) .all() } created = updated = unchanged = deleted = 0 changes: list[tuple[CalendarEvent, str, dict[str, Any] | None]] = [] raw_ics = str(parsed_events[0].get("raw_ics") or ics) for parsed in parsed_events: key = (parsed["uid"], parsed.get("recurrence_id")) href = subscription_event_href(source, parsed) event = remaining.get(key) or find_event_for_subscription_component(session, source=source, parsed=parsed) previous = calendar_event_change_payload(event, prefix="previous_") if event else None was_deleted = bool(event and event.deleted_at is not None) if event is None: event = CalendarEvent( tenant_id=source.tenant_id, calendar_id=source.calendar_id, uid=parsed["uid"], recurrence_id=parsed.get("recurrence_id"), created_by_user_id=user_id, ) session.add(event) created += 1 changes.append((event, "created", previous)) else: if was_deleted: created += 1 changes.append((event, "created", previous)) elif event.etag == etag and event.raw_ics == raw_ics: unchanged += 1 else: updated += 1 changes.append((event, "updated", previous)) apply_parsed_event_to_model(event, parsed, source=source, href=href, etag=etag, raw_ics=raw_ics, user_id=user_id) remaining.pop(key, None) deleted_at = utcnow() for event in remaining.values(): previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at deleted += 1 changes.append((event, "deleted", previous)) session.flush() for event, operation, previous in changes: record_calendar_event_change(session, event=event, operation=operation, user_id=user_id, previous=previous) return created, updated, unchanged, deleted def find_event_for_subscription_component(session: Session, *, source: CalendarSyncSource, parsed: dict[str, Any]) -> CalendarEvent | None: return ( session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.uid == parsed["uid"], CalendarEvent.recurrence_id == parsed.get("recurrence_id"), CalendarEvent.source_kind == source.source_kind, ) .order_by(CalendarEvent.deleted_at.is_(None).desc()) .first() ) def subscription_event_href(source: CalendarSyncSource, parsed: dict[str, Any]) -> str: uid = urllib.parse.quote(str(parsed["uid"]), safe="-_.~@") recurrence_id = parsed.get("recurrence_id") if recurrence_id: return f"{source.collection_url}#{uid}/{urllib.parse.quote(str(recurrence_id), safe='-_.~@:')}" return f"{source.collection_url}#{uid}" def sync_graph_source( session: Session, *, tenant_id: str, user_id: str | None, source_id: str, bearer_token: str | None = None, force_full: bool = False, ) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) if source.source_kind != "graph": raise CalendarError("Microsoft Graph sync source not found") headers = {"Accept": "application/json", "User-Agent": "govoplan-calendar-graph/0.1", "Prefer": 'outlook.timezone="UTC"'} headers.update(source_auth_headers(session, source, bearer_token=bearer_token)) stats = CalendarCalDavSyncStats(full_sync=force_full or not bool(source.sync_token), used_sync_token=bool(source.sync_token and not force_full)) source.last_attempt_at = utcnow() try: next_url = source.sync_token if source.sync_token and not force_full else source.collection_url seen_hrefs: set[str] = set() delta_link: str | None = None for _page in range(50): _status, _headers, body = http_request(next_url, headers=headers) payload = json.loads(body or "{}") for item in payload.get("value", []): href = str(item.get("id") or "") if not href: continue if item.get("@removed"): stats.deleted += soft_delete_source_href(session, source=source, href=href) continue seen_hrefs.add(href) created, updated, unchanged = import_provider_event(session, source=source, href=href, provider_event=graph_event_payload(item), user_id=user_id) stats.created += created stats.updated += updated stats.unchanged += unchanged next_link = payload.get("@odata.nextLink") delta_link = payload.get("@odata.deltaLink") or delta_link if not next_link: break next_url = str(next_link) else: raise CalendarError("Microsoft Graph sync returned too many pages") if stats.full_sync: stats.deleted += soft_delete_unseen_source_events(session, source=source, seen_hrefs=seen_hrefs) source.sync_token = delta_link or source.sync_token source.last_synced_at = utcnow() source.last_status = "ok" source.last_error = None schedule_next_caldav_sync(source) mark_calendar_sync_source(source.calendar, source) session.flush() return source, stats except Exception as exc: source.last_attempt_at = utcnow() source.last_status = "error" source.last_error = str(exc) schedule_next_caldav_sync(source) session.flush() raise def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]: start = graph_datetime(item.get("start")) end = graph_datetime(item.get("end")) all_day = bool(item.get("isAllDay")) uid = str(item.get("iCalUId") or item.get("uid") or item.get("id") or uuid.uuid4()) return { "uid": uid, "recurrence_id": str(item.get("id")) if item.get("type") == "occurrence" else None, "sequence": int(item.get("sequence") or 0), "summary": str(item.get("subject") or "(Untitled event)"), "description": graph_body_text(item), "location": graph_location_text(item.get("location")), "status": "CANCELLED" if item.get("isCancelled") else "CONFIRMED", "transparency": "TRANSPARENT" if item.get("showAs") in {"free", "workingElsewhere"} else "OPAQUE", "classification": "PRIVATE" if item.get("sensitivity") == "private" else "PUBLIC", "start_at": start, "end_at": end, "duration_seconds": int((end - start).total_seconds()) if start and end else None, "all_day": all_day, "timezone": ((item.get("start") or {}).get("timeZone") if isinstance(item.get("start"), dict) else None) or "UTC", "organizer": graph_party(item.get("organizer")), "attendees": [graph_party(attendee) for attendee in item.get("attendees") or []], "categories": [str(category) for category in item.get("categories") or []], "rrule": item.get("recurrence") if isinstance(item.get("recurrence"), dict) else None, "rdate": [], "exdate": [], "reminders": graph_reminders(item), "attachments": [], "related_to": [], "etag": item.get("@odata.etag"), "icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "graph", "graph": item}, "metadata": {"graph": item}, } def graph_datetime(value: Any) -> datetime: if not isinstance(value, dict) or not value.get("dateTime"): return utcnow() raw = str(value["dateTime"]).replace("Z", "+00:00") parsed = datetime.fromisoformat(raw) tz_name = str(value.get("timeZone") or "UTC") if parsed.tzinfo is None: try: parsed = parsed.replace(tzinfo=ZoneInfo(tz_name)) except ZoneInfoNotFoundError: parsed = parsed.replace(tzinfo=timezone.utc) return normalize_datetime(parsed) def graph_body_text(item: dict[str, Any]) -> str | None: body = item.get("body") if isinstance(body, dict) and body.get("content"): return str(body["content"]) return str(item.get("bodyPreview")) if item.get("bodyPreview") else None def graph_location_text(value: Any) -> str | None: if isinstance(value, dict): return str(value.get("displayName")) if value.get("displayName") else None return None def graph_party(value: Any) -> dict[str, Any]: if not isinstance(value, dict): return {} email = value.get("emailAddress") if isinstance(value.get("emailAddress"), dict) else value result = { "name": str(email.get("name") or "") if isinstance(email, dict) else "", "email": str(email.get("address") or "") if isinstance(email, dict) else "", } if value.get("type"): result["role"] = value["type"] if value.get("status"): result["status"] = value["status"] return result def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]: if item.get("isReminderOn") and item.get("reminderMinutesBeforeStart") is not None: return [{"action": "DISPLAY", "trigger_minutes_before": int(item["reminderMinutesBeforeStart"])}] return [] def sync_ews_source( session: Session, *, tenant_id: str, user_id: str | None, source_id: str, password: str | None = None, bearer_token: str | None = None, force_full: bool = False, ) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) if source.source_kind != "ews": raise CalendarError("Exchange Web Services sync source not found") metadata = source.metadata_ or {} window_before = int(metadata.get("sync_window_days_before", 365)) if isinstance(metadata, dict) else 365 window_after = int(metadata.get("sync_window_days_after", 730)) if isinstance(metadata, dict) else 730 now = utcnow() start = now - timedelta(days=max(window_before, 0)) end = now + timedelta(days=max(window_after, 1)) headers = { "Accept": "text/xml", "Content-Type": "text/xml; charset=utf-8", "User-Agent": "govoplan-calendar-ews/0.1", } headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token)) stats = CalendarCalDavSyncStats(full_sync=True) source.last_attempt_at = utcnow() try: _status, _headers, body = http_request(source.collection_url, method="POST", headers=headers, body=ews_find_item_body(start=start, end=end, mailbox=metadata.get("mailbox") if isinstance(metadata, dict) else None)) items = parse_ews_calendar_items(body) seen_hrefs: set[str] = set() for item in items: href = item["href"] seen_hrefs.add(href) created, updated, unchanged = import_provider_event(session, source=source, href=href, provider_event=item, user_id=user_id) stats.created += created stats.updated += updated stats.unchanged += unchanged stats.fetched = len(items) stats.deleted += soft_delete_unseen_source_events(session, source=source, seen_hrefs=seen_hrefs) source.sync_token = f"{response_datetime(start).isoformat()}..{response_datetime(end).isoformat()}" source.last_synced_at = utcnow() source.last_status = "ok" source.last_error = None schedule_next_caldav_sync(source) mark_calendar_sync_source(source.calendar, source) session.flush() return source, stats except Exception as exc: source.last_attempt_at = utcnow() source.last_status = "error" source.last_error = str(exc) schedule_next_caldav_sync(source) session.flush() raise def import_provider_event( session: Session, *, source: CalendarSyncSource, href: str, provider_event: dict[str, Any], user_id: str | None, ) -> tuple[int, int, int]: event = find_event_for_source_href(session, source=source, href=href) previous = calendar_event_change_payload(event, prefix="previous_") if event else None was_deleted = bool(event and event.deleted_at is not None) created = updated = unchanged = 0 raw_fingerprint = json.dumps(provider_event.get("metadata") or provider_event.get("icalendar") or provider_event, sort_keys=True, default=str) if event is None: event = CalendarEvent( tenant_id=source.tenant_id, calendar_id=source.calendar_id, uid=str(provider_event["uid"]), recurrence_id=provider_event.get("recurrence_id"), created_by_user_id=user_id, ) session.add(event) created = 1 elif was_deleted: created = 1 elif event.etag == provider_event.get("etag") and event.raw_ics == raw_fingerprint: unchanged = 1 else: updated = 1 for field_name in ( "sequence", "summary", "description", "location", "status", "transparency", "classification", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "organizer", "attendees", "categories", "rrule", "rdate", "exdate", "reminders", "attachments", "related_to", "icalendar", ): if field_name in provider_event: setattr(event, field_name, provider_event[field_name]) event.calendar_id = source.calendar_id event.source_kind = source.source_kind event.source_href = href event.etag = provider_event.get("etag") event.raw_ics = raw_fingerprint event.updated_by_user_id = user_id event.deleted_at = None metadata = dict(event.metadata_ or {}) metadata[source.source_kind] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": event.etag} if isinstance(provider_event.get("metadata"), dict): metadata[source.source_kind].update(provider_event["metadata"]) event.metadata_ = metadata validate_event_time(event) session.flush() if created or updated: record_calendar_event_change(session, event=event, operation="created" if created else "updated", user_id=user_id, previous=previous) return created, updated, unchanged def find_event_for_source_href(session: Session, *, source: CalendarSyncSource, href: str) -> CalendarEvent | None: return ( session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_kind == source.source_kind, CalendarEvent.source_href == href, ) .order_by(CalendarEvent.deleted_at.is_(None).desc()) .first() ) def soft_delete_source_href(session: Session, *, source: CalendarSyncSource, href: str) -> int: deleted_at = utcnow() count = 0 for event in ( session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_kind == source.source_kind, CalendarEvent.source_href == href, CalendarEvent.deleted_at.is_(None), ) .all() ): previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at record_calendar_event_change(session, event=event, operation="deleted", user_id=None, previous=previous) count += 1 return count def soft_delete_unseen_source_events( session: Session, *, source: CalendarSyncSource, seen_hrefs: set[str], source_kind: str | None = None, href_prefix: str | None = None, user_id: str | None = None, batch_size: int = SOURCE_EVENT_CLEANUP_BATCH_SIZE, ) -> int: deleted_at = utcnow() count = 0 last_id: str | None = None effective_source_kind = source_kind or source.source_kind effective_batch_size = max(1, batch_size) while True: id_query = session.query(CalendarEvent.id).filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_kind == effective_source_kind, CalendarEvent.deleted_at.is_(None), ) if href_prefix: id_query = id_query.filter(CalendarEvent.source_href.like(f"{href_prefix}%")) if last_id: id_query = id_query.filter(CalendarEvent.id > last_id) ids = [row[0] for row in id_query.order_by(CalendarEvent.id.asc()).limit(effective_batch_size).all()] if not ids: break last_id = ids[-1] events = ( session.query(CalendarEvent) .filter(CalendarEvent.id.in_(ids)) .order_by(CalendarEvent.id.asc()) .all() ) for event in events: if event.source_href not in seen_hrefs: previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) count += 1 session.flush() return count def ews_find_item_body(*, start: datetime, end: datetime, mailbox: Any | None = None) -> str: start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z") end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z") mailbox_xml = "" if mailbox: mailbox_xml = f"{xml_escape(str(mailbox))}" return f""" AllProperties {mailbox_xml} """ def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]: try: root = SafeElementTree.fromstring(xml_text) except SafeElementTree.ParseError as exc: raise CalendarError(f"Invalid EWS response XML: {exc}") from exc items: list[dict[str, Any]] = [] for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"): item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId") href = item_id.get("Id") if item_id is not None else None if not href: continue change_key = item_id.get("ChangeKey") if item_id is not None else None start = parse_ews_datetime(text_of(item, "Start")) end = parse_ews_datetime(text_of(item, "End")) if text_of(item, "End") else start uid = text_of(item, "UID") or href subject = text_of(item, "Subject") or "(Untitled event)" all_day = text_of(item, "IsAllDayEvent") == "true" free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy" sensitivity = text_of(item, "Sensitivity") or "Normal" body = item.find(f"./{{{EWS_TYPES_NS}}}Body") provider_payload = element_to_dict(item) items.append( { "href": href, "uid": uid, "recurrence_id": href if text_of(item, "CalendarItemType") in {"Occurrence", "Exception"} else None, "sequence": int_or_default(text_of(item, "AppointmentSequenceNumber"), 0), "summary": subject, "description": body.text if body is not None else None, "location": text_of(item, "Location"), "status": "CANCELLED" if text_of(item, "IsCancelled") == "true" else "CONFIRMED", "transparency": "TRANSPARENT" if free_busy.lower() == "free" else "OPAQUE", "classification": "PRIVATE" if sensitivity.lower() == "private" else "PUBLIC", "start_at": start, "end_at": end, "duration_seconds": int((end - start).total_seconds()) if start and end else None, "all_day": all_day, "timezone": "UTC", "organizer": ews_mailbox_record(item.find(f"./{{{EWS_TYPES_NS}}}Organizer/{{{EWS_TYPES_NS}}}Mailbox")), "attendees": ews_attendees(item), "categories": [category.text for category in item.findall(f"./{{{EWS_TYPES_NS}}}Categories/{{{EWS_TYPES_NS}}}String") if category.text], "rrule": None, "rdate": [], "exdate": [], "reminders": ews_reminders(item), "attachments": [], "related_to": [], "etag": change_key, "icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "ews", "ews": provider_payload}, "metadata": {"ews": provider_payload}, } ) return items def parse_ews_datetime(value: str | None) -> datetime: if not value: return utcnow() return normalize_datetime(datetime.fromisoformat(value.replace("Z", "+00:00"))) def text_of(item: Any, name: str) -> str | None: child = item.find(f"./{{{EWS_TYPES_NS}}}{name}") return child.text if child is not None else None def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None: if mailbox is None: return None return { "name": text_of(mailbox, "Name") or "", "email": text_of(mailbox, "EmailAddress") or "", "routing_type": text_of(mailbox, "RoutingType") or "", } def ews_attendees(item: Any) -> list[dict[str, Any]]: attendees: list[dict[str, Any]] = [] for role, path in (("required", "RequiredAttendees"), ("optional", "OptionalAttendees")): for attendee in item.findall(f"./{{{EWS_TYPES_NS}}}{path}/{{{EWS_TYPES_NS}}}Attendee"): mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox") record = ews_mailbox_record(mailbox) or {} record["role"] = role response = text_of(attendee, "ResponseType") if response: record["status"] = response attendees.append(record) return attendees def ews_reminders(item: Any) -> list[dict[str, Any]]: if text_of(item, "ReminderIsSet") == "true": minutes = int_or_default(text_of(item, "ReminderMinutesBeforeStart"), 15) return [{"action": "DISPLAY", "trigger_minutes_before": minutes}] return [] def element_to_dict(element: Any) -> dict[str, Any]: tag = element.tag.rsplit("}", 1)[-1] children = list(element) result: dict[str, Any] = {"tag": tag} if element.attrib: result["attributes"] = dict(element.attrib) if element.text and element.text.strip(): result["text"] = element.text.strip() if children: result["children"] = [element_to_dict(child) for child in children] return result def int_or_default(value: str | None, default: int) -> int: try: return int(value) if value is not None else default except ValueError: return default def xml_escape(value: str) -> str: return value.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) def sync_due_sources( session: Session, *, tenant_id: str | None = None, user_id: str | None = None, limit: int = 50, now: datetime | None = None, client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None, ) -> list[CalendarCalDavDueSyncResult]: return _sync_due_sources(session, source_kinds=SYNC_SOURCE_KINDS, **_due_sync_options(tenant_id, user_id, limit, now, client_factory)) def sync_due_caldav_sources( session: Session, *, tenant_id: str | None = None, user_id: str | None = None, limit: int = 50, now: datetime | None = None, client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None, ) -> list[CalendarCalDavDueSyncResult]: return _sync_due_sources(session, source_kinds={"caldav"}, **_due_sync_options(tenant_id, user_id, limit, now, client_factory)) def _due_sync_options( tenant_id: str | None, user_id: str | None, limit: int, now: datetime | None, client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None, ) -> dict[str, object]: return { "tenant_id": tenant_id, "user_id": user_id, "limit": limit, "now": now, "client_factory": client_factory, } def _sync_due_sources( session: Session, *, tenant_id: str | None, user_id: str | None, limit: int, now: datetime | None, source_kinds: set[str], client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None, ) -> list[CalendarCalDavDueSyncResult]: due_at = normalize_datetime(now or utcnow()) query = session.query(CalendarSyncSource).filter( CalendarSyncSource.source_kind.in_(source_kinds), CalendarSyncSource.sync_enabled.is_(True), CalendarSyncSource.deleted_at.is_(None), or_(CalendarSyncSource.next_sync_at.is_(None), CalendarSyncSource.next_sync_at <= due_at), ) if tenant_id is not None: query = query.filter(CalendarSyncSource.tenant_id == tenant_id) sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all() results: list[CalendarCalDavDueSyncResult] = [] for source in sources: previous_status = source.last_status try: refreshed, stats = _sync_due_source(session, source=source, user_id=user_id, client_factory=client_factory) results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats)) _emit_calendar_sync_notification(session, source=refreshed, status="ok", previous_status=previous_status, stats=stats) except Exception as exc: results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc))) _emit_calendar_sync_notification(session, source=source, status="error", previous_status=previous_status, error=str(exc)) session.flush() return results def _sync_due_source( session: Session, *, source: CalendarSyncSource, user_id: str | None, client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None, ) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: if source.source_kind == "caldav": client = client_factory(source) if client_factory else None return sync_caldav_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id, client=client) return sync_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id) def schedule_next_caldav_sync(source: CalendarSyncSource, *, now: datetime | None = None) -> None: if not source.sync_enabled: source.next_sync_at = None return interval = max(int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS), 60) source.next_sync_at = normalize_datetime(now or utcnow()) + timedelta(seconds=interval) def apply_caldav_report( session: Session, *, source: CalendarSyncSource, client: CalDAVClient, report: CalDAVReportResult, stats: CalendarCalDavSyncStats, user_id: str | None, ) -> None: seen_hrefs: set[str] = set() for item in report.objects: href = normalize_caldav_href(source.collection_url, item.href) if item.deleted: stats.deleted += soft_delete_caldav_href(session, source=source, href=href) continue seen_hrefs.add(href) calendar_data = item.calendar_data if calendar_data is None: try: calendar_data = client.fetch_object(item.href) stats.fetched += 1 except CalDAVNotFound: stats.deleted += soft_delete_caldav_href(session, source=source, href=href) stats.errors.append(f"CalDAV object disappeared during sync and was treated as deleted: {href}") continue created, updated, unchanged, deleted = import_caldav_resource( session, source=source, href=href, etag=item.etag, ics=calendar_data, user_id=user_id, ) stats.created += created stats.updated += updated stats.unchanged += unchanged stats.deleted += deleted if stats.full_sync: stats.deleted += soft_delete_unseen_source_events( session, source=source, seen_hrefs=seen_hrefs, source_kind="caldav", href_prefix=source.collection_url, user_id=user_id, ) def import_caldav_resource( session: Session, *, source: CalendarSyncSource, href: str, etag: str | None, ics: str, user_id: str | None, ) -> tuple[int, int, int, int]: parsed_events = parse_vevents(ics) remaining = { (event.uid, event.recurrence_id): event for event in session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_kind == "caldav", CalendarEvent.source_href == href, CalendarEvent.deleted_at.is_(None), ) .all() } created = updated = unchanged = deleted = 0 changes: list[tuple[CalendarEvent, str, dict[str, Any] | None]] = [] raw_ics = str(parsed_events[0].get("raw_ics") or ics) for parsed in parsed_events: key = (parsed["uid"], parsed.get("recurrence_id")) event = find_event_for_caldav_component(session, source=source, href=href, parsed=parsed) previous = calendar_event_change_payload(event, prefix="previous_") if event else None was_deleted = bool(event and event.deleted_at is not None) if event is None: event = CalendarEvent( tenant_id=source.tenant_id, calendar_id=source.calendar_id, uid=parsed["uid"], recurrence_id=parsed.get("recurrence_id"), created_by_user_id=user_id, ) session.add(event) created += 1 changes.append((event, "created", previous)) else: if was_deleted: created += 1 changes.append((event, "created", previous)) elif event.etag == etag and event.raw_ics == raw_ics: unchanged += 1 else: updated += 1 changes.append((event, "updated", previous)) apply_parsed_event_to_model(event, parsed, source=source, href=href, etag=etag, raw_ics=raw_ics, user_id=user_id) remaining.pop(key, None) deleted_at = utcnow() for event in remaining.values(): previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at deleted += 1 changes.append((event, "deleted", previous)) session.flush() for event, operation, previous in changes: record_calendar_event_change(session, event=event, operation=operation, user_id=user_id, previous=previous) return created, updated, unchanged, deleted def find_event_for_caldav_component(session: Session, *, source: CalendarSyncSource, href: str, parsed: dict[str, Any]) -> CalendarEvent | None: return ( session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.uid == parsed["uid"], CalendarEvent.recurrence_id == parsed.get("recurrence_id"), ) .order_by((CalendarEvent.source_href == href).desc(), CalendarEvent.deleted_at.is_(None).desc()) .first() ) def apply_parsed_event_to_model( event: CalendarEvent, parsed: dict[str, Any], *, source: CalendarSyncSource, href: str, etag: str | None, raw_ics: str, user_id: str | None, ) -> None: for field_name in ( "sequence", "summary", "description", "location", "status", "transparency", "classification", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "organizer", "attendees", "categories", "rrule", "rdate", "exdate", "reminders", "attachments", "related_to", "icalendar", ): setattr(event, field_name, parsed.get(field_name)) event.calendar_id = source.calendar_id event.source_kind = source.source_kind event.source_href = href event.etag = etag event.raw_ics = raw_ics event.updated_by_user_id = user_id event.deleted_at = None metadata = dict(event.metadata_ or {}) metadata[source.source_kind] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag} event.metadata_ = metadata validate_event_time(event) def soft_delete_caldav_href(session: Session, *, source: CalendarSyncSource, href: str) -> int: deleted_at = utcnow() count = 0 for event in ( session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_kind == "caldav", CalendarEvent.source_href == href, CalendarEvent.deleted_at.is_(None), ) .all() ): previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at record_calendar_event_change(session, event=event, operation="deleted", user_id=None, previous=previous) count += 1 return count def normalize_caldav_href(collection_url: str, href: str) -> str: return urllib.parse.urljoin(ensure_collection_url(collection_url), href) def mark_calendar_caldav(calendar: CalendarCollection, source: CalendarSyncSource) -> None: mark_calendar_sync_source(calendar, source) def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSyncSource) -> None: metadata = dict(calendar.metadata_ or {}) metadata.setdefault("source_kind", source.source_kind) metadata["connector_kind"] = source.source_kind metadata["sync_source_id"] = source.id metadata["sync_href"] = source.collection_url metadata["sync_direction"] = source.sync_direction calendar.metadata_ = metadata def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]: return { "id": source.id, "tenant_id": source.tenant_id, "calendar_id": source.calendar_id, "source_kind": source.source_kind, "collection_url": source.collection_url, "display_name": source.display_name, "auth_type": source.auth_type, "username": source.username, "credential_ref": source.credential_ref, "has_credential": bool(source.credential_ref), "sync_enabled": bool(source.sync_enabled), "sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS), "sync_direction": source.sync_direction, "conflict_policy": source.conflict_policy, "sync_token": source.sync_token, "ctag": source.ctag, "last_attempt_at": response_datetime(source.last_attempt_at), "last_synced_at": response_datetime(source.last_synced_at), "next_sync_at": response_datetime(source.next_sync_at), "last_status": source.last_status, "last_error": source.last_error, "created_at": response_datetime(source.created_at), "updated_at": response_datetime(source.updated_at), "metadata": source.metadata_ or {}, } def caldav_sync_response(source: CalendarSyncSource, stats: CalendarCalDavSyncStats) -> dict[str, Any]: return { "source": caldav_source_response(source), "created": stats.created, "updated": stats.updated, "deleted": stats.deleted, "unchanged": stats.unchanged, "fetched": stats.fetched, "full_sync": stats.full_sync, "used_sync_token": stats.used_sync_token, "sync_token": stats.sync_token, "ctag": stats.ctag, "errors": stats.errors, } def list_events( session: Session, *, tenant_id: str, calendar_id: str | None = None, start_at: datetime | None = None, end_at: datetime | None = None, ) -> list[CalendarEvent]: query = session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)) if calendar_id: query = query.filter(CalendarEvent.calendar_id == calendar_id) if start_at is not None: query = query.filter(or_(CalendarEvent.end_at.is_(None), CalendarEvent.end_at >= normalize_datetime(start_at))) if end_at is not None: query = query.filter(CalendarEvent.start_at <= normalize_datetime(end_at)) return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all() def list_freebusy( session: Session, *, tenant_id: str, start_at: datetime, end_at: datetime, calendar_ids: list[str] | None = None, ) -> list[dict[str, Any]]: range_start = normalize_datetime(start_at) range_end = normalize_datetime(end_at) if range_end < range_start: raise CalendarError("Free/busy end must be after start") query = session.query(CalendarEvent).filter( CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None), CalendarEvent.status != "CANCELLED", CalendarEvent.transparency != "TRANSPARENT", ) if calendar_ids: query = query.filter(CalendarEvent.calendar_id.in_(calendar_ids)) events = query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all() busy: list[dict[str, Any]] = [] for event in events: if event.rrule or event.rdate: for occurrence in expand_event_occurrences(event, range_start, range_end): busy.append(freebusy_block(event, occurrence["start_at"], occurrence["end_at"], occurrence.get("recurrence_id"))) continue event_start = normalize_datetime(event.start_at) event_end = normalize_datetime(event.end_at) if event.end_at else event_start if event_end >= range_start and event_start <= range_end: busy.append(freebusy_block(event, event_start, event_end, event.recurrence_id)) return sorted(busy, key=lambda item: (item["start_at"], item["calendar_id"], item["uid"])) def freebusy_block(event: CalendarEvent, start_at: datetime, end_at: datetime | None, recurrence_id: str | None) -> dict[str, Any]: return { "calendar_id": event.calendar_id, "event_id": event.id, "uid": event.uid, "recurrence_id": recurrence_id, "start_at": response_datetime(start_at), "end_at": response_datetime(end_at), "all_day": event.all_day, "transparency": event.transparency, "status": event.status, } def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEvent: event = ( session.query(CalendarEvent) .filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.id == event_id, CalendarEvent.deleted_at.is_(None)) .first() ) if not event: raise CalendarError("Calendar event not found") return event def create_event(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarEventCreateRequest) -> CalendarEvent: default_calendar = None if payload.calendar_id else get_default_calendar(session, tenant_id=tenant_id) calendar_id = payload.calendar_id or (default_calendar.id if default_calendar else None) if not calendar_id: raise CalendarError("calendar_id is required because no default calendar exists") get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) assert_sync_mutation_allowed(source) event = CalendarEvent( tenant_id=tenant_id, calendar_id=calendar_id, uid=payload.uid or f"{uuid.uuid4()}@govoplan.local", recurrence_id=payload.recurrence_id, sequence=payload.sequence, summary=payload.summary, description=payload.description, location=payload.location, status=payload.status.upper(), transparency=payload.transparency.upper(), classification=payload.classification.upper(), start_at=normalize_datetime(payload.start_at), end_at=normalize_datetime(payload.end_at) if payload.end_at else None, duration_seconds=payload.duration_seconds, all_day=payload.all_day, timezone=payload.timezone, organizer=payload.organizer, attendees=payload.attendees, categories=payload.categories, rrule=payload.rrule, rdate=payload.rdate, exdate=payload.exdate, reminders=payload.reminders, attachments=payload.attachments, related_to=payload.related_to, source_kind=payload.source_kind, source_href=payload.source_href, etag=payload.etag, icalendar=payload.icalendar, created_by_user_id=user_id, updated_by_user_id=user_id, metadata_=payload.metadata, ) validate_event_time(event) session.add(event) session.flush() if should_push_event_to_caldav(source=source, event=event): push_caldav_event_resource(session, source=source, event=event, create=True) record_calendar_event_change(session, event=event, operation="created", user_id=user_id) return event def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent: event = get_event(session, tenant_id=tenant_id, event_id=event_id) previous = calendar_event_change_payload(event, prefix="previous_") original_calendar_id = event.calendar_id original_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) original_caldav = event.source_kind == "caldav" and bool(event.source_href) if payload.calendar_id is not None: get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) assert_sync_mutation_allowed(target_source) if payload.calendar_id != event.calendar_id and original_source and original_caldav: assert_sync_mutation_allowed(original_source) push_caldav_delete_for_event(session, source=original_source, event=event) event.source_kind = "local" event.source_href = None event.etag = None event.calendar_id = payload.calendar_id else: assert_sync_mutation_allowed(original_source) scalar_fields = ( "sequence", "summary", "description", "location", "status", "transparency", "classification", "duration_seconds", "all_day", "timezone", "organizer", "attendees", "categories", "rrule", "rdate", "exdate", "reminders", "attachments", "related_to", "source_kind", "source_href", "etag", "icalendar", ) for attr in scalar_fields: value = getattr(payload, attr) if value is not None: setattr(event, attr, value.upper() if attr in {"status", "transparency", "classification"} and isinstance(value, str) else value) if payload.start_at is not None: event.start_at = normalize_datetime(payload.start_at) if "end_at" in payload.model_fields_set: event.end_at = normalize_datetime(payload.end_at) if payload.end_at else None if payload.metadata is not None: event.metadata_ = payload.metadata event.updated_by_user_id = user_id if payload.sequence is None: event.sequence += 1 validate_event_time(event) event.raw_ics = None session.flush() target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) if should_push_event_to_caldav(source=target_source, event=event): push_caldav_event_resource( session, source=target_source, event=event, create=event.source_kind != "caldav" or not event.source_href or original_calendar_id != event.calendar_id, ) record_calendar_event_change(session, event=event, operation="updated", user_id=user_id, previous=previous) return event def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None: event = get_event(session, tenant_id=tenant_id, event_id=event_id) previous = calendar_event_change_payload(event, prefix="previous_") source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) assert_sync_mutation_allowed(source) if should_push_event_to_caldav(source=source, event=event): push_caldav_delete_for_event(session, source=source, event=event) event.deleted_at = utcnow() session.flush() record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) def active_sync_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None: return ( session.query(CalendarSyncSource) .filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.calendar_id == calendar_id, CalendarSyncSource.deleted_at.is_(None), ) .order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc()) .first() ) def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None: return ( session.query(CalendarSyncSource) .filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.calendar_id == calendar_id, CalendarSyncSource.source_kind == "caldav", CalendarSyncSource.deleted_at.is_(None), ) .order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc()) .first() ) def assert_sync_mutation_allowed(source: CalendarSyncSource | None) -> None: if source is None: return if source.source_kind != "caldav" or source.sync_direction != "two_way": raise CalendarError(f"This {sync_source_label(source.source_kind)} calendar is inbound-only and cannot be changed locally") def assert_caldav_mutation_allowed(source: CalendarSyncSource | None) -> None: assert_sync_mutation_allowed(source) def should_push_event_to_caldav(*, source: CalendarSyncSource | None, event: CalendarEvent) -> bool: return bool(source and source.source_kind == "caldav" and source.sync_direction == "two_way" and event.deleted_at is None) def push_caldav_event_resource(session: Session, *, source: CalendarSyncSource, event: CalendarEvent, create: bool) -> None: href = event.source_href or generated_caldav_href(session, source=source, event=event) full_href = normalize_caldav_href(source.collection_url, href) resource_events = caldav_resource_events(session, source=source, href=full_href) if event.source_href else [event] if event not in resource_events: resource_events.append(event) resource_events = sorted(resource_events, key=caldav_resource_sort_key) ics = events_to_ics(resource_events) client = caldav_client_for_source(session, source) overwrite = source.conflict_policy == "overwrite" etag = None if create else resource_etag(resource_events) try: result = client.put_object(full_href, ics, etag=etag, create=create, overwrite=overwrite) except CalDAVPreconditionFailed as exc: raise CalendarError("CalDAV resource changed remotely; sync the calendar before saving again") from exc except CalDAVError as exc: raise CalendarError(f"CalDAV write failed: {exc}") from exc next_etag = result.etag for item in resource_events: apply_caldav_write_metadata(item, source=source, href=full_href, etag=next_etag, raw_ics=ics) source.last_status = "outbound_ok" source.last_error = None schedule_next_caldav_sync(source) session.flush() def push_caldav_delete_for_event(session: Session, *, source: CalendarSyncSource, event: CalendarEvent) -> None: if not event.source_href: return full_href = normalize_caldav_href(source.collection_url, event.source_href) remaining = [ item for item in caldav_resource_events(session, source=source, href=full_href) if item.id != event.id ] client = caldav_client_for_source(session, source) overwrite = source.conflict_policy == "overwrite" try: if remaining: remaining = sorted(remaining, key=caldav_resource_sort_key) ics = events_to_ics(remaining) result = client.put_object(full_href, ics, etag=event.etag or resource_etag(remaining), create=False, overwrite=overwrite) for item in remaining: apply_caldav_write_metadata(item, source=source, href=full_href, etag=result.etag, raw_ics=ics) else: client.delete_object(full_href, etag=event.etag, overwrite=overwrite) except CalDAVPreconditionFailed as exc: raise CalendarError("CalDAV resource changed remotely; sync the calendar before deleting again") from exc except CalDAVError as exc: raise CalendarError(f"CalDAV delete failed: {exc}") from exc source.last_status = "outbound_ok" source.last_error = None schedule_next_caldav_sync(source) session.flush() def caldav_resource_events(session: Session, *, source: CalendarSyncSource, href: str) -> list[CalendarEvent]: return ( session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_kind == "caldav", CalendarEvent.source_href == href, CalendarEvent.deleted_at.is_(None), ) .all() ) def caldav_resource_sort_key(event: CalendarEvent) -> tuple[int, str, datetime]: return (1 if event.recurrence_id else 0, event.recurrence_id or "", event.start_at) def resource_etag(events: list[CalendarEvent]) -> str | None: return next((event.etag for event in events if event.etag), None) def generated_caldav_href(session: Session, *, source: CalendarSyncSource, event: CalendarEvent) -> str: safe_uid = urllib.parse.quote(event.uid, safe="-_.~@") candidate = normalize_caldav_href(source.collection_url, f"{safe_uid}.ics") existing = ( session.query(CalendarEvent.id) .filter( CalendarEvent.tenant_id == source.tenant_id, CalendarEvent.calendar_id == source.calendar_id, CalendarEvent.source_href == candidate, CalendarEvent.deleted_at.is_(None), CalendarEvent.id != event.id, ) .first() ) if existing is None: return candidate return normalize_caldav_href(source.collection_url, f"{safe_uid}-{event.id}.ics") def apply_caldav_write_metadata(event: CalendarEvent, *, source: CalendarSyncSource, href: str, etag: str | None, raw_ics: str) -> None: event.source_kind = "caldav" event.source_href = href event.etag = etag event.raw_ics = raw_ics metadata = dict(event.metadata_ or {}) metadata["caldav"] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag} event.metadata_ = metadata def import_ics_event( session: Session, *, tenant_id: str, user_id: str | None, calendar_id: str | None, ics: str, upsert: bool, source_kind: str, source_href: str | None, etag: str | None, ) -> CalendarEvent: parsed = parse_vevent(ics) raw_ics = str(parsed.pop("raw_ics", "")) default_calendar = None if calendar_id else get_default_calendar(session, tenant_id=tenant_id) target_calendar_id = calendar_id or (default_calendar.id if default_calendar else None) if not target_calendar_id: raise CalendarError("calendar_id is required because no default calendar exists") existing = None if upsert: existing = ( session.query(CalendarEvent) .filter( CalendarEvent.tenant_id == tenant_id, CalendarEvent.calendar_id == target_calendar_id, CalendarEvent.uid == parsed["uid"], CalendarEvent.recurrence_id == parsed.get("recurrence_id"), CalendarEvent.deleted_at.is_(None), ) .first() ) payload = CalendarEventCreateRequest( calendar_id=target_calendar_id, source_kind=source_kind, source_href=source_href, etag=etag, metadata={}, **parsed, ) if existing: update_payload = CalendarEventUpdateRequest(**payload.model_dump(exclude={"uid", "recurrence_id"})) event = update_event(session, tenant_id=tenant_id, user_id=user_id, event_id=existing.id, payload=update_payload) event.raw_ics = raw_ics return event event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload) event.raw_ics = raw_ics return event def normalize_datetime(value: datetime) -> datetime: if value.tzinfo is None: return value.replace(tzinfo=timezone.utc) return value.astimezone(timezone.utc) def response_datetime(value: datetime | None) -> datetime | None: if value is None: return None return normalize_datetime(value) def validate_event_time(event: CalendarEvent) -> None: if event.end_at is not None and event.end_at < event.start_at: raise CalendarError("Event end must be after start") def calendar_response(calendar: CalendarCollection) -> dict[str, Any]: return { "id": calendar.id, "tenant_id": calendar.tenant_id, "slug": calendar.slug, "name": calendar.name, "description": calendar.description, "timezone": calendar.timezone, "color": calendar.color, "owner_type": calendar.owner_type, "owner_id": calendar.owner_id, "visibility": calendar.visibility, "is_default": calendar.is_default, "created_at": response_datetime(calendar.created_at), "updated_at": response_datetime(calendar.updated_at), "metadata": calendar.metadata_ or {}, } def event_response(event: CalendarEvent) -> dict[str, Any]: return { "id": event.id, "tenant_id": event.tenant_id, "calendar_id": event.calendar_id, "uid": event.uid, "recurrence_id": event.recurrence_id, "sequence": event.sequence, "summary": event.summary, "description": event.description, "location": event.location, "status": event.status, "transparency": event.transparency, "classification": event.classification, "start_at": response_datetime(event.start_at), "end_at": response_datetime(event.end_at), "duration_seconds": event.duration_seconds, "all_day": event.all_day, "timezone": event.timezone, "organizer": event.organizer, "attendees": event.attendees or [], "categories": event.categories or [], "rrule": event.rrule, "rdate": event.rdate or [], "exdate": event.exdate or [], "reminders": event.reminders or [], "attachments": event.attachments or [], "related_to": event.related_to or [], "source_kind": event.source_kind, "source_href": event.source_href, "etag": event.etag, "icalendar": event.icalendar or {}, "created_at": response_datetime(event.created_at), "updated_at": response_datetime(event.updated_at), "metadata": event.metadata_ or {}, }