chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:18 +02:00
parent c3c867391c
commit 15d5613f9b
20 changed files with 4704 additions and 902 deletions

View File

@@ -21,6 +21,10 @@ class CalDAVPreconditionFailed(CalDAVError):
pass
class CalDAVNotFound(CalDAVError):
pass
class CalDAVTransport(Protocol):
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
...
@@ -48,6 +52,29 @@ class CalDAVWriteResult:
status: int = 0
@dataclass(frozen=True, slots=True)
class CalDAVDiscoveryCalendar:
collection_url: str
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
@dataclass(frozen=True, slots=True)
class _DAVDiscoveryResponse:
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
is_calendar: bool = False
principal_hrefs: tuple[str, ...] = ()
calendar_home_set_hrefs: tuple[str, ...] = ()
supported_components: tuple[str, ...] = ()
class CalDAVClient:
def __init__(
self,
@@ -78,6 +105,96 @@ class CalDAVClient:
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
return parse_multistatus(payload)
def discover_calendars(self) -> list[CalDAVDiscoveryCalendar]:
start_url = self.collection_url
calendars: dict[str, CalDAVDiscoveryCalendar] = {}
home_urls: list[str] = []
principal_urls: list[str] = []
visited_urls: set[tuple[str, str]] = set()
errors: list[str] = []
def add_home_href(base_url: str, href: str) -> None:
url = ensure_collection_url(absolute_dav_url(base_url, href))
if url not in home_urls:
home_urls.append(url)
def add_principal_href(base_url: str, href: str) -> None:
url = ensure_collection_url(absolute_dav_url(base_url, href))
if url not in principal_urls:
principal_urls.append(url)
def add_calendar(base_url: str, response: _DAVDiscoveryResponse) -> None:
if not response.is_calendar:
return
if response.supported_components and "VEVENT" not in response.supported_components:
return
url = ensure_collection_url(absolute_dav_url(base_url, response.href or base_url))
calendars[url] = CalDAVDiscoveryCalendar(
collection_url=url,
href=response.href,
display_name=response.display_name,
color=response.color,
ctag=response.ctag,
sync_token=response.sync_token,
)
def propfind(url: str, depth: str) -> list[_DAVDiscoveryResponse]:
key = (url, depth)
if key in visited_urls:
return []
visited_urls.add(key)
return self.propfind_discovery(url, depth=depth)
try:
for response in propfind(start_url, "0"):
add_calendar(start_url, response)
for href in response.calendar_home_set_hrefs:
add_home_href(start_url, href)
for href in response.principal_hrefs:
add_principal_href(start_url, href)
except CalDAVError as exc:
errors.append(str(exc))
for principal_url in principal_urls[:6]:
try:
for response in propfind(principal_url, "0"):
for href in response.calendar_home_set_hrefs:
add_home_href(principal_url, href)
except CalDAVError as exc:
errors.append(str(exc))
if not home_urls:
home_urls.append(start_url)
for home_url in home_urls:
try:
for response in propfind(home_url, "1"):
add_calendar(home_url, response)
except CalDAVError as exc:
errors.append(str(exc))
if not calendars and errors:
raise CalDAVError(f"CalDAV discovery did not find any calendar collections: {errors[0]}")
return sorted(calendars.values(), key=lambda item: ((item.display_name or item.collection_url).lower(), item.collection_url))
def propfind_discovery(self, url: str, *, depth: str) -> list[_DAVDiscoveryResponse]:
body = b"""<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<D:prop>
<D:displayname/>
<D:current-user-principal/>
<D:principal-URL/>
<D:resourcetype/>
<D:sync-token/>
<C:calendar-home-set/>
<C:supported-calendar-component-set/>
<CS:getctag/>
<ICAL:calendar-color/>
</D:prop>
</D:propfind>"""
payload = self.request("PROPFIND", ensure_collection_url(url), body=body, depth=depth, expected={207})
return parse_discovery_multistatus(payload)
def list_objects(self) -> CalDAVReportResult:
body = b"""<?xml version="1.0" encoding="utf-8"?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
@@ -182,6 +299,8 @@ class CalDAVClient:
headers.update(dict(extra_headers))
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
if status not in expected:
if status == 404:
raise CalDAVNotFound(f"{method} {url} returned HTTP {status}")
if status == 412:
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
raise CalDAVError(f"{method} {url} returned HTTP {status}")
@@ -189,14 +308,16 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
try:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.status, dict(response.headers.items()), response.read()
except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read()
except urllib.error.URLError as exc:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
@@ -232,10 +353,80 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult:
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
try:
root = ElementTree.fromstring(payload)
except ElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
responses: list[_DAVDiscoveryResponse] = []
for response in child_elements(root, "response"):
href = first_child_text(response, "href")
if not href:
continue
display_name = None
color = None
ctag = None
sync_token = None
is_calendar = False
principal_hrefs: list[str] = []
calendar_home_set_hrefs: list[str] = []
supported_components: list[str] = []
for propstat in child_elements(response, "propstat"):
status = first_child_text(propstat, "status") or ""
if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status:
continue
prop = first_child(propstat, "prop")
if prop is None:
continue
for item in prop:
name = local_name(item.tag)
if name == "displayname" and item.text:
display_name = item.text.strip() or display_name
elif name == "calendar-color" and item.text:
color = item.text.strip() or color
elif name == "getctag" and item.text:
ctag = item.text.strip() or ctag
elif name == "sync-token" and item.text:
sync_token = item.text.strip() or sync_token
elif name == "resourcetype":
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item)
elif name in {"current-user-principal", "principal-URL"}:
principal_hrefs.extend(nested_href_texts(item))
elif name == "calendar-home-set":
calendar_home_set_hrefs.extend(nested_href_texts(item))
elif name == "supported-calendar-component-set":
for component in item:
if local_name(component.tag) == "comp":
component_name = (component.attrib.get("name") or "").strip().upper()
if component_name:
supported_components.append(component_name)
responses.append(
_DAVDiscoveryResponse(
href=href,
display_name=display_name,
color=color,
ctag=ctag,
sync_token=sync_token,
is_calendar=is_calendar,
principal_hrefs=tuple(dict.fromkeys(principal_hrefs)),
calendar_home_set_hrefs=tuple(dict.fromkeys(calendar_home_set_hrefs)),
supported_components=tuple(dict.fromkeys(supported_components)),
)
)
return responses
def ensure_collection_url(value: str) -> str:
value = value.strip()
if value and "://" not in value and not value.startswith("/"):
value = f"https://{value}"
return value if value.endswith("/") else f"{value}/"
def absolute_dav_url(base_url: str, href: str) -> str:
return urllib.parse.urljoin(ensure_collection_url(base_url), href)
def strip_weak_etag(value: str | None) -> str | None:
if value is None:
return None
@@ -271,5 +462,13 @@ def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.
return [child for child in element if local_name(child.tag) == name]
def nested_href_texts(element: ElementTree.Element) -> list[str]:
hrefs: list[str] = []
for child in element.iter():
if local_name(child.tag) == "href" and child.text and child.text.strip():
hrefs.append(child.text.strip())
return hrefs
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag

View File

@@ -29,7 +29,7 @@ class CalendarCollection(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@@ -39,7 +39,7 @@ class CalendarCollection(Base, TimestampMixin):
owner_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
visibility: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@@ -56,7 +56,7 @@ class CalendarEvent(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
@@ -86,8 +86,8 @@ class CalendarEvent(Base, TimestampMixin):
etag: Mapped[str | None] = mapped_column(String(255), index=True)
icalendar: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
raw_ics: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@@ -110,7 +110,7 @@ class CalendarSyncSource(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
source_kind: Mapped[str] = mapped_column(String(30), default="caldav", nullable=False, index=True)
collection_url: Mapped[str] = mapped_column(String(1000), nullable=False)
@@ -142,11 +142,11 @@ class CalendarSyncCredential(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
credential_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(255))
secret_encrypted: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)

View File

@@ -92,6 +92,8 @@ def parse_vevent_component(calendar: Calendar, event: Event, *, raw_ics: str) ->
properties = component_property_records(event)
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
standard = standard_property_index(properties)
if dtend is None and duration_seconds is not None:
standard["uses_duration"] = True
return {
"uid": uid,
@@ -154,7 +156,9 @@ def event_to_component(event: Any) -> Event:
component.add("uid", event.uid)
component.add("dtstamp", datetime.now(timezone.utc))
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
if event.end_at is not None:
if event_uses_duration(event):
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
elif event.end_at is not None:
component.add("dtend", event_temporal_value(event.end_at, all_day=event.all_day, timezone_id=event.timezone))
elif getattr(event, "duration_seconds", None) is not None:
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
@@ -474,6 +478,16 @@ def metadata_value(event: Any, key: str) -> str | None:
return str(value) if value else None
def event_uses_duration(event: Any) -> bool:
if getattr(event, "duration_seconds", None) is None:
return False
raw_metadata = getattr(event, "icalendar", None)
if not isinstance(raw_metadata, dict):
return False
standard = raw_metadata.get("standard")
return isinstance(standard, dict) and standard.get("uses_duration") is True
def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime:
value = normalize_datetime(value)
if all_day:

View File

@@ -1,7 +1,7 @@
"""calendar collections and VEVENT storage
Revision ID: 7c8d9e0f1a2b
Revises: 1b2c3d4e5f70
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-07 00:00:00.000000
"""
from __future__ import annotations
@@ -11,7 +11,7 @@ import sqlalchemy as sa
revision = "7c8d9e0f1a2b"
down_revision = "1b2c3d4e5f70"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
@@ -38,8 +38,8 @@ def upgrade() -> None:
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_collections_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_collections_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_collections_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_collections_tenant_id_tenants"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_collections")),
)
op.create_index(op.f("ix_calendar_collections_tenant_id"), "calendar_collections", ["tenant_id"], unique=False)
@@ -100,9 +100,9 @@ def upgrade() -> None:
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_events_calendar_id_calendar_collections"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_events_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_updated_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_events_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_events_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_events_updated_by_user_id_users"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_events")),
sa.UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
)

View File

@@ -42,7 +42,7 @@ def upgrade() -> None:
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_sync_sources_calendar_id_calendar_collections"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_sources_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_sync_sources_tenant_id_tenants"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_sources")),
)
for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"):

View File

@@ -47,8 +47,8 @@ def upgrade() -> None:
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_sync_credentials_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_credentials_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_sync_credentials_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], name=op.f("fk_calendar_sync_credentials_tenant_id_tenants"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_credentials")),
)
for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"):

View File

@@ -6,9 +6,14 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, st
from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem
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,
@@ -23,24 +28,39 @@ from govoplan_calendar.backend.schemas import (
CalendarCollectionResponse,
CalendarCollectionUpdateRequest,
CalendarEventCreateRequest,
CalendarEventDeltaResponse,
CalendarEventListResponse,
CalendarEventResponse,
CalendarEventUpdateRequest,
CalendarFreeBusyRequest,
CalendarFreeBusyResponse,
CalendarIcsImportRequest,
CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest,
CalendarSyncSourceListResponse,
CalendarSyncSourceResponse,
CalendarSyncSourceSyncRequest,
CalendarSyncSourceSyncResponse,
CalendarSyncSourceUpdateRequest,
)
from govoplan_calendar.backend.service import (
CALENDAR_EVENTS_COLLECTION,
CALENDAR_EVENT_RESOURCE,
CALENDAR_MODULE_ID,
CalendarError,
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_caldav_source,
get_event,
@@ -49,10 +69,15 @@ from govoplan_calendar.backend.service import (
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
@@ -78,10 +103,123 @@ 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
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))
@router.get("/calendars", response_model=CalendarCollectionListResponse)
def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal),
@@ -107,7 +245,7 @@ def api_create_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
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.patch("/calendars/{calendar_id}", response_model=CalendarCollectionResponse)
@@ -142,7 +280,124 @@ def api_delete_calendar(
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
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)
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)
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("/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)
@@ -156,6 +411,20 @@ def api_list_caldav_sources(
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 HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
def api_create_caldav_source(
payload: CalendarCalDavSourceCreateRequest,
@@ -170,7 +439,7 @@ def api_create_caldav_source(
return _caldav_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse)
@@ -188,7 +457,7 @@ def api_update_caldav_source(
return _caldav_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
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)
@@ -231,7 +500,7 @@ def api_sync_caldav_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_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse)
@@ -274,6 +543,57 @@ def api_list_events(
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,
@@ -288,7 +608,7 @@ def api_create_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
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/events/{event_id}", response_model=CalendarEventResponse)
@@ -319,7 +639,7 @@ def api_update_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
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)
@@ -330,7 +650,7 @@ def api_delete_event(
):
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
try:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id)
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:
@@ -362,7 +682,7 @@ def api_import_ics_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
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/events/{event_id}/ics")
@@ -404,4 +724,4 @@ def api_freebusy(
)
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_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc

View File

@@ -5,12 +5,15 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from govoplan_core.api.v1.schemas import DeltaDeletedItem
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
CalendarDeleteEventAction = Literal["delete", "move"]
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
CalendarSyncDirection = Literal["inbound", "two_way"]
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
@@ -70,9 +73,10 @@ class CalendarCollectionListResponse(BaseModel):
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
class CalendarCalDavSourceCreateRequest(BaseModel):
class CalendarSyncSourceCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_kind: CalendarSyncSourceKind = "caldav"
calendar_id: str
collection_url: str = Field(min_length=1, max_length=1000)
display_name: str | None = Field(default=None, max_length=255)
@@ -88,7 +92,11 @@ class CalendarCalDavSourceCreateRequest(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarCalDavSourceUpdateRequest(BaseModel):
class CalendarCalDavSourceCreateRequest(CalendarSyncSourceCreateRequest):
source_kind: Literal["caldav"] = "caldav"
class CalendarSyncSourceUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
calendar_id: str | None = None
@@ -108,7 +116,11 @@ class CalendarCalDavSourceUpdateRequest(BaseModel):
metadata: dict[str, Any] | None = None
class CalendarCalDavSourceResponse(BaseModel):
class CalendarCalDavSourceUpdateRequest(CalendarSyncSourceUpdateRequest):
pass
class CalendarSyncSourceResponse(BaseModel):
id: str
tenant_id: str
calendar_id: str
@@ -135,11 +147,44 @@ class CalendarCalDavSourceResponse(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarCalDavSourceResponse(CalendarSyncSourceResponse):
pass
class CalendarSyncSourceListResponse(BaseModel):
sources: list[CalendarSyncSourceResponse] = Field(default_factory=list)
class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCalDavSyncRequest(BaseModel):
class CalendarCalDavDiscoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
url: str = Field(min_length=1, max_length=1000)
source_id: str | None = None
auth_type: CalendarSyncAuthType | None = None
username: str | None = Field(default=None, max_length=255)
credential_ref: str | None = Field(default=None, max_length=255)
password: SecretStr | None = None
bearer_token: SecretStr | None = None
class CalendarCalDavDiscoveryCalendarResponse(BaseModel):
collection_url: str
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
class CalendarCalDavDiscoveryResponse(BaseModel):
calendars: list[CalendarCalDavDiscoveryCalendarResponse] = Field(default_factory=list)
class CalendarSyncSourceSyncRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str | None = None
@@ -147,8 +192,12 @@ class CalendarCalDavSyncRequest(BaseModel):
force_full: bool = False
class CalendarCalDavSyncResponse(BaseModel):
source: CalendarCalDavSourceResponse
class CalendarCalDavSyncRequest(CalendarSyncSourceSyncRequest):
pass
class CalendarSyncSourceSyncResponse(BaseModel):
source: CalendarSyncSourceResponse
created: int = 0
updated: int = 0
deleted: int = 0
@@ -161,7 +210,11 @@ class CalendarCalDavSyncResponse(BaseModel):
errors: list[str] = Field(default_factory=list)
class CalendarCalDavDueSyncItemResponse(BaseModel):
class CalendarCalDavSyncResponse(CalendarSyncSourceSyncResponse):
source: CalendarCalDavSourceResponse
class CalendarSyncDueSyncItemResponse(BaseModel):
source_id: str
calendar_id: str
status: str
@@ -173,6 +226,14 @@ class CalendarCalDavDueSyncItemResponse(BaseModel):
fetched: int = 0
class CalendarCalDavDueSyncItemResponse(CalendarSyncDueSyncItemResponse):
pass
class CalendarSyncDueSyncResponse(BaseModel):
results: list[CalendarSyncDueSyncItemResponse] = Field(default_factory=list)
class CalendarCalDavDueSyncResponse(BaseModel):
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
@@ -283,6 +344,14 @@ class CalendarEventListResponse(BaseModel):
events: list[CalendarEventResponse] = Field(default_factory=list)
class CalendarEventDeltaResponse(BaseModel):
events: list[CalendarEventResponse] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CalendarFreeBusyRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

File diff suppressed because it is too large Load Diff

View File

@@ -5,11 +5,11 @@ from celery import shared_task
@shared_task(name="govoplan_calendar.sync_due_caldav_sources")
def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]:
from govoplan_calendar.backend.service import sync_due_caldav_sources
from govoplan_calendar.backend.service import sync_due_sources
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
results = sync_due_caldav_sources(session, tenant_id=tenant_id, limit=limit)
results = sync_due_sources(session, tenant_id=tenant_id, limit=limit)
session.commit()
return [
{