Files
govoplan-calendar/src/govoplan_calendar/backend/service.py
2026-07-14 13:22:10 +02:00

2446 lines
99 KiB
Python

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"<t:Mailbox><t:EmailAddress>{xml_escape(str(mailbox))}</t:EmailAddress></t:Mailbox>"
return f"""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="{EWS_SOAP_NS}" xmlns:m="{EWS_MESSAGES_NS}" xmlns:t="{EWS_TYPES_NS}">
<s:Header>
<t:RequestServerVersion Version="Exchange2013_SP1" />
</s:Header>
<s:Body>
<m:FindItem Traversal="Shallow">
<m:ItemShape>
<t:BaseShape>AllProperties</t:BaseShape>
</m:ItemShape>
<m:CalendarView StartDate="{start_text}" EndDate="{end_text}" />
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="calendar">{mailbox_xml}</t:DistinguishedFolderId>
</m:ParentFolderIds>
</m:FindItem>
</s:Body>
</s:Envelope>"""
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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 {},
}