feat(calendar): make external mutations durable
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user