Refactor synchronized calendar deletion
This commit is contained in:
@@ -485,135 +485,243 @@ def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
||||
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) -> None:
|
||||
payload = payload or CalendarCollectionDeleteRequest()
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||
deleted_at = utcnow()
|
||||
if payload.event_action == "move":
|
||||
if not payload.target_calendar_id:
|
||||
raise CalendarError("Target calendar is required when moving events")
|
||||
if payload.target_calendar_id == calendar_id:
|
||||
raise CalendarError("Target calendar must be different from the deleted calendar")
|
||||
target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id)
|
||||
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 {}
|
||||
calendar = _move_calendar_before_delete(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar=calendar,
|
||||
payload=payload,
|
||||
deleted_at=deleted_at,
|
||||
)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user