Files
govoplan-calendar/src/govoplan_calendar/backend/service.py

3667 lines
135 KiB
Python

from __future__ import annotations
import json
import posixpath
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, Iterable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from defusedxml import ElementTree as SafeElementTree
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
from govoplan_core.audit.logging import audit_event
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.ical import 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]:
metadata = event.metadata_ or {}
caldav = metadata.get("caldav") if isinstance(metadata, dict) else None
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,
f"{prefix}external_state": (
caldav.get("external_state") if isinstance(caldav, dict) else "local"
),
f"{prefix}outbox_operation_id": (
caldav.get("outbox_operation_id") if isinstance(caldav, dict) else None
),
}
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.strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
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")
_parsed_http_origin(parsed, label=label)
return urllib.parse.urlunparse(parsed)
def same_origin_http_url(base_url: str, candidate_url: str, *, label: str) -> str:
base = validate_http_url(base_url, label=label)
candidate = validate_http_url(candidate_url, label=label)
if _parsed_http_origin(urllib.parse.urlparse(candidate), label=label) != _parsed_http_origin(
urllib.parse.urlparse(base),
label=label,
):
raise CalendarError(f"{label} must remain on the configured source origin")
return candidate
def _parsed_http_origin(parsed: urllib.parse.ParseResult, *, label: str) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise CalendarError(f"{label} has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
parsed = urllib.parse.urlparse(validate_http_url(source_url, label="Calendar source URL"))
self._source_origin = _parsed_http_origin(parsed, label="Calendar source URL")
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
try:
candidate = validate_http_url(newurl, label="Calendar source redirect URL")
candidate = validate_outbound_http_url(candidate, label="Calendar source redirect URL")
candidate_origin = _parsed_http_origin(
urllib.parse.urlparse(candidate),
label="Calendar source redirect URL",
)
except (CalendarError, OutboundHttpError):
return None
if candidate_origin != self._source_origin:
return None
return super().redirect_request(req, fp, code, msg, headers, candidate)
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 calendar_is_visible_to_principal(
calendar: CalendarCollection,
*,
user_id: str | None,
group_ids: Iterable[str] = (),
can_admin: bool = False,
) -> bool:
"""Return whether a principal may discover a calendar collection.
Collection visibility is deliberately enforced before serializing picker
metadata. ``shared`` currently means tenant-visible; explicit per-user
sharing can narrow this rule when Calendar gains a share relation.
"""
if can_admin:
return True
if calendar.visibility in {"tenant", "shared", "public"}:
return True
if calendar.visibility != "private" or user_id is None:
return False
if calendar.created_by_user_id == user_id:
return True
if calendar.owner_type == "user":
return calendar.owner_id == user_id
if calendar.owner_type == "group":
return bool(calendar.owner_id and calendar.owner_id in set(group_ids))
return False
def list_calendars(
session: Session,
*,
tenant_id: str,
ensure_default: bool = False,
user_id: str | None = None,
group_ids: Iterable[str] = (),
can_admin: bool = False,
) -> list[CalendarCollection]:
if ensure_default:
ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id)
calendars = (
session.query(CalendarCollection)
.filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None))
.order_by(CalendarCollection.is_default.desc(), CalendarCollection.name.asc())
.all()
)
principal_group_ids = tuple(group_ids)
return [
calendar
for calendar in calendars
if calendar_is_visible_to_principal(
calendar,
user_id=user_id,
group_ids=principal_group_ids,
can_admin=can_admin,
)
]
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 _lock_calendar_move_context(
session: Session,
*,
tenant_id: str,
calendar: CalendarCollection,
target_calendar: CalendarCollection,
) -> tuple[
CalendarCollection,
CalendarCollection,
CalendarSyncSource | None,
CalendarSyncSource | None,
]:
if not hasattr(session, "query"):
return calendar, target_calendar, None, None
# Source creation takes the collection row lock before linking a source.
# Lock both collections in stable order so their modes cannot change
# between validation and the local/outbox mutation.
locked_calendars = (
session.query(CalendarCollection)
.filter(
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.id.in_((calendar.id, target_calendar.id)),
CalendarCollection.deleted_at.is_(None),
)
.order_by(CalendarCollection.id.asc())
.populate_existing()
.with_for_update()
.all()
)
locked_calendar_by_id = {item.id: item for item in locked_calendars}
if len(locked_calendar_by_id) != 2:
raise CalendarError("Source or target calendar is no longer available")
calendar = locked_calendar_by_id[calendar.id]
target_calendar = locked_calendar_by_id[target_calendar.id]
source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=calendar.id,
)
target_source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar.id,
)
locked_sources = lock_sync_sources(session, source, target_source)
if source is not None:
source = locked_sources[source.id]
if target_source is not None:
target_source = locked_sources[target_source.id]
return calendar, target_calendar, source, target_source
def _validate_calendar_move_source_pair(
payload: CalendarCollectionDeleteRequest,
*,
source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
) -> None:
if payload.external_action == "remote_move":
raise CalendarError(
"external_action='remote_move' is not supported; no destructive remote move was performed"
)
if source is not None and target_source is not None:
raise CalendarError(
"Events cannot be bulk-moved between synchronized calendars; "
"external_action='remote_move' remains unsupported"
)
def _prepare_calendar_move_external_action(
session: Session,
*,
tenant_id: str,
payload: CalendarCollectionDeleteRequest,
source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
deleted_at: datetime,
) -> None:
if source is not None:
if payload.external_action != "detach_keep_remote":
raise CalendarError(
"Moving events from a synchronized calendar to a local calendar requires "
"external_action='detach_keep_remote'"
)
# Retirement rejects a live delivery lease before any local event
# projection is changed, and cancels all remaining undelivered desired
# state. It never creates a remote DELETE operation.
retire_sync_source(
session,
tenant_id=tenant_id,
source=source,
deleted_at=deleted_at,
)
return
if target_source is not None:
if payload.external_action != "copy_to_remote":
raise CalendarError(
"Moving local events to a synchronized calendar requires "
"external_action='copy_to_remote'"
)
if (
target_source.source_kind != "caldav"
or target_source.sync_direction != "two_way"
or not target_source.sync_enabled
):
raise CalendarError(
"external_action='copy_to_remote' requires an active two-way CalDAV target"
)
assert_sync_mutation_allowed(target_source)
return
if payload.external_action is not None:
raise CalendarError(
"external_action is only valid when moving events to or from a synchronized calendar"
)
def _active_events_with_previous_state(
session: Session,
calendar: CalendarCollection,
) -> tuple[list[CalendarEvent], dict[str, dict[str, Any]]]:
active_events = [event for event in calendar.events if event.deleted_at is None]
previous_event_states = (
{
event.id: calendar_event_change_payload(event, prefix="previous_")
for event in active_events
}
if hasattr(session, "query")
else {}
)
return active_events, previous_event_states
def _move_calendar_events(
session: Session,
*,
tenant_id: str,
payload: CalendarCollectionDeleteRequest,
target_calendar: CalendarCollection,
source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
active_events: list[CalendarEvent],
previous_event_states: dict[str, dict[str, Any]],
) -> None:
for event in active_events:
if source is not None or target_source is not None:
event.source_kind = "local"
event.source_href = None
event.etag = None
metadata = dict(event.metadata_ or {})
metadata.pop("caldav", None)
event.metadata_ = metadata
event.calendar_id = target_calendar.id
session.flush()
if target_source is not None:
from govoplan_calendar.backend.outbox import enqueue_caldav_put
for event in active_events:
enqueue_caldav_put(session, source=target_source, event_model=event)
if previous_event_states:
for event in active_events:
record_calendar_event_change(
session,
event=event,
operation="updated",
user_id=None,
previous=previous_event_states[event.id],
)
if payload.make_target_default:
clear_default_calendar(session, tenant_id=tenant_id)
target_calendar.is_default = True
def _move_calendar_before_delete(
session: Session,
*,
tenant_id: str,
calendar: CalendarCollection,
payload: CalendarCollectionDeleteRequest,
deleted_at: datetime,
) -> CalendarCollection:
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,
)
calendar, target_calendar, source, target_source = _lock_calendar_move_context(
session,
tenant_id=tenant_id,
calendar=calendar,
target_calendar=target_calendar,
)
_validate_calendar_move_source_pair(
payload,
source=source,
target_source=target_source,
)
active_events, previous_event_states = _active_events_with_previous_state(
session,
calendar,
)
_prepare_calendar_move_external_action(
session,
tenant_id=tenant_id,
payload=payload,
source=source,
target_source=target_source,
deleted_at=deleted_at,
)
_move_calendar_events(
session,
tenant_id=tenant_id,
payload=payload,
target_calendar=target_calendar,
source=source,
target_source=target_source,
active_events=active_events,
previous_event_states=previous_event_states,
)
return calendar
def delete_calendar(
session: Session,
*,
tenant_id: str,
calendar_id: str,
payload: CalendarCollectionDeleteRequest | None = None,
user_id: str | None = None,
api_key_id: str | 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":
calendar = _move_calendar_before_delete(
session,
tenant_id=tenant_id,
calendar=calendar,
payload=payload,
deleted_at=deleted_at,
)
elif payload.event_action != "delete":
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
elif payload.external_action is not None:
raise CalendarError("external_action is only valid when event_action='move'")
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,
deletion_reason="calendar_deleted",
user_id=user_id,
api_key_id=api_key_id,
)
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)
if payload.credential_ref is not None and (source is None or payload.credential_ref != source.credential_ref):
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a password/token "
"or select an existing sync source"
)
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
if secret is None and source is not None and credential_ref == source.credential_ref:
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:
if payload.credential_ref is not None:
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a password or bearer token"
)
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
calendar = (
session.query(CalendarCollection)
.filter(
CalendarCollection.id == calendar.id,
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
existing_calendar_source = (
session.query(CalendarSyncSource.id)
.filter(
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.calendar_id == calendar.id,
CalendarSyncSource.deleted_at.is_(None),
)
.first()
)
if existing_calendar_source is not None:
raise CalendarError(
"A calendar can have only one active synchronization source"
)
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 _lock_sync_source_for_update(
session: Session,
*,
tenant_id: str,
source_id: str,
) -> CalendarSyncSource:
return (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == source_id,
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
def _sync_source_reconfiguration(
source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
) -> tuple[str, bool, bool]:
normalized_collection_url = (
normalize_sync_source_url(source.source_kind, payload.collection_url)
if payload.collection_url is not None
else source.collection_url
)
calendar_changed = bool(
payload.calendar_id is not None and payload.calendar_id != source.calendar_id
)
endpoint_changed = normalized_collection_url != source.collection_url
endpoint_or_calendar_changed = bool(
source.source_kind == "caldav" and (calendar_changed or endpoint_changed)
)
materially_reconfigured = bool(
source.source_kind == "caldav"
and (
calendar_changed
or endpoint_changed
or payload.sync_enabled is False
or payload.sync_direction == "inbound"
)
)
return (
normalized_collection_url,
materially_reconfigured,
endpoint_or_calendar_changed,
)
def _caldav_source_has_linked_events(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
) -> bool:
return (
session.query(CalendarEvent.id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.calendar_id == source.calendar_id,
CalendarEvent.source_kind == "caldav",
CalendarEvent.source_href.is_not(None),
CalendarEvent.deleted_at.is_(None),
)
.first()
is not None
)
def _guard_sync_source_reconfiguration(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
endpoint_or_calendar_changed: bool,
) -> None:
from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease,
calendar_outbox_has_unresolved_desired_state,
cancel_calendar_outbox_for_source,
)
if calendar_outbox_has_live_lease(session, source_id=source.id):
raise CalendarError(
"CalDAV source cannot be materially changed while an outbound operation has an active lease"
)
unresolved_desired_state = calendar_outbox_has_unresolved_desired_state(
session,
source_id=source.id,
)
stops_outbound_delivery = bool(
(payload.sync_enabled is False and source.sync_enabled)
or (
payload.sync_direction == "inbound"
and source.sync_direction == "two_way"
)
)
if stops_outbound_delivery and unresolved_desired_state:
raise CalendarError(
"CalDAV source cannot disable outbound delivery while unresolved local desired "
"changes exist; deliver or explicitly discard them first"
)
if endpoint_or_calendar_changed and unresolved_desired_state:
raise CalendarError(
"CalDAV source endpoint or calendar cannot change while unresolved local desired "
"changes exist; deliver or explicitly discard them first"
)
if endpoint_or_calendar_changed and _caldav_source_has_linked_events(
session,
tenant_id=tenant_id,
source=source,
):
raise CalendarError(
"CalDAV source endpoint or calendar cannot change while events still reference it; "
"detach or resync those events first"
)
cancel_calendar_outbox_for_source(
session,
source_id=source.id,
reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only",
)
def _calendar_for_sync_source_update(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
calendar_id: str | None,
) -> CalendarCollection:
if calendar_id is None:
return get_calendar(
session,
tenant_id=tenant_id,
calendar_id=source.calendar_id,
)
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
calendar = (
session.query(CalendarCollection)
.filter(
CalendarCollection.id == calendar.id,
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
conflicting_source = (
session.query(CalendarSyncSource.id)
.filter(
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.calendar_id == calendar.id,
CalendarSyncSource.id != source.id,
CalendarSyncSource.deleted_at.is_(None),
)
.first()
)
if conflicting_source is not None:
raise CalendarError("A calendar can have only one active synchronization source")
source.calendar_id = calendar.id
return calendar
def _apply_sync_source_values(
source: CalendarSyncSource,
*,
payload: CalendarSyncSourceUpdateRequest,
normalized_collection_url: str,
) -> None:
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 = normalized_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"
def _validate_sync_source_auth(source: CalendarSyncSource) -> None:
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"
)
def _update_sync_source_credential_and_schedule(
session: Session,
*,
tenant_id: str,
source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
previous_auth_type: str,
user_id: str | None,
api_key_id: str | None,
) -> None:
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,
api_key_id=api_key_id,
)
elif payload.auth_type is not None and source.auth_type != previous_auth_type:
delete_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
deletion_reason=(
"authentication_disabled"
if source.auth_type == "none"
else "authentication_type_changed"
),
user_id=user_id,
api_key_id=api_key_id,
)
source.credential_ref = None
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
def update_sync_source(
session: Session,
*,
tenant_id: str,
source_id: str,
payload: CalendarSyncSourceUpdateRequest,
user_id: str | None = None,
api_key_id: str | None = None,
) -> CalendarSyncSource:
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
source = _lock_sync_source_for_update(
session,
tenant_id=tenant_id,
source_id=source.id,
)
if payload.credential_ref is not None and payload.credential_ref != source.credential_ref:
raise CalendarError(
"Caller-supplied credential references are not accepted; provide a replacement password or bearer token"
)
previous_auth_type = source.auth_type
(
normalized_collection_url,
materially_reconfigured,
endpoint_or_calendar_changed,
) = _sync_source_reconfiguration(source, payload)
if materially_reconfigured:
_guard_sync_source_reconfiguration(
session,
tenant_id=tenant_id,
source=source,
payload=payload,
endpoint_or_calendar_changed=endpoint_or_calendar_changed,
)
calendar = _calendar_for_sync_source_update(
session,
tenant_id=tenant_id,
source=source,
calendar_id=payload.calendar_id,
)
_apply_sync_source_values(
source,
payload=payload,
normalized_collection_url=normalized_collection_url,
)
_validate_sync_source_auth(source)
_update_sync_source_credential_and_schedule(
session,
tenant_id=tenant_id,
source=source,
payload=payload,
previous_auth_type=previous_auth_type,
user_id=user_id,
api_key_id=api_key_id,
)
mark_calendar_sync_source(calendar, source)
session.flush()
return source
def update_caldav_source(
session: Session,
*,
tenant_id: str,
source_id: str,
payload: CalendarCalDavSourceUpdateRequest,
user_id: str | None = None,
api_key_id: str | None = None,
) -> 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,
user_id=user_id,
api_key_id=api_key_id,
)
def delete_sync_source(
session: Session,
*,
tenant_id: str,
source_id: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> 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(),
deletion_reason="sync_source_deleted",
user_id=user_id,
api_key_id=api_key_id,
)
session.flush()
def delete_caldav_source(
session: Session,
*,
tenant_id: str,
source_id: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> 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(),
deletion_reason="sync_source_deleted",
user_id=user_id,
api_key_id=api_key_id,
)
session.flush()
def retire_sync_sources_for_calendar(
session: Session,
*,
tenant_id: str,
calendar_id: str,
deleted_at: datetime,
deletion_reason: str = "calendar_deleted",
user_id: str | None = None,
api_key_id: str | None = None,
) -> 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,
deletion_reason=deletion_reason,
user_id=user_id,
api_key_id=api_key_id,
)
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,
deletion_reason="stale_sync_source_retired",
)
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,
deletion_reason: str = "sync_source_retired",
user_id: str | None = None,
api_key_id: str | None = None,
) -> None:
from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease,
cancel_calendar_outbox_for_source,
)
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == source.id,
CalendarSyncSource.tenant_id == tenant_id,
)
.populate_existing()
.with_for_update()
.one()
)
if calendar_outbox_has_live_lease(session, source_id=source.id):
raise CalendarError(
"CalDAV source cannot be retired while an outbound operation has an active lease"
)
# Delete credentials first. An external secret provider cannot participate
# in the SQL transaction, so provider failure must leave the source and its
# queued work untouched. If the later SQL commit fails after provider
# success, the source fails closed and the idempotent delete can be retried.
delete_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
deletion_reason=deletion_reason,
user_id=user_id,
api_key_id=api_key_id,
)
source.credential_ref = None
cancel_calendar_outbox_for_source(
session,
source_id=source.id,
reason="CalDAV source was retired before queued external changes were delivered",
)
source.deleted_at = deleted_at
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,
api_key_id: str | None = None,
) -> str:
existing = internal_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
replacing_existing = existing is not None
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()
old_provider_ref = _credential_provider_ref(existing)
provider = secret_provider()
name = f"caldav:{source.id}:{source.auth_type}"
if old_provider_ref and provider is None:
raise CalendarError(
"Stored sync credential cannot be replaced while its secret provider is unavailable"
)
if provider is not None:
try:
provider_ref = str(
provider.store_secret(
scope=f"calendar:{tenant_id}",
name=name,
value=secret,
)
).strip()
except Exception as exc:
raise CalendarError("Stored sync credential could not be written to its secret provider") from exc
if not provider_ref:
raise CalendarError("Secret provider returned an empty credential reference")
if old_provider_ref and old_provider_ref != provider_ref:
try:
provider.delete_secret(old_provider_ref)
except Exception as exc:
if not _provider_secret_is_absent(provider, old_provider_ref):
compensation_failed = False
try:
provider.delete_secret(provider_ref)
except Exception:
compensation_failed = not _provider_secret_is_absent(provider, provider_ref)
compensation_detail = (
"; replacement cleanup also failed and requires provider-side reconciliation"
if compensation_failed
else ""
)
raise CalendarError(
"Previous sync credential could not be deleted from its secret provider; "
f"the replacement was not activated{compensation_detail}"
) from exc
existing.secret_encrypted = None
existing.metadata_ = {"source_id": source.id, "provider_ref": provider_ref}
else:
existing.secret_encrypted = encrypt_secret(secret)
existing.metadata_ = {"source_id": source.id}
existing.credential_kind = credential_kind_for_auth_type(source.auth_type)
existing.label = source.display_name or source.collection_url
existing.deleted_at = None
if replacing_existing:
audit_event(
session,
tenant_id=tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action="calendar.sync_credential_rotated",
object_type="calendar_sync_credential",
object_id=existing.id,
details={
"sync_source_id": source.id,
"storage_backend": (
"external_secret_provider"
if provider is not None
else "encrypted_database"
),
},
)
session.flush()
return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}"
def delete_caldav_credential(
session: Session,
*,
tenant_id: str,
source_id: str,
credential_ref: str | None,
deletion_reason: str = "sync_source_retired",
user_id: str | None = None,
api_key_id: str | None = None,
) -> bool:
if not credential_ref:
return False
credential = internal_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source_id,
credential_ref=credential_ref,
)
if credential is None:
# Legacy external/env references have no locally provable ownership and
# must never be passed to a provider delete operation.
return False
return _delete_calendar_owned_credential(
session,
credential=credential,
source_id=source_id,
deletion_reason=deletion_reason,
user_id=user_id,
api_key_id=api_key_id,
)
def _delete_calendar_owned_credential(
session: Session,
*,
credential: CalendarSyncCredential,
source_id: str | None,
deletion_reason: str,
user_id: str | None = None,
api_key_id: str | None = None,
) -> bool:
provider_ref = _credential_provider_ref(credential)
if not credential.secret_encrypted and not provider_ref:
return False
provider = secret_provider()
storage_backend = "encrypted_database"
if provider_ref:
if provider is None:
raise CalendarError(
"Stored sync credential cannot be deleted while its secret provider is unavailable; "
"the requested deletion was not completed"
)
try:
provider.delete_secret(provider_ref)
except Exception as exc:
if not _provider_secret_is_absent(provider, provider_ref):
raise CalendarError(
"Stored sync credential could not be deleted from its secret provider; "
"the requested deletion was not completed"
) from exc
storage_backend = "external_secret_provider"
credential.secret_encrypted = None
credential.metadata_ = {"source_id": source_id} if source_id else {}
credential.deleted_at = utcnow()
audit_event(
session,
tenant_id=credential.tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action="calendar.sync_credential_deleted",
object_type="calendar_sync_credential",
object_id=credential.id,
details={
"sync_source_id": source_id,
"storage_backend": storage_backend,
"deletion_reason": deletion_reason,
},
)
return True
def _provider_secret_is_absent(provider: Any, provider_ref: str) -> bool:
"""Confirm an idempotent delete when a provider reports missing/error."""
try:
return provider.read_secret(provider_ref) is None
except Exception:
return False
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
credential = internal_caldav_credential(
session,
tenant_id=source.tenant_id,
source_id=source.id,
credential_ref=source.credential_ref,
)
if credential is None:
raise CalendarError(
"The sync source credential reference is not a server-owned credential for this tenant/source"
)
provider_ref = _credential_provider_ref(credential)
if provider_ref:
provider = secret_provider()
if provider is None:
return None
return provider.read_secret(provider_ref)
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
def resolve_caldav_credential_ref(
session: Session,
*,
tenant_id: str,
source_id: str,
credential_ref: str,
) -> str | None:
credential = internal_caldav_credential(
session,
tenant_id=tenant_id,
source_id=source_id,
credential_ref=credential_ref,
)
if credential is None:
return None
provider_ref = _credential_provider_ref(credential)
if provider_ref:
provider = secret_provider()
return provider.read_secret(provider_ref) if provider is not None else None
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
def resolve_trusted_deployment_caldav_credential_ref(credential_ref: str) -> str | None:
"""Resolve an env reference only for trusted, out-of-band deployment code.
HTTP/API source configuration deliberately never calls this function.
"""
if not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
raise CalendarError("Trusted deployment credential references must use the env: prefix")
env_name = credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
raise CalendarError("Trusted deployment credential reference contains an invalid environment variable name")
return os.environ.get(env_name)
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,
credential_origin: str | None = None,
) -> 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")
credential_origin = validate_http_url(
credential_origin or url,
label="Calendar credential origin",
)
url = same_origin_http_url(
credential_origin,
url,
label="Calendar source request URL",
)
try:
url = validate_outbound_http_url(url, label="Calendar source request URL")
except OutboundHttpError as exc:
raise CalendarError(str(exc)) from exc
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=data,
method=method,
headers=headers or {},
)
opener = build_outbound_http_opener(_SameOriginRedirectHandler(credential_origin))
try:
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated, origin-confined Calendar 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 = bounded_response_bytes(
response,
headers=response_headers,
label="Calendar source response",
).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()}, ""
try:
detail = bounded_response_bytes(
exc,
headers={key.lower(): value for key, value in exc.headers.items()},
label="Calendar source error response",
).decode("utf-8", errors="replace")
except OutboundHttpError as policy_exc:
raise CalendarError(str(policy_exc)) from policy_exc
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,
source_id: str | None = None,
) -> CalendarSyncCredential | None:
if not credential_ref or not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
return None
credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
credential = (
session.query(CalendarSyncCredential)
.filter(
CalendarSyncCredential.tenant_id == tenant_id,
CalendarSyncCredential.id == credential_id,
CalendarSyncCredential.deleted_at.is_(None),
)
.first()
)
if credential is None or source_id is None:
return credential
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
return credential if metadata.get("source_id") == source_id else None
def _credential_provider_ref(credential: CalendarSyncCredential) -> str | None:
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
value = metadata.get("provider_ref")
return str(value) if isinstance(value, str) and value.strip() else None
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)
# Serialize inbound application with local desired-state snapshots and
# outbound delivery for this source. The lock intentionally spans the
# REPORT and its application so an older report cannot land after a newer
# outbound write.
source = (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.id == source.id,
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.source_kind == "caldav",
CalendarSyncSource.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
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):
next_url = same_origin_http_url(
source.collection_url,
next_url,
label="Microsoft Graph continuation URL",
)
_status, _headers, body = http_request(
next_url,
headers=headers,
credential_origin=source.collection_url,
)
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")
raw_delta_link = payload.get("@odata.deltaLink")
if raw_delta_link:
delta_link = same_origin_http_url(
source.collection_url,
str(raw_delta_link),
label="Microsoft Graph delta URL",
)
if not next_link:
break
next_url = same_origin_http_url(
source.collection_url,
str(next_link),
label="Microsoft Graph continuation URL",
)
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 effective_source_kind == "caldav" and event.source_href:
from govoplan_calendar.backend.outbox import calendar_outbox_has_active_desired_state
if calendar_outbox_has_active_desired_state(
session,
source_id=source.id,
href=event.source_href,
):
continue
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)
from govoplan_calendar.backend.outbox import calendar_outbox_has_active_desired_state
if calendar_outbox_has_active_desired_state(session, source_id=source.id, href=href):
# Do not let an older remote representation erase a committed
# local desired state while its outbound operation is pending.
seen_hrefs.add(href)
stats.unchanged += 1
continue
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:
collection = ensure_collection_url(collection_url)
candidate = urllib.parse.urljoin(collection, href)
collection_parts = urllib.parse.urlparse(collection)
candidate_parts = urllib.parse.urlparse(candidate)
if (
candidate_parts.username
or candidate_parts.password
or (
candidate_parts.scheme.lower(),
candidate_parts.hostname,
candidate_parts.port,
)
!= (
collection_parts.scheme.lower(),
collection_parts.hostname,
collection_parts.port,
)
):
raise CalendarError("CalDAV resource href must use the configured collection origin")
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
raise CalendarError("CalDAV resource href must remain inside the configured collection path")
if candidate_parts.query or candidate_parts.fragment:
raise CalendarError("CalDAV resource href must not contain a query or fragment")
return urllib.parse.urlunparse(candidate_parts)
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]:
has_server_owned_credential = bool(
source.credential_ref and source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
)
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": None,
"has_credential": has_server_owned_credential,
"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,
for_update=True,
)
assert_sync_mutation_allowed(source)
supplied_sync_fields = bool(
payload.source_href is not None
or payload.etag is not None
or (
"source_kind" in payload.model_fields_set
and payload.source_kind != "local"
)
)
if source is not None and supplied_sync_fields:
raise CalendarError(
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
)
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):
from govoplan_calendar.backend.outbox import enqueue_caldav_put
enqueue_caldav_put(session, source=source, event_model=event)
record_calendar_event_change(session, event=event, operation="created", user_id=user_id)
return event
def _event_update_calendar_ids(
session: Session,
*,
tenant_id: str,
event_id: str,
payload: CalendarEventUpdateRequest,
) -> tuple[str, str]:
event_locator = (
session.query(CalendarEvent.calendar_id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.id == event_id,
CalendarEvent.deleted_at.is_(None),
)
.first()
)
if event_locator is None:
raise CalendarError("Calendar event not found")
original_calendar_id = event_locator[0]
target_calendar_id = payload.calendar_id or original_calendar_id
if payload.calendar_id is not None:
get_calendar(session, tenant_id=tenant_id, calendar_id=target_calendar_id)
return original_calendar_id, target_calendar_id
def _lock_event_update_sources(
session: Session,
*,
tenant_id: str,
original_calendar_id: str,
target_calendar_id: str,
) -> tuple[CalendarSyncSource | None, CalendarSyncSource | None]:
original_source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=original_calendar_id,
)
target_source = (
original_source
if target_calendar_id == original_calendar_id
else active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar_id,
)
)
locked_sources = lock_sync_sources(session, original_source, target_source)
if original_source is not None:
original_source = locked_sources[original_source.id]
if target_source is not None:
target_source = locked_sources[target_source.id]
return original_source, target_source
def _assert_event_update_sync_fields(
payload: CalendarEventUpdateRequest,
*,
original_source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
) -> None:
supplied_sync_fields = {"source_kind", "source_href", "etag"} & payload.model_fields_set
if (original_source is not None or target_source is not None) and supplied_sync_fields:
raise CalendarError(
"source_kind, source_href, and etag are sync-owned fields on synchronized calendars"
)
def _move_event_for_update(
session: Session,
*,
event: CalendarEvent,
payload: CalendarEventUpdateRequest,
original_source: CalendarSyncSource | None,
target_source: CalendarSyncSource | None,
) -> None:
if payload.calendar_id is None:
assert_sync_mutation_allowed(original_source)
return
assert_sync_mutation_allowed(target_source)
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
if payload.calendar_id != event.calendar_id and original_source and original_caldav:
assert_sync_mutation_allowed(original_source)
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
enqueue_caldav_delete(session, source=original_source, event_model=event)
event.source_kind = "local"
event.source_href = None
event.etag = None
event.calendar_id = payload.calendar_id
def _apply_event_update_values(
event: CalendarEvent,
*,
user_id: str | None,
payload: CalendarEventUpdateRequest,
) -> None:
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:
value = (
value.upper()
if attr in {"status", "transparency", "classification"}
and isinstance(value, str)
else value
)
setattr(event, attr, 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
def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
original_calendar_id, target_calendar_id = _event_update_calendar_ids(
session,
tenant_id=tenant_id,
event_id=event_id,
payload=payload,
)
original_source, target_source = _lock_event_update_sources(
session,
tenant_id=tenant_id,
original_calendar_id=original_calendar_id,
target_calendar_id=target_calendar_id,
)
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
previous = calendar_event_change_payload(event, prefix="previous_")
_assert_event_update_sync_fields(
payload,
original_source=original_source,
target_source=target_source,
)
_move_event_for_update(
session,
event=event,
payload=payload,
original_source=original_source,
target_source=target_source,
)
_apply_event_update_values(event, user_id=user_id, payload=payload)
validate_event_time(event)
event.raw_ics = None
session.flush()
if should_push_event_to_caldav(source=target_source, event=event):
from govoplan_calendar.backend.outbox import enqueue_caldav_put
enqueue_caldav_put(session, source=target_source, event_model=event)
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_locator = (
session.query(CalendarEvent.calendar_id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.id == event_id,
CalendarEvent.deleted_at.is_(None),
)
.first()
)
if event_locator is None:
raise CalendarError("Calendar event not found")
source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=event_locator[0],
for_update=True,
)
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
previous = calendar_event_change_payload(event, prefix="previous_")
assert_sync_mutation_allowed(source)
if should_push_event_to_caldav(source=source, event=event):
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
enqueue_caldav_delete(session, source=source, event_model=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,
for_update: bool = False,
) -> CalendarSyncSource | None:
query = (
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())
)
if for_update:
query = query.populate_existing().with_for_update()
return query.first()
def lock_sync_sources(
session: Session,
*sources: CalendarSyncSource | None,
) -> dict[str, CalendarSyncSource]:
"""Lock source rows in stable order before producing desired snapshots."""
source_ids = sorted({source.id for source in sources if source is not None})
if not source_ids:
return {}
locked_sources = (
session.query(CalendarSyncSource)
.filter(CalendarSyncSource.id.in_(source_ids))
.order_by(CalendarSyncSource.id.asc())
.populate_existing()
.with_for_update()
.all()
)
return {source.id: source for source in locked_sources}
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.deleted_at is not None:
raise CalendarError("This calendar synchronization source is no longer active")
if not source.sync_enabled:
raise CalendarError(
"This calendar synchronization source is disabled and cannot accept local changes"
)
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.deleted_at is None
and source.sync_enabled
and source.source_kind == "caldav"
and source.sync_direction == "two_way"
and event.deleted_at is None
)
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 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")
target_calendar = get_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar_id,
)
# Source creation also locks the calendar row. Holding it here makes the
# public import's provenance decision stable until create/update has queued
# its desired state.
(
session.query(CalendarCollection)
.filter(
CalendarCollection.id == target_calendar.id,
CalendarCollection.tenant_id == tenant_id,
CalendarCollection.deleted_at.is_(None),
)
.populate_existing()
.with_for_update()
.one()
)
target_source = active_sync_source_for_calendar(
session,
tenant_id=tenant_id,
calendar_id=target_calendar_id,
for_update=True,
)
synchronized_target = target_source is not None
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_values: dict[str, Any] = {
"calendar_id": target_calendar_id,
"metadata": {},
**parsed,
}
if not synchronized_target:
payload_values.update(
{
"source_kind": source_kind,
"source_href": source_href,
"etag": etag,
}
)
payload = CalendarEventCreateRequest(**payload_values)
if existing:
excluded_fields = {"uid", "recurrence_id"}
if synchronized_target:
excluded_fields.update({"source_kind", "source_href", "etag"})
update_payload = CalendarEventUpdateRequest(
**payload.model_dump(exclude=excluded_fields)
)
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 {},
}