Implement destructive CalDAV move saga
This commit is contained in:
@@ -37,10 +37,20 @@ from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNo
|
||||
from govoplan_calendar.backend.db.models import (
|
||||
CalendarCollection,
|
||||
CalendarEvent,
|
||||
CalendarMigrationBatch,
|
||||
CalendarSyncCredential,
|
||||
CalendarSyncSource,
|
||||
CalendarViewPreference,
|
||||
)
|
||||
from govoplan_calendar.backend.migrations_saga import (
|
||||
CalendarMigrationError,
|
||||
active_tenant_migration,
|
||||
assert_calendar_not_migrating,
|
||||
assert_event_not_migrating,
|
||||
assert_source_not_migrating,
|
||||
migration_source_ids_in_progress,
|
||||
start_remote_move,
|
||||
)
|
||||
from govoplan_calendar.backend.ews import (
|
||||
EwsAdapterError,
|
||||
ews_find_item_body,
|
||||
@@ -103,6 +113,42 @@ DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = {
|
||||
}
|
||||
|
||||
|
||||
def _assert_calendar_mutation_allowed(session: Session, *, tenant_id: str, calendar_id: str) -> None:
|
||||
if not hasattr(session, "query"):
|
||||
return
|
||||
try:
|
||||
assert_calendar_not_migrating(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
except CalendarMigrationError as exc:
|
||||
raise CalendarError(str(exc)) from exc
|
||||
|
||||
|
||||
def _assert_source_mutation_allowed(session: Session, *, tenant_id: str, source_id: str) -> None:
|
||||
if not hasattr(session, "query"):
|
||||
return
|
||||
try:
|
||||
assert_source_not_migrating(session, tenant_id=tenant_id, source_id=source_id)
|
||||
except CalendarMigrationError as exc:
|
||||
raise CalendarError(str(exc)) from exc
|
||||
|
||||
|
||||
def _assert_event_mutation_allowed(event: CalendarEvent) -> None:
|
||||
try:
|
||||
assert_event_not_migrating(event)
|
||||
except CalendarMigrationError as exc:
|
||||
raise CalendarError(str(exc)) from exc
|
||||
|
||||
|
||||
def _assert_default_calendar_mutation_allowed(session: Session, *, tenant_id: str) -> None:
|
||||
if not hasattr(session, "query"):
|
||||
return
|
||||
batch = active_tenant_migration(session, tenant_id=tenant_id)
|
||||
if batch is not None:
|
||||
raise CalendarError(
|
||||
"The default calendar cannot change while remote move "
|
||||
f"{batch.id} is {batch.phase}."
|
||||
)
|
||||
|
||||
|
||||
def calendar_credential_context(
|
||||
*,
|
||||
tenant_id: str,
|
||||
@@ -585,6 +631,7 @@ def create_calendar(session: Session, *, tenant_id: str, user_id: str | None, pa
|
||||
if calendar_slug_exists(session, tenant_id=tenant_id, slug=slug):
|
||||
raise CalendarError(f"Calendar slug already exists: {slug}")
|
||||
if payload.is_default:
|
||||
_assert_default_calendar_mutation_allowed(session, tenant_id=tenant_id)
|
||||
clear_default_calendar(session, tenant_id=tenant_id)
|
||||
calendar = CalendarCollection(
|
||||
tenant_id=tenant_id,
|
||||
@@ -607,7 +654,9 @@ def create_calendar(session: Session, *, tenant_id: str, user_id: str | None, pa
|
||||
|
||||
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)
|
||||
_assert_calendar_mutation_allowed(session, tenant_id=tenant_id, calendar_id=calendar.id)
|
||||
if payload.is_default is True:
|
||||
_assert_default_calendar_mutation_allowed(session, tenant_id=tenant_id)
|
||||
clear_default_calendar(session, tenant_id=tenant_id)
|
||||
calendar.is_default = True
|
||||
elif payload.is_default is False:
|
||||
@@ -680,15 +729,14 @@ def _validate_calendar_move_source_pair(
|
||||
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:
|
||||
if payload.external_action == "remote_move":
|
||||
return
|
||||
raise CalendarError(
|
||||
"Events cannot be bulk-moved between synchronized calendars; "
|
||||
"external_action='remote_move' remains unsupported"
|
||||
"Moving events between synchronized calendars requires external_action='remote_move'"
|
||||
)
|
||||
if payload.external_action == "remote_move":
|
||||
raise CalendarError("external_action='remote_move' requires synchronized source and target calendars")
|
||||
|
||||
|
||||
def _prepare_calendar_move_external_action(
|
||||
@@ -700,6 +748,8 @@ def _prepare_calendar_move_external_action(
|
||||
target_source: CalendarSyncSource | None,
|
||||
deleted_at: datetime,
|
||||
) -> None:
|
||||
if source is not None and target_source is not None:
|
||||
return
|
||||
if source is not None:
|
||||
if payload.external_action != "detach_keep_remote":
|
||||
raise CalendarError(
|
||||
@@ -801,7 +851,9 @@ def _move_calendar_before_delete(
|
||||
calendar: CalendarCollection,
|
||||
payload: CalendarCollectionDeleteRequest,
|
||||
deleted_at: datetime,
|
||||
) -> CalendarCollection:
|
||||
user_id: str | None,
|
||||
api_key_id: str | None,
|
||||
) -> tuple[CalendarCollection, CalendarMigrationBatch | None]:
|
||||
if not payload.target_calendar_id:
|
||||
raise CalendarError("Target calendar is required when moving events")
|
||||
if payload.target_calendar_id == calendar.id:
|
||||
@@ -817,6 +869,8 @@ def _move_calendar_before_delete(
|
||||
calendar=calendar,
|
||||
target_calendar=target_calendar,
|
||||
)
|
||||
_assert_calendar_mutation_allowed(session, tenant_id=tenant_id, calendar_id=calendar.id)
|
||||
_assert_calendar_mutation_allowed(session, tenant_id=tenant_id, calendar_id=target_calendar.id)
|
||||
_validate_calendar_move_source_pair(
|
||||
payload,
|
||||
source=source,
|
||||
@@ -826,6 +880,26 @@ def _move_calendar_before_delete(
|
||||
session,
|
||||
calendar,
|
||||
)
|
||||
if source is not None and target_source is not None:
|
||||
try:
|
||||
batch = start_remote_move(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_calendar=calendar,
|
||||
target_calendar=target_calendar,
|
||||
source=source,
|
||||
target_source=target_source,
|
||||
events=active_events,
|
||||
previous_event_states=previous_event_states,
|
||||
make_target_default=payload.make_target_default,
|
||||
confirmation=payload.destructive_confirmation,
|
||||
evidence_note=payload.evidence_note,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
except CalendarMigrationError as exc:
|
||||
raise CalendarError(str(exc)) from exc
|
||||
return calendar, batch
|
||||
_prepare_calendar_move_external_action(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -844,7 +918,7 @@ def _move_calendar_before_delete(
|
||||
active_events=active_events,
|
||||
previous_event_states=previous_event_states,
|
||||
)
|
||||
return calendar
|
||||
return calendar, None
|
||||
|
||||
|
||||
def delete_calendar(
|
||||
@@ -855,22 +929,29 @@ def delete_calendar(
|
||||
payload: CalendarCollectionDeleteRequest | None = None,
|
||||
user_id: str | None = None,
|
||||
api_key_id: str | None = None,
|
||||
) -> None:
|
||||
) -> CalendarMigrationBatch | None:
|
||||
payload = payload or CalendarCollectionDeleteRequest()
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
_assert_calendar_mutation_allowed(session, tenant_id=tenant_id, calendar_id=calendar.id)
|
||||
deleted_at = utcnow()
|
||||
migration_batch: CalendarMigrationBatch | None = None
|
||||
if payload.event_action == "move":
|
||||
calendar = _move_calendar_before_delete(
|
||||
calendar, migration_batch = _move_calendar_before_delete(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar=calendar,
|
||||
payload=payload,
|
||||
deleted_at=deleted_at,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
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 migration_batch is not None:
|
||||
session.flush()
|
||||
return migration_batch
|
||||
if calendar.is_default:
|
||||
calendar.is_default = False
|
||||
calendar.deleted_at = deleted_at
|
||||
@@ -887,6 +968,7 @@ def delete_calendar(
|
||||
if payload.event_action == "delete" and event.deleted_at is None:
|
||||
event.deleted_at = deleted_at
|
||||
session.flush()
|
||||
return None
|
||||
|
||||
|
||||
def get_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarCollection:
|
||||
@@ -1641,6 +1723,7 @@ def update_sync_source(
|
||||
api_key_id: str | None = None,
|
||||
) -> CalendarSyncSource:
|
||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
||||
_assert_source_mutation_allowed(session, tenant_id=tenant_id, source_id=source.id)
|
||||
source = _lock_sync_source_for_update(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -1736,6 +1819,7 @@ def delete_sync_source(
|
||||
api_key_id: str | None = None,
|
||||
) -> None:
|
||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id)
|
||||
_assert_source_mutation_allowed(session, tenant_id=tenant_id, source_id=source.id)
|
||||
retire_sync_source(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -1757,6 +1841,7 @@ def delete_caldav_source(
|
||||
api_key_id: str | None = None,
|
||||
) -> None:
|
||||
source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav")
|
||||
_assert_source_mutation_allowed(session, tenant_id=tenant_id, source_id=source.id)
|
||||
retire_sync_source(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -2317,6 +2402,8 @@ def sync_source(
|
||||
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)
|
||||
_assert_source_mutation_allowed(session, tenant_id=tenant_id, source_id=source.id)
|
||||
# Sync is an explicit unit-of-work boundary. Commit configuration changes
|
||||
# and release any request/authentication transaction before remote I/O.
|
||||
session.commit()
|
||||
@@ -2569,6 +2656,7 @@ def _prepare_caldav_sync(
|
||||
tenant_id=tenant_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
_assert_source_mutation_allowed(preparation_session, tenant_id=tenant_id, source_id=source.id)
|
||||
get_calendar(
|
||||
preparation_session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -2710,6 +2798,7 @@ def _prepare_remote_sync(
|
||||
tenant_id=tenant_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
_assert_source_mutation_allowed(preparation_session, tenant_id=tenant_id, source_id=source.id)
|
||||
if source.source_kind not in expected_kinds:
|
||||
expected = "/".join(sorted(expected_kinds))
|
||||
raise CalendarError(f"{expected} sync source not found")
|
||||
@@ -3354,6 +3443,9 @@ def _sync_due_sources(
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(CalendarSyncSource.tenant_id == tenant_id)
|
||||
migrating_source_ids = migration_source_ids_in_progress(session, tenant_id=tenant_id)
|
||||
if migrating_source_ids:
|
||||
query = query.filter(CalendarSyncSource.id.notin_(sorted(migrating_source_ids)))
|
||||
sources = (
|
||||
query.order_by(
|
||||
CalendarSyncSource.next_sync_at.asc(),
|
||||
@@ -4095,7 +4187,8 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo
|
||||
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)
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
_assert_calendar_mutation_allowed(session, tenant_id=tenant_id, calendar_id=calendar.id)
|
||||
source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -4180,6 +4273,9 @@ def _event_update_calendar_ids(
|
||||
raise CalendarError("Calendar event not found")
|
||||
original_calendar_id = event_locator[0]
|
||||
target_calendar_id = payload.calendar_id or original_calendar_id
|
||||
_assert_calendar_mutation_allowed(session, tenant_id=tenant_id, calendar_id=original_calendar_id)
|
||||
if target_calendar_id != original_calendar_id:
|
||||
_assert_calendar_mutation_allowed(session, tenant_id=tenant_id, calendar_id=target_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
|
||||
@@ -4317,6 +4413,7 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
||||
target_calendar_id=target_calendar_id,
|
||||
)
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
_assert_event_mutation_allowed(event)
|
||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||
_assert_event_update_sync_fields(
|
||||
payload,
|
||||
@@ -4355,6 +4452,7 @@ def update_event_occurrence(
|
||||
tenant_id=tenant_id,
|
||||
event_id=series_event_id,
|
||||
)
|
||||
_assert_event_mutation_allowed(master)
|
||||
if master.recurrence_id is not None or not (master.rrule or master.rdate):
|
||||
raise CalendarError("Calendar event is not a recurring series master")
|
||||
occurrence = recurrence_occurrence(master, payload.recurrence_id)
|
||||
@@ -4552,6 +4650,7 @@ def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: st
|
||||
for_update=True,
|
||||
)
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
_assert_event_mutation_allowed(event)
|
||||
assert_sync_mutation_allowed(source)
|
||||
series_events = [event]
|
||||
if event.recurrence_id is None and (event.rrule or event.rdate):
|
||||
|
||||
Reference in New Issue
Block a user