Initialize GovOPlaN calendar module

This commit is contained in:
2026-07-07 02:49:44 +02:00
commit 3bcb04ff76
36 changed files with 3410 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
from govoplan_calendar.backend.schemas import (
CalendarCollectionCreateRequest,
CalendarCollectionListResponse,
CalendarCollectionResponse,
CalendarCollectionUpdateRequest,
CalendarEventCreateRequest,
CalendarEventListResponse,
CalendarEventResponse,
CalendarEventUpdateRequest,
CalendarIcsImportRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
calendar_response,
create_calendar,
create_event,
delete_calendar,
delete_event,
event_response,
get_event,
import_ics_event,
list_calendars,
list_events,
update_calendar,
update_event,
)
from govoplan_core.db.session import get_session
router = APIRouter(prefix="/calendar", tags=["calendar"])
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))
@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)
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_ENTITY, 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,
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)
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_ENTITY, 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.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")
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_ENTITY, 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")
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_ENTITY, 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")
try:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_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_ENTITY, 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),
},
)