feat(calendar): make external mutations durable
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
import uuid
|
||||
import os
|
||||
@@ -9,16 +10,16 @@ import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
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_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||
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 events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents
|
||||
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,
|
||||
@@ -58,12 +59,20 @@ 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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -324,15 +333,63 @@ def get_default_calendar(session: Session, *, tenant_id: str) -> CalendarCollect
|
||||
)
|
||||
|
||||
|
||||
def list_calendars(session: Session, *, tenant_id: str, ensure_default: bool = False, user_id: str | None = None) -> list[CalendarCollection]:
|
||||
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)
|
||||
return (
|
||||
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:
|
||||
@@ -387,14 +444,129 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
||||
if payload.target_calendar_id == calendar_id:
|
||||
raise CalendarError("Target calendar must be different from the deleted calendar")
|
||||
target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id)
|
||||
for event in calendar.events:
|
||||
if event.deleted_at is None:
|
||||
event.calendar_id = target_calendar.id
|
||||
source = None
|
||||
target_source = None
|
||||
if hasattr(session, "query"):
|
||||
# Source creation takes the collection row lock before linking a
|
||||
# source. Lock both collections in stable order so the mode 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]
|
||||
|
||||
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"
|
||||
)
|
||||
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 {}
|
||||
)
|
||||
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,
|
||||
)
|
||||
elif 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)
|
||||
elif payload.external_action is not None:
|
||||
raise CalendarError(
|
||||
"external_action is only valid when moving events to or from a synchronized calendar"
|
||||
)
|
||||
|
||||
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
|
||||
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
|
||||
@@ -515,6 +687,30 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
||||
|
||||
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
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)
|
||||
@@ -562,8 +758,123 @@ def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | Non
|
||||
|
||||
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource:
|
||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
||||
source = (
|
||||
session.query(CalendarSyncSource)
|
||||
.filter(
|
||||
CalendarSyncSource.id == source.id,
|
||||
CalendarSyncSource.tenant_id == tenant_id,
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.populate_existing()
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
normalized_collection_url = (
|
||||
normalize_sync_source_url(source.source_kind, payload.collection_url)
|
||||
if payload.collection_url is not None
|
||||
else source.collection_url
|
||||
)
|
||||
materially_reconfigured = bool(
|
||||
source.source_kind == "caldav"
|
||||
and (
|
||||
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
|
||||
or normalized_collection_url != source.collection_url
|
||||
or payload.sync_enabled is False
|
||||
or payload.sync_direction == "inbound"
|
||||
)
|
||||
)
|
||||
endpoint_or_calendar_changed = bool(
|
||||
source.source_kind == "caldav"
|
||||
and (
|
||||
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id)
|
||||
or normalized_collection_url != source.collection_url
|
||||
)
|
||||
)
|
||||
if materially_reconfigured:
|
||||
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:
|
||||
sourced_event_exists = (
|
||||
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
|
||||
)
|
||||
if sourced_event_exists:
|
||||
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",
|
||||
)
|
||||
if payload.calendar_id is not None:
|
||||
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()
|
||||
)
|
||||
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
|
||||
else:
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
|
||||
@@ -583,7 +894,7 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
||||
if value is not None:
|
||||
setattr(source, field_name, value)
|
||||
if payload.collection_url is not None:
|
||||
source.collection_url = normalize_sync_source_url(source.source_kind, payload.collection_url)
|
||||
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:
|
||||
@@ -684,6 +995,31 @@ def active_caldav_source_for_url(session: Session, *, tenant_id: str, collection
|
||||
|
||||
|
||||
def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> 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"
|
||||
)
|
||||
|
||||
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
|
||||
delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
|
||||
|
||||
@@ -933,6 +1269,22 @@ def sync_caldav_source(
|
||||
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()
|
||||
@@ -1452,6 +1804,15 @@ def soft_delete_unseen_source_events(
|
||||
.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
|
||||
@@ -1714,6 +2075,14 @@ def apply_caldav_report(
|
||||
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
|
||||
@@ -1899,7 +2268,33 @@ def soft_delete_caldav_href(session: Session, *, source: CalendarSyncSource, hre
|
||||
|
||||
|
||||
def normalize_caldav_href(collection_url: str, href: str) -> str:
|
||||
return urllib.parse.urljoin(ensure_collection_url(collection_url), href)
|
||||
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:
|
||||
@@ -2044,8 +2439,25 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
|
||||
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)
|
||||
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,
|
||||
@@ -2084,24 +2496,63 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
|
||||
session.add(event)
|
||||
session.flush()
|
||||
if should_push_event_to_caldav(source=source, event=event):
|
||||
push_caldav_event_resource(session, source=source, event=event, create=True)
|
||||
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 update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent:
|
||||
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)
|
||||
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]
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||
original_calendar_id = event.calendar_id
|
||||
original_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
|
||||
original_caldav = event.source_kind == "caldav" and bool(event.source_href)
|
||||
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"
|
||||
)
|
||||
if payload.calendar_id is not None:
|
||||
get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
assert_sync_mutation_allowed(target_source)
|
||||
if payload.calendar_id != event.calendar_id and original_source and original_caldav:
|
||||
assert_sync_mutation_allowed(original_source)
|
||||
push_caldav_delete_for_event(session, source=original_source, event=event)
|
||||
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
|
||||
@@ -2149,32 +2600,52 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
||||
validate_event_time(event)
|
||||
event.raw_ics = None
|
||||
session.flush()
|
||||
target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
|
||||
if should_push_event_to_caldav(source=target_source, event=event):
|
||||
push_caldav_event_resource(
|
||||
session,
|
||||
source=target_source,
|
||||
event=event,
|
||||
create=event.source_kind != "caldav" or not event.source_href or original_calendar_id != event.calendar_id,
|
||||
)
|
||||
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_")
|
||||
source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id)
|
||||
assert_sync_mutation_allowed(source)
|
||||
if should_push_event_to_caldav(source=source, event=event):
|
||||
push_caldav_delete_for_event(session, source=source, event=event)
|
||||
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) -> CalendarSyncSource | None:
|
||||
return (
|
||||
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,
|
||||
@@ -2182,8 +2653,30 @@ def active_sync_source_for_calendar(session: Session, *, tenant_id: str, calenda
|
||||
CalendarSyncSource.deleted_at.is_(None),
|
||||
)
|
||||
.order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
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:
|
||||
@@ -2203,6 +2696,12 @@ def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calen
|
||||
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")
|
||||
|
||||
@@ -2212,63 +2711,14 @@ def assert_caldav_mutation_allowed(source: CalendarSyncSource | None) -> None:
|
||||
|
||||
|
||||
def should_push_event_to_caldav(*, source: CalendarSyncSource | None, event: CalendarEvent) -> bool:
|
||||
return bool(source and source.source_kind == "caldav" and source.sync_direction == "two_way" and event.deleted_at is None)
|
||||
|
||||
|
||||
def push_caldav_event_resource(session: Session, *, source: CalendarSyncSource, event: CalendarEvent, create: bool) -> None:
|
||||
href = event.source_href or generated_caldav_href(session, source=source, event=event)
|
||||
full_href = normalize_caldav_href(source.collection_url, href)
|
||||
resource_events = caldav_resource_events(session, source=source, href=full_href) if event.source_href else [event]
|
||||
if event not in resource_events:
|
||||
resource_events.append(event)
|
||||
resource_events = sorted(resource_events, key=caldav_resource_sort_key)
|
||||
ics = events_to_ics(resource_events)
|
||||
client = caldav_client_for_source(session, source)
|
||||
overwrite = source.conflict_policy == "overwrite"
|
||||
etag = None if create else resource_etag(resource_events)
|
||||
try:
|
||||
result = client.put_object(full_href, ics, etag=etag, create=create, overwrite=overwrite)
|
||||
except CalDAVPreconditionFailed as exc:
|
||||
raise CalendarError("CalDAV resource changed remotely; sync the calendar before saving again") from exc
|
||||
except CalDAVError as exc:
|
||||
raise CalendarError(f"CalDAV write failed: {exc}") from exc
|
||||
next_etag = result.etag
|
||||
for item in resource_events:
|
||||
apply_caldav_write_metadata(item, source=source, href=full_href, etag=next_etag, raw_ics=ics)
|
||||
source.last_status = "outbound_ok"
|
||||
source.last_error = None
|
||||
schedule_next_caldav_sync(source)
|
||||
session.flush()
|
||||
|
||||
|
||||
def push_caldav_delete_for_event(session: Session, *, source: CalendarSyncSource, event: CalendarEvent) -> None:
|
||||
if not event.source_href:
|
||||
return
|
||||
full_href = normalize_caldav_href(source.collection_url, event.source_href)
|
||||
remaining = [
|
||||
item
|
||||
for item in caldav_resource_events(session, source=source, href=full_href)
|
||||
if item.id != event.id
|
||||
]
|
||||
client = caldav_client_for_source(session, source)
|
||||
overwrite = source.conflict_policy == "overwrite"
|
||||
try:
|
||||
if remaining:
|
||||
remaining = sorted(remaining, key=caldav_resource_sort_key)
|
||||
ics = events_to_ics(remaining)
|
||||
result = client.put_object(full_href, ics, etag=event.etag or resource_etag(remaining), create=False, overwrite=overwrite)
|
||||
for item in remaining:
|
||||
apply_caldav_write_metadata(item, source=source, href=full_href, etag=result.etag, raw_ics=ics)
|
||||
else:
|
||||
client.delete_object(full_href, etag=event.etag, overwrite=overwrite)
|
||||
except CalDAVPreconditionFailed as exc:
|
||||
raise CalendarError("CalDAV resource changed remotely; sync the calendar before deleting again") from exc
|
||||
except CalDAVError as exc:
|
||||
raise CalendarError(f"CalDAV delete failed: {exc}") from exc
|
||||
source.last_status = "outbound_ok"
|
||||
source.last_error = None
|
||||
schedule_next_caldav_sync(source)
|
||||
session.flush()
|
||||
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]:
|
||||
@@ -2312,16 +2762,6 @@ def generated_caldav_href(session: Session, *, source: CalendarSyncSource, event
|
||||
return normalize_caldav_href(source.collection_url, f"{safe_uid}-{event.id}.ics")
|
||||
|
||||
|
||||
def apply_caldav_write_metadata(event: CalendarEvent, *, source: CalendarSyncSource, href: str, etag: str | None, raw_ics: str) -> None:
|
||||
event.source_kind = "caldav"
|
||||
event.source_href = href
|
||||
event.etag = etag
|
||||
event.raw_ics = raw_ics
|
||||
metadata = dict(event.metadata_ or {})
|
||||
metadata["caldav"] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag}
|
||||
event.metadata_ = metadata
|
||||
|
||||
|
||||
def import_ics_event(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -2340,6 +2780,32 @@ def import_ics_event(
|
||||
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 = (
|
||||
@@ -2353,16 +2819,27 @@ def import_ics_event(
|
||||
)
|
||||
.first()
|
||||
)
|
||||
payload = CalendarEventCreateRequest(
|
||||
calendar_id=target_calendar_id,
|
||||
source_kind=source_kind,
|
||||
source_href=source_href,
|
||||
etag=etag,
|
||||
metadata={},
|
||||
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:
|
||||
update_payload = CalendarEventUpdateRequest(**payload.model_dump(exclude={"uid", "recurrence_id"}))
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user