feat(calendar): make external mutations durable

This commit is contained in:
2026-07-20 16:58:45 +02:00
parent 371a00aff5
commit a6cd64e3f9
15 changed files with 4726 additions and 152 deletions

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
@@ -35,6 +36,9 @@ from govoplan_calendar.backend.schemas import (
CalendarFreeBusyRequest,
CalendarFreeBusyResponse,
CalendarIcsImportRequest,
CalendarOutboxDispatchResponse,
CalendarOutboxOperationListResponse,
CalendarOutboxOperationResponse,
CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest,
@@ -44,6 +48,14 @@ from govoplan_calendar.backend.schemas import (
CalendarSyncSourceSyncResponse,
CalendarSyncSourceUpdateRequest,
)
from govoplan_calendar.backend.outbox import (
calendar_outbox_operation_response,
discard_calendar_outbox_operation,
dispatch_calendar_outbox,
list_calendar_outbox_operations,
reconcile_calendar_outbox_operation,
retry_calendar_outbox_operation,
)
from govoplan_calendar.backend.service import (
CALENDAR_EVENTS_COLLECTION,
CALENDAR_EVENT_RESOURCE,
@@ -62,7 +74,6 @@ from govoplan_calendar.backend.service import (
delete_event,
discover_caldav_calendars,
event_response,
get_caldav_source,
get_event,
import_ics_event,
list_freebusy,
@@ -220,13 +231,25 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
def _outbox_operation_response(operation) -> CalendarOutboxOperationResponse:
return CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(operation)
)
@router.get("/calendars", response_model=CalendarCollectionListResponse)
def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:read")
calendars = list_calendars(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
calendars = list_calendars(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_admin=principal.has("calendar:calendar:admin"),
)
session.commit()
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
@@ -343,7 +366,12 @@ def api_delete_sync_source(
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/sync-sources/{source_id}/sync", response_model=CalendarSyncSourceSyncResponse)
@@ -473,7 +501,12 @@ def api_delete_caldav_source(
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
@@ -530,6 +563,117 @@ def api_sync_due_caldav_sources(
)
@router.get("/caldav/outbox", response_model=CalendarOutboxOperationListResponse)
def api_list_caldav_outbox(
operation_status: str | None = Query(default=None, alias="status"),
source_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
operations = list_calendar_outbox_operations(
session,
tenant_id=principal.tenant_id,
status=operation_status,
source_id=source_id,
limit=limit,
)
return CalendarOutboxOperationListResponse(
operations=[_outbox_operation_response(operation) for operation in operations]
)
@router.post("/caldav/outbox/dispatch", response_model=CalendarOutboxDispatchResponse)
def api_dispatch_caldav_outbox(
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarOutboxDispatchResponse.model_validate(
dispatch_calendar_outbox(
session,
tenant_id=principal.tenant_id,
limit=limit,
)
)
@router.post(
"/caldav/outbox/{operation_id}/retry",
response_model=CalendarOutboxOperationResponse,
)
def api_retry_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = retry_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/reconcile",
response_model=CalendarOutboxOperationResponse,
)
def api_reconcile_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = reconcile_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except (ValueError, CalDAVError, CalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/discard",
response_model=CalendarOutboxOperationResponse,
)
def api_discard_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
"""Abandon local desired state and allow the next sync to accept remote."""
_require_scope(principal, "calendar:calendar:admin")
try:
operation = discard_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.get("/events", response_model=CalendarEventListResponse)
def api_list_events(
calendar_id: str | None = None,
@@ -600,7 +744,7 @@ def api_create_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit()
@@ -631,7 +775,7 @@ def api_update_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
session.commit()
@@ -648,7 +792,7 @@ def api_delete_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
_require_any_scope(principal, "calendar:event:delete", CALENDAR_EVENT_WRITE_SCOPE)
try:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
session.commit()
@@ -713,7 +857,7 @@ def api_freebusy(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:availability:read")
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
busy = list_freebusy(
session,