from __future__ import annotations from datetime import datetime from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status 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 from govoplan_calendar.backend.caldav import CalDAVError from govoplan_calendar.backend.schemas import ( CalendarCalDavDiscoveryRequest, CalendarCalDavDiscoveryResponse, CalendarCalDavDueSyncItemResponse, CalendarCalDavDueSyncResponse, CalendarCalDavSourceCreateRequest, CalendarCalDavSourceListResponse, CalendarCalDavSourceResponse, CalendarCalDavSourceUpdateRequest, CalendarCalDavSyncRequest, CalendarCalDavSyncResponse, CalendarCollectionCreateRequest, CalendarCollectionDeleteRequest, CalendarCollectionListResponse, CalendarCollectionResponse, CalendarCollectionUpdateRequest, CalendarCredentialEnvelopeListResponse, CalendarCredentialEnvelopeResponse, CalendarEventCreateRequest, CalendarEventDeltaResponse, CalendarEventListResponse, CalendarEventResponse, CalendarEventUpdateRequest, CalendarFreeBusyRequest, CalendarFreeBusyResponse, CalendarIcsImportRequest, CalendarOutboxDispatchResponse, CalendarOutboxOperationListResponse, CalendarOutboxOperationResponse, CalendarSyncDueSyncItemResponse, CalendarSyncDueSyncResponse, CalendarSyncSourceCreateRequest, CalendarSyncSourceListResponse, CalendarSyncSourceResponse, CalendarSyncSourceSyncRequest, 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, CALENDAR_MODULE_ID, CalendarError, available_calendar_credentials, caldav_source_response, caldav_sync_response, calendar_response, create_calendar, create_caldav_source, create_sync_source, create_event, delete_calendar, delete_caldav_source, delete_sync_source, delete_event, discover_caldav_calendars, event_response, get_event, import_ics_event, list_freebusy, list_caldav_sources, list_calendars, list_events, list_sync_sources, normalize_datetime, sync_due_sources, sync_due_caldav_sources, sync_caldav_source, sync_source, update_calendar, update_caldav_source, update_sync_source, update_event, ) from govoplan_core.db.session import get_session router = APIRouter(prefix="/calendar", tags=["calendar"]) def _caldav_discovery_http_error( exc: CalendarError | CalDAVError, ) -> HTTPException: return HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) def _require_scope(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None: if not any(has_scope(principal, scope) for scope in scopes): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes)) def _calendar_response(calendar) -> CalendarCollectionResponse: return CalendarCollectionResponse.model_validate(calendar_response(calendar)) def _event_response(event) -> CalendarEventResponse: return CalendarEventResponse.model_validate(event_response(event)) def _calendar_event_watermark(session: Session, tenant_id: str) -> str: return encode_sequence_watermark( max_sequence_id(session, tenant_id=tenant_id, module_id=CALENDAR_MODULE_ID, collections=(CALENDAR_EVENTS_COLLECTION,)) ) def _parse_payload_datetime(value) -> datetime | None: if not isinstance(value, str) or not value: return None try: return datetime.fromisoformat(value) except ValueError: return None @router.get("/credentials", response_model=CalendarCredentialEnvelopeListResponse) def api_list_calendar_credentials( source_id: str | None = None, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") return CalendarCredentialEnvelopeListResponse( credentials=[ CalendarCredentialEnvelopeResponse.model_validate(item) for item in available_calendar_credentials( session, tenant_id=principal.tenant_id, source_id=source_id, ) ] ) def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool: event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at")) event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start if event_start is None: return False if start_at is not None and event_end is not None and event_end < normalize_datetime(start_at): return False if end_at is not None and event_start > normalize_datetime(end_at): return False return True def _event_payload_matches_window(entry, *, calendar_id: str | None, start_at: datetime | None, end_at: datetime | None) -> bool: payload = entry.payload or {} current_calendar = payload.get("calendar_id") previous_calendar = payload.get("previous_calendar_id") current_calendar_matches = calendar_id is None or current_calendar == calendar_id previous_calendar_matches = calendar_id is None or previous_calendar == calendar_id return ( current_calendar_matches and _event_interval_overlaps(payload, prefix="", start_at=start_at, end_at=end_at) ) or ( previous_calendar_matches and _event_interval_overlaps(payload, prefix="previous_", start_at=start_at, end_at=end_at) ) def _visible_events_for_delta( session: Session, *, tenant_id: str, event_ids: list[str], calendar_id: str | None, start_at: datetime | None, end_at: datetime | None, ) -> list[CalendarEvent]: if not event_ids: return [] query = session.query(CalendarEvent).filter( CalendarEvent.tenant_id == tenant_id, CalendarEvent.id.in_(event_ids), CalendarEvent.deleted_at.is_(None), ) if calendar_id: query = query.filter(CalendarEvent.calendar_id == calendar_id) if start_at is not None: normalized_start = normalize_datetime(start_at) query = query.filter((CalendarEvent.end_at.is_(None)) | (CalendarEvent.end_at >= normalized_start)) if end_at is not None: query = query.filter(CalendarEvent.start_at <= normalize_datetime(end_at)) return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc(), CalendarEvent.id.asc()).all() def _full_event_delta_response( session: Session, *, tenant_id: str, calendar_id: str | None, start_at: datetime | None, end_at: datetime | None, ) -> CalendarEventDeltaResponse: events = list_events(session, tenant_id=tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) return CalendarEventDeltaResponse( events=[_event_response(event) for event in events], deleted=[], watermark=_calendar_event_watermark(session, tenant_id), has_more=False, full=True, ) def _event_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int): try: since_sequence = decode_sequence_watermark(since) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if sequence_watermark_is_expired( session, since=since_sequence, tenant_id=tenant_id, module_id=CALENDAR_MODULE_ID, collections=(CALENDAR_EVENTS_COLLECTION,), ): return None, False entries_plus_one = sequence_entries_since( session, since=since_sequence, tenant_id=tenant_id, module_id=CALENDAR_MODULE_ID, collections=(CALENDAR_EVENTS_COLLECTION,), limit=limit + 1, ) has_more = len(entries_plus_one) > limit return entries_plus_one[:limit], has_more def _caldav_source_response(source) -> CalendarCalDavSourceResponse: return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source)) 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, group_ids=principal.group_ids, can_admin=principal.has("calendar:calendar:admin"), ) session.commit() return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars]) @router.post("/calendars", response_model=CalendarCollectionResponse, status_code=status.HTTP_201_CREATED) def api_create_calendar( payload: CalendarCollectionCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:write") try: calendar = create_calendar(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload) session.commit() session.refresh(calendar) return _calendar_response(calendar) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.patch("/calendars/{calendar_id}", response_model=CalendarCollectionResponse) def api_update_calendar( calendar_id: str, payload: CalendarCollectionUpdateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:write") try: calendar = update_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload) session.commit() session.refresh(calendar) return _calendar_response(calendar) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.delete("/calendars/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_calendar( calendar_id: str, payload: CalendarCollectionDeleteRequest | None = Body(default=None), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: delete_calendar( session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload, user_id=principal.user.id, api_key_id=principal.api_key_id, ) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.get("/sync-sources", response_model=CalendarSyncSourceListResponse) def api_list_sync_sources( calendar_id: str | None = None, source_kind: str | None = None, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:read") sources = list_sync_sources(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, source_kind=source_kind) return CalendarSyncSourceListResponse(sources=[_sync_source_response(source) for source in sources]) @router.post("/sync-sources", response_model=CalendarSyncSourceResponse, status_code=status.HTTP_201_CREATED) def api_create_sync_source( payload: CalendarSyncSourceCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: source = create_sync_source(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload) session.commit() session.refresh(source) return _sync_source_response(source) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.patch("/sync-sources/{source_id}", response_model=CalendarSyncSourceResponse) def api_update_sync_source( source_id: str, payload: CalendarSyncSourceUpdateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: source = update_sync_source( session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload, user_id=principal.user.id, api_key_id=principal.api_key_id, ) session.commit() session.refresh(source) return _sync_source_response(source) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.delete("/sync-sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_sync_source( source_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: delete_sync_source( session, tenant_id=principal.tenant_id, source_id=source_id, user_id=principal.user.id, api_key_id=principal.api_key_id, ) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except CalendarError as exc: session.rollback() 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) def api_sync_source( source_id: str, payload: CalendarSyncSourceSyncRequest | None = None, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:import") payload = payload or CalendarSyncSourceSyncRequest() try: source, stats = sync_source( session, tenant_id=principal.tenant_id, user_id=principal.user.id, source_id=source_id, password=payload.password, bearer_token=payload.bearer_token, force_full=payload.force_full, ) session.commit() session.refresh(source) return CalendarSyncSourceSyncResponse.model_validate(caldav_sync_response(source, stats)) except (CalendarError, CalDAVError, ICalendarError) as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/sync-sources/sync-due", response_model=CalendarSyncDueSyncResponse) def api_sync_due_sources( limit: int = Query(default=50, ge=1, le=200), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:import") results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit) session.commit() return CalendarSyncDueSyncResponse( results=[ CalendarSyncDueSyncItemResponse( source_id=item.source_id, calendar_id=item.calendar_id, status=item.status, error=item.error, created=item.stats.created if item.stats else 0, updated=item.stats.updated if item.stats else 0, deleted=item.stats.deleted if item.stats else 0, unchanged=item.stats.unchanged if item.stats else 0, fetched=item.stats.fetched if item.stats else 0, ) for item in results ] ) @router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse) def api_list_caldav_sources( calendar_id: str | None = None, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:read") sources = list_caldav_sources(session, tenant_id=principal.tenant_id, calendar_id=calendar_id) return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in sources]) @router.post("/caldav/discover", response_model=CalendarCalDavDiscoveryResponse) def api_discover_caldav_calendars( payload: CalendarCalDavDiscoveryRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload) return CalendarCalDavDiscoveryResponse(calendars=calendars) except (CalendarError, CalDAVError) as exc: raise _caldav_discovery_http_error(exc) from exc @router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED) def api_create_caldav_source( payload: CalendarCalDavSourceCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: source = create_caldav_source(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload) session.commit() session.refresh(source) return _caldav_source_response(source) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse) def api_update_caldav_source( source_id: str, payload: CalendarCalDavSourceUpdateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: source = update_caldav_source( session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload, user_id=principal.user.id, api_key_id=principal.api_key_id, ) session.commit() session.refresh(source) return _caldav_source_response(source) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_caldav_source( source_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: delete_caldav_source( session, tenant_id=principal.tenant_id, source_id=source_id, user_id=principal.user.id, api_key_id=principal.api_key_id, ) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except CalendarError as exc: session.rollback() 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) def api_sync_caldav_source( source_id: str, payload: CalendarCalDavSyncRequest | None = None, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:import") payload = payload or CalendarCalDavSyncRequest() try: source, stats = sync_caldav_source( session, tenant_id=principal.tenant_id, user_id=principal.user.id, source_id=source_id, password=payload.password, bearer_token=payload.bearer_token, force_full=payload.force_full, ) session.commit() session.refresh(source) return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats)) except (CalendarError, CalDAVError, ICalendarError) as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse) def api_sync_due_caldav_sources( limit: int = Query(default=50, ge=1, le=200), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:import") results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit) session.commit() return CalendarCalDavDueSyncResponse( results=[ CalendarCalDavDueSyncItemResponse( source_id=item.source_id, calendar_id=item.calendar_id, status=item.status, error=item.error, created=item.stats.created if item.stats else 0, updated=item.stats.updated if item.stats else 0, deleted=item.stats.deleted if item.stats else 0, unchanged=item.stats.unchanged if item.stats else 0, fetched=item.stats.fetched if item.stats else 0, ) for item in results ] ) @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, start_at: datetime | None = Query(default=None), end_at: datetime | None = Query(default=None), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:read") events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) return CalendarEventListResponse(events=[_event_response(event) for event in events]) @router.get("/events/delta", response_model=CalendarEventDeltaResponse) def api_list_events_delta( calendar_id: str | None = None, start_at: datetime | None = Query(default=None), end_at: datetime | None = Query(default=None), since: str | None = None, limit: int = Query(default=500, ge=1, le=1000), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:read") if since is None: return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) entries, has_more = _event_delta_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit) if entries is None: return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) scoped_entries = [ entry for entry in entries if entry.resource_type == CALENDAR_EVENT_RESOURCE and _event_payload_matches_window(entry, calendar_id=calendar_id, start_at=start_at, end_at=end_at) ] changed_ids = list(dict.fromkeys(entry.resource_id for entry in scoped_entries if entry.operation != "deleted")) visible_events = _visible_events_for_delta( session, tenant_id=principal.tenant_id, event_ids=changed_ids, calendar_id=calendar_id, start_at=start_at, end_at=end_at, ) visible_ids = {event.id for event in visible_events} deleted = [ DeltaDeletedItem( id=entry.resource_id, resource_type=entry.resource_type, revision=encode_sequence_watermark(entry.id), deleted_at=entry.created_at if entry.operation == "deleted" else None, ) for entry in scoped_entries if entry.resource_id not in visible_ids ] watermark = encode_sequence_watermark(entries[-1].id) if has_more and entries else _calendar_event_watermark(session, principal.tenant_id) return CalendarEventDeltaResponse( events=[_event_response(event) for event in visible_events], deleted=deleted, watermark=watermark, has_more=has_more, full=False, ) @router.post("/events", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED) def api_create_event( payload: CalendarEventCreateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _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() session.refresh(event) return _event_response(event) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.get("/events/{event_id}", response_model=CalendarEventResponse) def api_get_event( event_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:read") try: return _event_response(get_event(session, tenant_id=principal.tenant_id, event_id=event_id)) except CalendarError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @router.patch("/events/{event_id}", response_model=CalendarEventResponse) def api_update_event( event_id: str, payload: CalendarEventUpdateRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _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() session.refresh(event) return _event_response(event) except CalendarError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_event( event_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _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() 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 @router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED) def api_import_ics_event( payload: CalendarIcsImportRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:import") try: event = import_ics_event( session, tenant_id=principal.tenant_id, user_id=principal.user.id, calendar_id=payload.calendar_id, ics=payload.ics, upsert=payload.upsert, source_kind=payload.source_kind, source_href=payload.source_href, etag=payload.etag, ) session.commit() session.refresh(event) return _event_response(event) except (CalendarError, ICalendarError) as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @router.get("/events/{event_id}/ics") def api_export_ics_event( event_id: str, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:export") try: event = get_event(session, tenant_id=principal.tenant_id, event_id=event_id) except CalendarError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc ics = event.raw_ics or event_to_ics(event) return Response( content=ics, media_type="text/calendar; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{event.uid}.ics"', "Last-Modified": http_last_modified(event.updated_at), }, ) @router.post("/availability/freebusy", response_model=CalendarFreeBusyResponse) def api_freebusy( payload: CalendarFreeBusyRequest, principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE) try: busy = list_freebusy( session, tenant_id=principal.tenant_id, start_at=payload.start_at, end_at=payload.end_at, calendar_ids=payload.calendar_ids, ) return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy) except CalendarError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc