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
|
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:
|
def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None:
|
||||||
payload = payload or CalendarCollectionDeleteRequest()
|
payload = payload or CalendarCollectionDeleteRequest()
|
||||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id)
|
||||||
deleted_at = utcnow()
|
deleted_at = utcnow()
|
||||||
if payload.event_action == "move":
|
if payload.event_action == "move":
|
||||||
if not payload.target_calendar_id:
|
calendar = _move_calendar_before_delete(
|
||||||
raise CalendarError("Target calendar is required when moving events")
|
session,
|
||||||
if payload.target_calendar_id == calendar_id:
|
tenant_id=tenant_id,
|
||||||
raise CalendarError("Target calendar must be different from the deleted calendar")
|
calendar=calendar,
|
||||||
target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id)
|
payload=payload,
|
||||||
source = None
|
deleted_at=deleted_at,
|
||||||
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":
|
elif payload.event_action != "delete":
|
||||||
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
|
raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}")
|
||||||
elif payload.external_action is not None:
|
elif payload.external_action is not None:
|
||||||
|
|||||||
Reference in New Issue
Block a user