Refactor calendar sync source updates

This commit is contained in:
2026-07-21 12:33:34 +02:00
parent d10570fc0c
commit baf624c7a6

View File

@@ -922,12 +922,16 @@ def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | Non
return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload) return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource: def _lock_sync_source_for_update(
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) session: Session,
source = ( *,
tenant_id: str,
source_id: str,
) -> CalendarSyncSource:
return (
session.query(CalendarSyncSource) session.query(CalendarSyncSource)
.filter( .filter(
CalendarSyncSource.id == source.id, CalendarSyncSource.id == source_id,
CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.deleted_at.is_(None), CalendarSyncSource.deleted_at.is_(None),
) )
@@ -935,32 +939,68 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
.with_for_update() .with_for_update()
.one() .one()
) )
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" def _sync_source_reconfiguration(
) source: CalendarSyncSource,
payload: CalendarSyncSourceUpdateRequest,
) -> tuple[str, bool, bool]:
normalized_collection_url = ( normalized_collection_url = (
normalize_sync_source_url(source.source_kind, payload.collection_url) normalize_sync_source_url(source.source_kind, payload.collection_url)
if payload.collection_url is not None if payload.collection_url is not None
else source.collection_url 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( materially_reconfigured = bool(
source.source_kind == "caldav" source.source_kind == "caldav"
and ( and (
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id) calendar_changed
or normalized_collection_url != source.collection_url or endpoint_changed
or payload.sync_enabled is False or payload.sync_enabled is False
or payload.sync_direction == "inbound" or payload.sync_direction == "inbound"
) )
) )
endpoint_or_calendar_changed = bool( return (
source.source_kind == "caldav" normalized_collection_url,
and ( materially_reconfigured,
(payload.calendar_id is not None and payload.calendar_id != source.calendar_id) endpoint_or_calendar_changed,
or normalized_collection_url != source.collection_url
) )
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),
) )
if materially_reconfigured: .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 ( from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease, calendar_outbox_has_live_lease,
calendar_outbox_has_unresolved_desired_state, calendar_outbox_has_unresolved_desired_state,
@@ -971,7 +1011,6 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
raise CalendarError( raise CalendarError(
"CalDAV source cannot be materially changed while an outbound operation has an active lease" "CalDAV source cannot be materially changed while an outbound operation has an active lease"
) )
unresolved_desired_state = calendar_outbox_has_unresolved_desired_state( unresolved_desired_state = calendar_outbox_has_unresolved_desired_state(
session, session,
source_id=source.id, source_id=source.id,
@@ -993,33 +1032,36 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
"CalDAV source endpoint or calendar cannot change while unresolved local desired " "CalDAV source endpoint or calendar cannot change while unresolved local desired "
"changes exist; deliver or explicitly discard them first" "changes exist; deliver or explicitly discard them first"
) )
if endpoint_or_calendar_changed and _caldav_source_has_linked_events(
if endpoint_or_calendar_changed: session,
sourced_event_exists = ( tenant_id=tenant_id,
session.query(CalendarEvent.id) source=source,
.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( raise CalendarError(
"CalDAV source endpoint or calendar cannot change while events still reference it; " "CalDAV source endpoint or calendar cannot change while events still reference it; "
"detach or resync those events first" "detach or resync those events first"
) )
cancel_calendar_outbox_for_source( cancel_calendar_outbox_for_source(
session, session,
source_id=source.id, source_id=source.id,
reason="CalDAV source endpoint/calendar changed, was disabled, or was made inbound-only", 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)
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 = ( calendar = (
session.query(CalendarCollection) session.query(CalendarCollection)
.filter( .filter(
@@ -1042,12 +1084,17 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
.first() .first()
) )
if conflicting_source is not None: if conflicting_source is not None:
raise CalendarError( raise CalendarError("A calendar can have only one active synchronization source")
"A calendar can have only one active synchronization source"
)
source.calendar_id = calendar.id source.calendar_id = calendar.id
else: return calendar
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id)
def _apply_sync_source_values(
source: CalendarSyncSource,
*,
payload: CalendarSyncSourceUpdateRequest,
normalized_collection_url: str,
) -> None:
for field_name in ( for field_name in (
"display_name", "display_name",
"auth_type", "auth_type",
@@ -1069,15 +1116,85 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
source.metadata_ = payload.metadata source.metadata_ = payload.metadata
if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS: if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS:
source.sync_direction = "inbound" source.sync_direction = "inbound"
def _validate_sync_source_auth(source: CalendarSyncSource) -> None:
if source.source_kind == "graph" and source.auth_type != "bearer": if source.source_kind == "graph" and source.auth_type != "bearer":
raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication") raise CalendarError(
"Microsoft Graph calendar sync requires bearer token authentication"
)
if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}: if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}:
raise CalendarError("Exchange Web Services sync requires basic or bearer authentication") raise CalendarError(
credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token) "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,
) -> 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: if credential_value is not None:
source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=None, source=source, secret=credential_value) source.credential_ref = store_caldav_credential(
session,
tenant_id=tenant_id,
user_id=None,
source=source,
secret=credential_value,
)
if payload.sync_enabled is not None or payload.sync_interval_seconds is not None: 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 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) -> 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"
)
(
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,
)
mark_calendar_sync_source(calendar, source) mark_calendar_sync_source(calendar, source)
session.flush() session.flush()
return source return source