936 lines
35 KiB
Python
936 lines
35 KiB
Python
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
|