diff --git a/package.json b/package.json index d86947e..13db408 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ ], "peerDependencies": { "@govoplan/core-webui": "^0.1.6", - "lucide-react": "^0.555.0", + "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.1", diff --git a/src/govoplan_calendar/backend/caldav.py b/src/govoplan_calendar/backend/caldav.py index 0736907..7a19d98 100644 --- a/src/govoplan_calendar/backend/caldav.py +++ b/src/govoplan_calendar/backend/caldav.py @@ -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""" + + + + + + + + + + + + +""" + 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""" @@ -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 diff --git a/src/govoplan_calendar/backend/db/models.py b/src/govoplan_calendar/backend/db/models.py index 758a993..8ae6478 100644 --- a/src/govoplan_calendar/backend/db/models.py +++ b/src/govoplan_calendar/backend/db/models.py @@ -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) diff --git a/src/govoplan_calendar/backend/ical.py b/src/govoplan_calendar/backend/ical.py index 06c88b4..6590abe 100644 --- a/src/govoplan_calendar/backend/ical.py +++ b/src/govoplan_calendar/backend/ical.py @@ -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: diff --git a/src/govoplan_calendar/backend/migrations/versions/7c8d9e0f1a2b_calendar_collections_events.py b/src/govoplan_calendar/backend/migrations/versions/7c8d9e0f1a2b_calendar_collections_events.py index 4fe9acc..73e310e 100644 --- a/src/govoplan_calendar/backend/migrations/versions/7c8d9e0f1a2b_calendar_collections_events.py +++ b/src/govoplan_calendar/backend/migrations/versions/7c8d9e0f1a2b_calendar_collections_events.py @@ -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"), ) diff --git a/src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py b/src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py index 2c5295a..c98f282 100644 --- a/src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py +++ b/src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py @@ -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"): diff --git a/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py b/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py index 0d3b2ec..cce842d 100644 --- a/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py +++ b/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py @@ -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"): diff --git a/src/govoplan_calendar/backend/router.py b/src/govoplan_calendar/backend/router.py index 94b9944..2081dfe 100644 --- a/src/govoplan_calendar/backend/router.py +++ b/src/govoplan_calendar/backend/router.py @@ -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 diff --git a/src/govoplan_calendar/backend/schemas.py b/src/govoplan_calendar/backend/schemas.py index c1f5cff..057f85b 100644 --- a/src/govoplan_calendar/backend/schemas.py +++ b/src/govoplan_calendar/backend/schemas.py @@ -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") diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index 9d77329..d329cba 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -1,21 +1,27 @@ from __future__ import annotations +import json import re import uuid import os import urllib.parse +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any, Callable +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import or_ from sqlalchemy.orm import Session -from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url +from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource from govoplan_calendar.backend.ical import event_to_ics, events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents from govoplan_calendar.backend.runtime import get_registry from govoplan_calendar.backend.schemas import ( + CalendarCalDavDiscoveryRequest, CalendarCalDavSourceCreateRequest, CalendarCalDavSourceUpdateRequest, CalendarCollectionCreateRequest, @@ -23,10 +29,12 @@ from govoplan_calendar.backend.schemas import ( CalendarCollectionUpdateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest, + CalendarSyncSourceCreateRequest, + CalendarSyncSourceUpdateRequest, ) -from govoplan_core.core.access import CAPABILITY_SECURITY_SECRET_PROVIDER from govoplan_core.db.base import utcnow -from govoplan_core.security.secrets import decrypt_secret, encrypt_secret +from govoplan_core.core.change_sequence import record_change +from govoplan_core.security.secrets import CAPABILITY_SECURITY_SECRET_PROVIDER, decrypt_secret, encrypt_secret class CalendarError(ValueError): @@ -36,6 +44,50 @@ class CalendarError(ValueError): CALDAV_INTERNAL_CREDENTIAL_PREFIX = "calendar-sync-credential:" CALDAV_ENV_CREDENTIAL_PREFIX = "env:" CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900 +SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"} +READ_ONLY_SYNC_SOURCE_KINDS = {"ics", "webcal", "graph", "ews"} +GRAPH_DEFAULT_BASE_URL = "https://graph.microsoft.com/v1.0/" +EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/" +EWS_MESSAGES_NS = "http://schemas.microsoft.com/exchange/services/2006/messages" +EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types" +CALENDAR_MODULE_ID = "calendar" +CALENDAR_EVENTS_COLLECTION = "calendar.events" +CALENDAR_EVENT_RESOURCE = "calendar_event" + + +def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]: + return { + f"{prefix}calendar_id": event.calendar_id, + f"{prefix}start_at": response_datetime(event.start_at).isoformat() if event.start_at else None, + f"{prefix}end_at": response_datetime(event.end_at).isoformat() if event.end_at else None, + f"{prefix}summary": event.summary, + f"{prefix}status": event.status, + } + + +def record_calendar_event_change( + session: Session, + *, + event: CalendarEvent, + operation: str, + user_id: str | None, + previous: dict[str, Any] | None = None, +) -> None: + payload = calendar_event_change_payload(event) + if previous: + payload.update(previous) + record_change( + session, + module_id=CALENDAR_MODULE_ID, + collection=CALENDAR_EVENTS_COLLECTION, + resource_type=CALENDAR_EVENT_RESOURCE, + resource_id=event.id, + operation=operation, + tenant_id=event.tenant_id, + actor_type="user" if user_id else "system", + actor_id=user_id, + payload=payload, + ) @dataclass(slots=True) @@ -66,6 +118,71 @@ def slugify(value: str) -> str: return slug or "calendar" +def normalize_source_kind(value: str) -> str: + source_kind = (value or "").strip().lower() + if source_kind not in SYNC_SOURCE_KINDS: + raise CalendarError(f"Unsupported calendar sync source kind: {value}") + return source_kind + + +def normalize_sync_source_url(source_kind: str, value: str) -> str: + url = value.strip() + if not url: + raise CalendarError("Calendar sync source URL is required") + if source_kind == "caldav": + return ensure_collection_url(url) + if source_kind == "webcal": + if url.lower().startswith("webcal://"): + url = "https://" + url[len("webcal://"):] + return validate_http_url(url, label="webcal URL") + if source_kind == "ics": + if url.lower().startswith("webcal://"): + return normalize_sync_source_url("webcal", url) + return validate_http_url(url, label="ICS URL") + if source_kind == "graph": + return normalize_graph_collection_url(url) + if source_kind == "ews": + return validate_http_url(url, label="Exchange Web Services URL") + return url + + +def validate_http_url(url: str, *, label: str) -> str: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise CalendarError(f"{label} must be an absolute HTTP(S) URL") + return urllib.parse.urlunparse(parsed) + + +def normalize_graph_collection_url(value: str) -> str: + url = value.strip() + if url.startswith("/"): + url = url[1:] + parsed = urllib.parse.urlparse(url) + if parsed.scheme: + return validate_http_url(url, label="Microsoft Graph calendar URL") + if url in {"me/calendar", "me/calendar/events", "me/calendar/events/delta"}: + path = "me/calendar/events/delta" + elif url in {"me/events", "me/events/delta"}: + path = "me/events/delta" + elif url.endswith("/events/delta") or url.endswith("/calendarView/delta"): + path = url + elif url.endswith("/events"): + path = f"{url}/delta" + else: + path = f"{url.rstrip('/')}/events/delta" + return urllib.parse.urljoin(GRAPH_DEFAULT_BASE_URL, path) + + +def sync_source_label(source_kind: str) -> str: + return { + "caldav": "CalDAV", + "ics": "ICS", + "webcal": "webcal", + "graph": "Microsoft Graph", + "ews": "Exchange Web Services", + }.get(source_kind, source_kind) + + def ensure_default_calendar(session: Session, *, tenant_id: str, user_id: str | None = None) -> CalendarCollection: existing = ( session.query(CalendarCollection) @@ -176,6 +293,7 @@ def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo if calendar.is_default: calendar.is_default = False calendar.deleted_at = deleted_at + retire_sync_sources_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar.id, deleted_at=deleted_at) for event in calendar.events: if payload.event_action == "delete" and event.deleted_at is None: event.deleted_at = deleted_at @@ -211,47 +329,114 @@ def clear_default_calendar(session: Session, *, tenant_id: str) -> None: calendar.is_default = False -def list_caldav_sources(session: Session, *, tenant_id: str, calendar_id: str | None = None) -> list[CalendarSyncSource]: - query = session.query(CalendarSyncSource).filter( +def list_sync_sources(session: Session, *, tenant_id: str, calendar_id: str | None = None, source_kind: str | None = None) -> list[CalendarSyncSource]: + query = session.query(CalendarSyncSource).join(CalendarCollection, CalendarCollection.id == CalendarSyncSource.calendar_id).filter( CalendarSyncSource.tenant_id == tenant_id, - CalendarSyncSource.source_kind == "caldav", CalendarSyncSource.deleted_at.is_(None), + CalendarCollection.tenant_id == tenant_id, + CalendarCollection.deleted_at.is_(None), ) + if source_kind: + query = query.filter(CalendarSyncSource.source_kind == source_kind) if calendar_id: query = query.filter(CalendarSyncSource.calendar_id == calendar_id) - return query.order_by(CalendarSyncSource.display_name.asc(), CalendarSyncSource.created_at.asc()).all() + return query.order_by(CalendarSyncSource.source_kind.asc(), CalendarSyncSource.display_name.asc(), CalendarSyncSource.created_at.asc()).all() -def get_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> CalendarSyncSource: +def list_caldav_sources(session: Session, *, tenant_id: str, calendar_id: str | None = None) -> list[CalendarSyncSource]: + return list_sync_sources(session, tenant_id=tenant_id, calendar_id=calendar_id, source_kind="caldav") + + +def get_sync_source(session: Session, *, tenant_id: str, source_id: str, source_kind: str | None = None) -> CalendarSyncSource: source = ( session.query(CalendarSyncSource) .filter( CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.id == source_id, - CalendarSyncSource.source_kind == "caldav", CalendarSyncSource.deleted_at.is_(None), ) .first() ) + if source is not None and source_kind is not None and source.source_kind != source_kind: + source = None if not source: - raise CalendarError("CalDAV sync source not found") + raise CalendarError("Calendar sync source not found") return source -def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCalDavSourceCreateRequest) -> CalendarSyncSource: +def get_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> CalendarSyncSource: + try: + return get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") + except CalendarError as exc: + raise CalendarError("CalDAV sync source not found") from exc + + +def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: CalendarCalDavDiscoveryRequest) -> list[dict[str, Any]]: + source = get_caldav_source(session, tenant_id=tenant_id, source_id=payload.source_id) if payload.source_id else None + auth_type = payload.auth_type or (source.auth_type if source else "none") + username = payload.username if payload.username is not None else (source.username if source else None) + credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None) + secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token) + if secret is None and credential_ref: + secret = resolve_caldav_credential_ref(session, tenant_id=tenant_id, credential_ref=credential_ref) + if secret is None and source is not None: + secret = resolve_caldav_secret(session, source=source) + + if auth_type == "basic": + if not username: + raise CalendarError("CalDAV discovery with basic auth requires a username") + if not secret: + raise CalendarError("CalDAV discovery with basic auth requires a password or credential reference") + client = CalDAVClient(collection_url=payload.url, username=username, password=secret) + elif auth_type == "bearer": + if not secret: + raise CalendarError("CalDAV discovery with bearer auth requires a token or credential reference") + client = CalDAVClient(collection_url=payload.url, bearer_token=secret) + else: + client = CalDAVClient(collection_url=payload.url) + + return [ + { + "collection_url": calendar.collection_url, + "href": calendar.href, + "display_name": calendar.display_name, + "color": calendar.color, + "ctag": calendar.ctag, + "sync_token": calendar.sync_token, + } + for calendar in client.discover_calendars() + ] + + +def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource: calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) + source_kind = normalize_source_kind(payload.source_kind) + collection_url = normalize_sync_source_url(source_kind, payload.collection_url) + retire_stale_sync_sources_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url) + existing = active_sync_source_for_url(session, tenant_id=tenant_id, source_kind=source_kind, collection_url=collection_url) + if existing is not None: + existing_calendar = existing.calendar + calendar_name = existing_calendar.name if existing_calendar and existing_calendar.deleted_at is None else "another calendar" + raise CalendarError(f"{sync_source_label(source_kind)} source is already linked to {calendar_name}") + sync_direction = "inbound" if source_kind in READ_ONLY_SYNC_SOURCE_KINDS else payload.sync_direction + if source_kind == "graph" and payload.auth_type != "bearer": + raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication") + if source_kind in {"ics", "webcal"} and payload.auth_type not in {"none", "basic", "bearer"}: + raise CalendarError("ICS/webcal subscriptions support none, basic, or bearer authentication") + if source_kind == "ews" and payload.auth_type not in {"basic", "bearer"}: + raise CalendarError("Exchange Web Services sync requires basic or bearer authentication") source = CalendarSyncSource( tenant_id=tenant_id, calendar_id=calendar.id, - source_kind="caldav", - collection_url=ensure_collection_url(payload.collection_url), + source_kind=source_kind, + collection_url=collection_url, display_name=payload.display_name, auth_type=payload.auth_type, username=payload.username, credential_ref=payload.credential_ref, sync_enabled=payload.sync_enabled, sync_interval_seconds=payload.sync_interval_seconds, - sync_direction=payload.sync_direction, + sync_direction=sync_direction, conflict_policy=payload.conflict_policy, metadata_=payload.metadata, ) @@ -261,16 +446,22 @@ def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | Non if credential_value is not None: source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=user_id, source=source, secret=credential_value) source.next_sync_at = utcnow() if source.sync_enabled else None - mark_calendar_caldav(calendar, source) + mark_calendar_sync_source(calendar, source) session.flush() return source -def update_caldav_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarCalDavSourceUpdateRequest) -> CalendarSyncSource: - source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id) +def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCalDavSourceCreateRequest) -> CalendarSyncSource: + return create_sync_source(session, tenant_id=tenant_id, user_id=user_id, payload=payload) + + +def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarSyncSourceUpdateRequest) -> CalendarSyncSource: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) if payload.calendar_id is not None: calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) source.calendar_id = calendar.id + else: + calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) for field_name in ( "display_name", "auth_type", @@ -287,27 +478,115 @@ def update_caldav_source(session: Session, *, tenant_id: str, source_id: str, pa if value is not None: setattr(source, field_name, value) if payload.collection_url is not None: - source.collection_url = ensure_collection_url(payload.collection_url) + source.collection_url = normalize_sync_source_url(source.source_kind, payload.collection_url) if payload.metadata is not None: source.metadata_ = payload.metadata + if source.source_kind in READ_ONLY_SYNC_SOURCE_KINDS: + source.sync_direction = "inbound" + if source.source_kind == "graph" and source.auth_type != "bearer": + raise CalendarError("Microsoft Graph calendar sync requires bearer token authentication") + if source.source_kind == "ews" and source.auth_type not in {"basic", "bearer"}: + raise CalendarError("Exchange Web Services sync requires basic or bearer authentication") credential_value = caldav_secret_from_payload(auth_type=source.auth_type, password=payload.password, bearer_token=payload.bearer_token) if credential_value is not None: source.credential_ref = store_caldav_credential(session, tenant_id=tenant_id, user_id=None, source=source, secret=credential_value) if payload.sync_enabled is not None or payload.sync_interval_seconds is not None: source.next_sync_at = utcnow() if source.sync_enabled else None - calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) - mark_calendar_caldav(calendar, source) + mark_calendar_sync_source(calendar, source) session.flush() return source -def delete_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> None: - source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id) - source.deleted_at = utcnow() - delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) +def update_caldav_source(session: Session, *, tenant_id: str, source_id: str, payload: CalendarCalDavSourceUpdateRequest) -> CalendarSyncSource: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") + return update_sync_source(session, tenant_id=tenant_id, source_id=source.id, payload=payload) + + +def delete_sync_source(session: Session, *, tenant_id: str, source_id: str) -> None: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) + retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) session.flush() +def delete_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> None: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id, source_kind="caldav") + retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=utcnow()) + session.flush() + + +def retire_sync_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: + if not hasattr(session, "query"): + return + sources = ( + session.query(CalendarSyncSource) + .filter( + CalendarSyncSource.tenant_id == tenant_id, + CalendarSyncSource.calendar_id == calendar_id, + CalendarSyncSource.deleted_at.is_(None), + ) + .all() + ) + for source in sources: + retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=deleted_at) + + +def retire_caldav_sources_for_calendar(session: Session, *, tenant_id: str, calendar_id: str, deleted_at: datetime) -> None: + retire_sync_sources_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id, deleted_at=deleted_at) + + +def retire_stale_sync_sources_for_url(session: Session, *, tenant_id: str, source_kind: str, collection_url: str) -> None: + sources = ( + session.query(CalendarSyncSource) + .outerjoin(CalendarCollection, CalendarCollection.id == CalendarSyncSource.calendar_id) + .filter( + CalendarSyncSource.tenant_id == tenant_id, + CalendarSyncSource.source_kind == source_kind, + CalendarSyncSource.collection_url == collection_url, + CalendarSyncSource.deleted_at.is_(None), + ) + .all() + ) + now = utcnow() + for source in sources: + if source.calendar is None or source.calendar.deleted_at is not None: + retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=now) + session.flush() + + +def retire_stale_caldav_sources_for_url(session: Session, *, tenant_id: str, collection_url: str) -> None: + retire_stale_sync_sources_for_url(session, tenant_id=tenant_id, source_kind="caldav", collection_url=collection_url) + + +def active_sync_source_for_url(session: Session, *, tenant_id: str, source_kind: str, collection_url: str) -> CalendarSyncSource | None: + return ( + session.query(CalendarSyncSource) + .join(CalendarCollection, CalendarCollection.id == CalendarSyncSource.calendar_id) + .filter( + CalendarSyncSource.tenant_id == tenant_id, + CalendarSyncSource.source_kind == source_kind, + CalendarSyncSource.collection_url == collection_url, + CalendarSyncSource.deleted_at.is_(None), + CalendarCollection.tenant_id == tenant_id, + CalendarCollection.deleted_at.is_(None), + ) + .order_by(CalendarSyncSource.created_at.asc()) + .first() + ) + + +def active_caldav_source_for_url(session: Session, *, tenant_id: str, collection_url: str) -> CalendarSyncSource | None: + return active_sync_source_for_url(session, tenant_id=tenant_id, source_kind="caldav", collection_url=collection_url) + + +def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: + source.deleted_at = deleted_at + delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) + + +def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None: + retire_sync_source(session, tenant_id=tenant_id, source=source, deleted_at=deleted_at) + + def caldav_secret_from_payload(*, auth_type: str, password: Any | None, bearer_token: Any | None) -> str | None: if auth_type == "basic" and password is not None: return secret_value(password) @@ -395,6 +674,18 @@ def resolve_caldav_secret( return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None +def resolve_caldav_credential_ref(session: Session, *, tenant_id: str, credential_ref: str) -> str | None: + if credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): + return os.environ.get(credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)) + provider = secret_provider() + if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX): + secret = provider.read_secret(credential_ref) + if secret: + return secret + credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref) + return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None + + def caldav_client_for_source( session: Session, source: CalendarSyncSource, @@ -416,6 +707,85 @@ def caldav_client_for_source( return CalDAVClient(collection_url=source.collection_url) +def sync_source( + session: Session, + *, + tenant_id: str, + user_id: str | None, + source_id: str, + password: str | None = None, + bearer_token: str | None = None, + force_full: bool = False, +) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) + if source.source_kind == "caldav": + return sync_caldav_source( + session, + tenant_id=tenant_id, + user_id=user_id, + source_id=source_id, + password=password, + bearer_token=bearer_token, + force_full=force_full, + ) + get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) + if source.source_kind in {"ics", "webcal"}: + return sync_ics_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full) + if source.source_kind == "graph": + return sync_graph_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, bearer_token=bearer_token, force_full=force_full) + if source.source_kind == "ews": + return sync_ews_source(session, tenant_id=tenant_id, user_id=user_id, source_id=source_id, password=password, bearer_token=bearer_token, force_full=force_full) + raise CalendarError(f"Unsupported calendar sync source kind: {source.source_kind}") + + +def http_request( + url: str, + *, + method: str = "GET", + headers: dict[str, str] | None = None, + body: str | bytes | None = None, + timeout: int = 30, +) -> tuple[int, dict[str, str], str]: + data = body.encode("utf-8") if isinstance(body, str) else body + request = urllib.request.Request(url, data=data, method=method, headers=headers or {}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URLs are admin-configured sync sources. + response_headers = {key.lower(): value for key, value in response.headers.items()} + payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace") + return int(response.status), response_headers, payload + except urllib.error.HTTPError as exc: + if exc.code == 304: + return 304, {key.lower(): value for key, value in exc.headers.items()}, "" + detail = exc.read().decode("utf-8", errors="replace") + raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc + except urllib.error.URLError as exc: + raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc + + +def source_auth_headers( + session: Session, + source: CalendarSyncSource, + *, + password: str | None = None, + bearer_token: str | None = None, +) -> dict[str, str]: + secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token) + if source.auth_type == "basic": + if not source.username: + raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a username") + if not secret: + raise CalendarError(f"{sync_source_label(source.source_kind)} basic auth requires a stored or transient password") + import base64 + + token = base64.b64encode(f"{source.username}:{secret}".encode("utf-8")).decode("ascii") + return {"Authorization": f"Basic {token}"} + if source.auth_type == "bearer": + if not secret: + raise CalendarError(f"{sync_source_label(source.source_kind)} bearer auth requires a stored or transient token") + return {"Authorization": f"Bearer {secret}"} + return {} + + def internal_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> CalendarSyncCredential | None: if not credential_ref: return None @@ -501,6 +871,669 @@ def sync_caldav_source( raise +def sync_ics_source( + session: Session, + *, + tenant_id: str, + user_id: str | None, + source_id: str, + password: str | None = None, + bearer_token: str | None = None, + force_full: bool = False, +) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) + if source.source_kind not in {"ics", "webcal"}: + raise CalendarError("ICS/webcal sync source not found") + stats = CalendarCalDavSyncStats(full_sync=True) + source.last_attempt_at = utcnow() + headers = {"Accept": "text/calendar, application/calendar+json;q=0.5, */*;q=0.1", "User-Agent": "govoplan-calendar-sync/0.1"} + headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token)) + if source.ctag and not force_full: + headers["If-None-Match"] = source.ctag + last_modified = (source.metadata_ or {}).get("last_modified") if isinstance(source.metadata_, dict) else None + if isinstance(last_modified, str) and last_modified and not force_full: + headers["If-Modified-Since"] = last_modified + try: + status_code, response_headers, body = http_request(source.collection_url, headers=headers) + if status_code == 304: + source.last_synced_at = utcnow() + source.last_status = "ok" + source.last_error = None + schedule_next_caldav_sync(source) + session.flush() + return source, stats + stats.fetched = 1 + stats.created, stats.updated, stats.unchanged, stats.deleted = import_ics_subscription( + session, + source=source, + ics=body, + etag=response_headers.get("etag"), + user_id=user_id, + ) + metadata = dict(source.metadata_ or {}) + if response_headers.get("last-modified"): + metadata["last_modified"] = response_headers["last-modified"] + source.metadata_ = metadata + source.ctag = response_headers.get("etag") or source.ctag + source.last_synced_at = utcnow() + source.last_status = "ok" + source.last_error = None + schedule_next_caldav_sync(source) + mark_calendar_sync_source(source.calendar, source) + session.flush() + return source, stats + except Exception as exc: + source.last_attempt_at = utcnow() + source.last_status = "error" + source.last_error = str(exc) + schedule_next_caldav_sync(source) + session.flush() + raise + + +def import_ics_subscription( + session: Session, + *, + source: CalendarSyncSource, + ics: str, + etag: str | None, + user_id: str | None, +) -> tuple[int, int, int, int]: + parsed_events = parse_vevents(ics) + remaining = { + (event.uid, event.recurrence_id): event + for event in session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.source_kind == source.source_kind, + CalendarEvent.deleted_at.is_(None), + ) + .all() + } + created = updated = unchanged = deleted = 0 + changes: list[tuple[CalendarEvent, str, dict[str, Any] | None]] = [] + raw_ics = str(parsed_events[0].get("raw_ics") or ics) + for parsed in parsed_events: + key = (parsed["uid"], parsed.get("recurrence_id")) + href = subscription_event_href(source, parsed) + event = remaining.get(key) or find_event_for_subscription_component(session, source=source, parsed=parsed) + previous = calendar_event_change_payload(event, prefix="previous_") if event else None + was_deleted = bool(event and event.deleted_at is not None) + if event is None: + event = CalendarEvent( + tenant_id=source.tenant_id, + calendar_id=source.calendar_id, + uid=parsed["uid"], + recurrence_id=parsed.get("recurrence_id"), + created_by_user_id=user_id, + ) + session.add(event) + created += 1 + changes.append((event, "created", previous)) + else: + if was_deleted: + created += 1 + changes.append((event, "created", previous)) + elif event.etag == etag and event.raw_ics == raw_ics: + unchanged += 1 + else: + updated += 1 + changes.append((event, "updated", previous)) + apply_parsed_event_to_model(event, parsed, source=source, href=href, etag=etag, raw_ics=raw_ics, user_id=user_id) + remaining.pop(key, None) + deleted_at = utcnow() + for event in remaining.values(): + previous = calendar_event_change_payload(event, prefix="previous_") + event.deleted_at = deleted_at + deleted += 1 + changes.append((event, "deleted", previous)) + session.flush() + for event, operation, previous in changes: + record_calendar_event_change(session, event=event, operation=operation, user_id=user_id, previous=previous) + return created, updated, unchanged, deleted + + +def find_event_for_subscription_component(session: Session, *, source: CalendarSyncSource, parsed: dict[str, Any]) -> CalendarEvent | None: + return ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.uid == parsed["uid"], + CalendarEvent.recurrence_id == parsed.get("recurrence_id"), + CalendarEvent.source_kind == source.source_kind, + ) + .order_by(CalendarEvent.deleted_at.is_(None).desc()) + .first() + ) + + +def subscription_event_href(source: CalendarSyncSource, parsed: dict[str, Any]) -> str: + uid = urllib.parse.quote(str(parsed["uid"]), safe="-_.~@") + recurrence_id = parsed.get("recurrence_id") + if recurrence_id: + return f"{source.collection_url}#{uid}/{urllib.parse.quote(str(recurrence_id), safe='-_.~@:')}" + return f"{source.collection_url}#{uid}" + + +def sync_graph_source( + session: Session, + *, + tenant_id: str, + user_id: str | None, + source_id: str, + bearer_token: str | None = None, + force_full: bool = False, +) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) + if source.source_kind != "graph": + raise CalendarError("Microsoft Graph sync source not found") + headers = {"Accept": "application/json", "User-Agent": "govoplan-calendar-graph/0.1", "Prefer": 'outlook.timezone="UTC"'} + headers.update(source_auth_headers(session, source, bearer_token=bearer_token)) + stats = CalendarCalDavSyncStats(full_sync=force_full or not bool(source.sync_token), used_sync_token=bool(source.sync_token and not force_full)) + source.last_attempt_at = utcnow() + try: + next_url = source.sync_token if source.sync_token and not force_full else source.collection_url + seen_hrefs: set[str] = set() + delta_link: str | None = None + for _page in range(50): + _status, _headers, body = http_request(next_url, headers=headers) + payload = json.loads(body or "{}") + for item in payload.get("value", []): + href = str(item.get("id") or "") + if not href: + continue + if item.get("@removed"): + stats.deleted += soft_delete_source_href(session, source=source, href=href) + continue + seen_hrefs.add(href) + created, updated, unchanged = import_provider_event(session, source=source, href=href, provider_event=graph_event_payload(item), user_id=user_id) + stats.created += created + stats.updated += updated + stats.unchanged += unchanged + next_link = payload.get("@odata.nextLink") + delta_link = payload.get("@odata.deltaLink") or delta_link + if not next_link: + break + next_url = str(next_link) + else: + raise CalendarError("Microsoft Graph sync returned too many pages") + if stats.full_sync: + stats.deleted += soft_delete_unseen_source_events(session, source=source, seen_hrefs=seen_hrefs) + source.sync_token = delta_link or source.sync_token + source.last_synced_at = utcnow() + source.last_status = "ok" + source.last_error = None + schedule_next_caldav_sync(source) + mark_calendar_sync_source(source.calendar, source) + session.flush() + return source, stats + except Exception as exc: + source.last_attempt_at = utcnow() + source.last_status = "error" + source.last_error = str(exc) + schedule_next_caldav_sync(source) + session.flush() + raise + + +def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]: + start = graph_datetime(item.get("start")) + end = graph_datetime(item.get("end")) + all_day = bool(item.get("isAllDay")) + uid = str(item.get("iCalUId") or item.get("uid") or item.get("id") or uuid.uuid4()) + return { + "uid": uid, + "recurrence_id": str(item.get("id")) if item.get("type") == "occurrence" else None, + "sequence": int(item.get("sequence") or 0), + "summary": str(item.get("subject") or "(Untitled event)"), + "description": graph_body_text(item), + "location": graph_location_text(item.get("location")), + "status": "CANCELLED" if item.get("isCancelled") else "CONFIRMED", + "transparency": "TRANSPARENT" if item.get("showAs") in {"free", "workingElsewhere"} else "OPAQUE", + "classification": "PRIVATE" if item.get("sensitivity") == "private" else "PUBLIC", + "start_at": start, + "end_at": end, + "duration_seconds": int((end - start).total_seconds()) if start and end else None, + "all_day": all_day, + "timezone": ((item.get("start") or {}).get("timeZone") if isinstance(item.get("start"), dict) else None) or "UTC", + "organizer": graph_party(item.get("organizer")), + "attendees": [graph_party(attendee) for attendee in item.get("attendees") or []], + "categories": [str(category) for category in item.get("categories") or []], + "rrule": item.get("recurrence") if isinstance(item.get("recurrence"), dict) else None, + "rdate": [], + "exdate": [], + "reminders": graph_reminders(item), + "attachments": [], + "related_to": [], + "etag": item.get("@odata.etag"), + "icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "graph", "graph": item}, + "metadata": {"graph": item}, + } + + +def graph_datetime(value: Any) -> datetime: + if not isinstance(value, dict) or not value.get("dateTime"): + return utcnow() + raw = str(value["dateTime"]).replace("Z", "+00:00") + parsed = datetime.fromisoformat(raw) + tz_name = str(value.get("timeZone") or "UTC") + if parsed.tzinfo is None: + try: + parsed = parsed.replace(tzinfo=ZoneInfo(tz_name)) + except ZoneInfoNotFoundError: + parsed = parsed.replace(tzinfo=timezone.utc) + return normalize_datetime(parsed) + + +def graph_body_text(item: dict[str, Any]) -> str | None: + body = item.get("body") + if isinstance(body, dict) and body.get("content"): + return str(body["content"]) + return str(item.get("bodyPreview")) if item.get("bodyPreview") else None + + +def graph_location_text(value: Any) -> str | None: + if isinstance(value, dict): + return str(value.get("displayName")) if value.get("displayName") else None + return None + + +def graph_party(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + email = value.get("emailAddress") if isinstance(value.get("emailAddress"), dict) else value + result = { + "name": str(email.get("name") or "") if isinstance(email, dict) else "", + "email": str(email.get("address") or "") if isinstance(email, dict) else "", + } + if value.get("type"): + result["role"] = value["type"] + if value.get("status"): + result["status"] = value["status"] + return result + + +def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]: + if item.get("isReminderOn") and item.get("reminderMinutesBeforeStart") is not None: + return [{"action": "DISPLAY", "trigger_minutes_before": int(item["reminderMinutesBeforeStart"])}] + return [] + + +def sync_ews_source( + session: Session, + *, + tenant_id: str, + user_id: str | None, + source_id: str, + password: str | None = None, + bearer_token: str | None = None, + force_full: bool = False, +) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: + source = get_sync_source(session, tenant_id=tenant_id, source_id=source_id) + if source.source_kind != "ews": + raise CalendarError("Exchange Web Services sync source not found") + metadata = source.metadata_ or {} + window_before = int(metadata.get("sync_window_days_before", 365)) if isinstance(metadata, dict) else 365 + window_after = int(metadata.get("sync_window_days_after", 730)) if isinstance(metadata, dict) else 730 + now = utcnow() + start = now - timedelta(days=max(window_before, 0)) + end = now + timedelta(days=max(window_after, 1)) + headers = { + "Accept": "text/xml", + "Content-Type": "text/xml; charset=utf-8", + "User-Agent": "govoplan-calendar-ews/0.1", + } + headers.update(source_auth_headers(session, source, password=password, bearer_token=bearer_token)) + stats = CalendarCalDavSyncStats(full_sync=True) + source.last_attempt_at = utcnow() + try: + _status, _headers, body = http_request(source.collection_url, method="POST", headers=headers, body=ews_find_item_body(start=start, end=end, mailbox=metadata.get("mailbox") if isinstance(metadata, dict) else None)) + items = parse_ews_calendar_items(body) + seen_hrefs: set[str] = set() + for item in items: + href = item["href"] + seen_hrefs.add(href) + created, updated, unchanged = import_provider_event(session, source=source, href=href, provider_event=item, user_id=user_id) + stats.created += created + stats.updated += updated + stats.unchanged += unchanged + stats.fetched = len(items) + stats.deleted += soft_delete_unseen_source_events(session, source=source, seen_hrefs=seen_hrefs) + source.sync_token = f"{response_datetime(start).isoformat()}..{response_datetime(end).isoformat()}" + source.last_synced_at = utcnow() + source.last_status = "ok" + source.last_error = None + schedule_next_caldav_sync(source) + mark_calendar_sync_source(source.calendar, source) + session.flush() + return source, stats + except Exception as exc: + source.last_attempt_at = utcnow() + source.last_status = "error" + source.last_error = str(exc) + schedule_next_caldav_sync(source) + session.flush() + raise + + +def import_provider_event( + session: Session, + *, + source: CalendarSyncSource, + href: str, + provider_event: dict[str, Any], + user_id: str | None, +) -> tuple[int, int, int]: + event = find_event_for_source_href(session, source=source, href=href) + previous = calendar_event_change_payload(event, prefix="previous_") if event else None + was_deleted = bool(event and event.deleted_at is not None) + created = updated = unchanged = 0 + raw_fingerprint = json.dumps(provider_event.get("metadata") or provider_event.get("icalendar") or provider_event, sort_keys=True, default=str) + if event is None: + event = CalendarEvent( + tenant_id=source.tenant_id, + calendar_id=source.calendar_id, + uid=str(provider_event["uid"]), + recurrence_id=provider_event.get("recurrence_id"), + created_by_user_id=user_id, + ) + session.add(event) + created = 1 + elif was_deleted: + created = 1 + elif event.etag == provider_event.get("etag") and event.raw_ics == raw_fingerprint: + unchanged = 1 + else: + updated = 1 + for field_name in ( + "sequence", + "summary", + "description", + "location", + "status", + "transparency", + "classification", + "start_at", + "end_at", + "duration_seconds", + "all_day", + "timezone", + "organizer", + "attendees", + "categories", + "rrule", + "rdate", + "exdate", + "reminders", + "attachments", + "related_to", + "icalendar", + ): + if field_name in provider_event: + setattr(event, field_name, provider_event[field_name]) + event.calendar_id = source.calendar_id + event.source_kind = source.source_kind + event.source_href = href + event.etag = provider_event.get("etag") + event.raw_ics = raw_fingerprint + event.updated_by_user_id = user_id + event.deleted_at = None + metadata = dict(event.metadata_ or {}) + metadata[source.source_kind] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": event.etag} + if isinstance(provider_event.get("metadata"), dict): + metadata[source.source_kind].update(provider_event["metadata"]) + event.metadata_ = metadata + validate_event_time(event) + session.flush() + if created or updated: + record_calendar_event_change(session, event=event, operation="created" if created else "updated", user_id=user_id, previous=previous) + return created, updated, unchanged + + +def find_event_for_source_href(session: Session, *, source: CalendarSyncSource, href: str) -> CalendarEvent | None: + return ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.source_kind == source.source_kind, + CalendarEvent.source_href == href, + ) + .order_by(CalendarEvent.deleted_at.is_(None).desc()) + .first() + ) + + +def soft_delete_source_href(session: Session, *, source: CalendarSyncSource, href: str) -> int: + deleted_at = utcnow() + count = 0 + for event in ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.source_kind == source.source_kind, + CalendarEvent.source_href == href, + CalendarEvent.deleted_at.is_(None), + ) + .all() + ): + previous = calendar_event_change_payload(event, prefix="previous_") + event.deleted_at = deleted_at + record_calendar_event_change(session, event=event, operation="deleted", user_id=None, previous=previous) + count += 1 + return count + + +def soft_delete_unseen_source_events(session: Session, *, source: CalendarSyncSource, seen_hrefs: set[str]) -> int: + deleted_at = utcnow() + count = 0 + for event in ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.source_kind == source.source_kind, + CalendarEvent.deleted_at.is_(None), + ) + .all() + ): + if event.source_href not in seen_hrefs: + previous = calendar_event_change_payload(event, prefix="previous_") + event.deleted_at = deleted_at + record_calendar_event_change(session, event=event, operation="deleted", user_id=None, previous=previous) + count += 1 + return count + + +def ews_find_item_body(*, start: datetime, end: datetime, mailbox: Any | None = None) -> str: + start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z") + end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z") + mailbox_xml = "" + if mailbox: + mailbox_xml = f"{xml_escape(str(mailbox))}" + return f""" + + + + + + + + AllProperties + + + + {mailbox_xml} + + + +""" + + +def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]: + try: + root = ET.fromstring(xml_text) + except ET.ParseError as exc: + raise CalendarError(f"Invalid EWS response XML: {exc}") from exc + items: list[dict[str, Any]] = [] + for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"): + item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId") + href = item_id.get("Id") if item_id is not None else None + if not href: + continue + change_key = item_id.get("ChangeKey") if item_id is not None else None + start = parse_ews_datetime(text_of(item, "Start")) + end = parse_ews_datetime(text_of(item, "End")) if text_of(item, "End") else start + uid = text_of(item, "UID") or href + subject = text_of(item, "Subject") or "(Untitled event)" + all_day = text_of(item, "IsAllDayEvent") == "true" + free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy" + sensitivity = text_of(item, "Sensitivity") or "Normal" + body = item.find(f"./{{{EWS_TYPES_NS}}}Body") + provider_payload = element_to_dict(item) + items.append( + { + "href": href, + "uid": uid, + "recurrence_id": href if text_of(item, "CalendarItemType") in {"Occurrence", "Exception"} else None, + "sequence": int_or_default(text_of(item, "AppointmentSequenceNumber"), 0), + "summary": subject, + "description": body.text if body is not None else None, + "location": text_of(item, "Location"), + "status": "CANCELLED" if text_of(item, "IsCancelled") == "true" else "CONFIRMED", + "transparency": "TRANSPARENT" if free_busy.lower() == "free" else "OPAQUE", + "classification": "PRIVATE" if sensitivity.lower() == "private" else "PUBLIC", + "start_at": start, + "end_at": end, + "duration_seconds": int((end - start).total_seconds()) if start and end else None, + "all_day": all_day, + "timezone": "UTC", + "organizer": ews_mailbox_record(item.find(f"./{{{EWS_TYPES_NS}}}Organizer/{{{EWS_TYPES_NS}}}Mailbox")), + "attendees": ews_attendees(item), + "categories": [category.text for category in item.findall(f"./{{{EWS_TYPES_NS}}}Categories/{{{EWS_TYPES_NS}}}String") if category.text], + "rrule": None, + "rdate": [], + "exdate": [], + "reminders": ews_reminders(item), + "attachments": [], + "related_to": [], + "etag": change_key, + "icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "ews", "ews": provider_payload}, + "metadata": {"ews": provider_payload}, + } + ) + return items + + +def parse_ews_datetime(value: str | None) -> datetime: + if not value: + return utcnow() + return normalize_datetime(datetime.fromisoformat(value.replace("Z", "+00:00"))) + + +def text_of(item: ET.Element, name: str) -> str | None: + child = item.find(f"./{{{EWS_TYPES_NS}}}{name}") + return child.text if child is not None else None + + +def ews_mailbox_record(mailbox: ET.Element | None) -> dict[str, Any] | None: + if mailbox is None: + return None + return { + "name": text_of(mailbox, "Name") or "", + "email": text_of(mailbox, "EmailAddress") or "", + "routing_type": text_of(mailbox, "RoutingType") or "", + } + + +def ews_attendees(item: ET.Element) -> list[dict[str, Any]]: + attendees: list[dict[str, Any]] = [] + for role, path in (("required", "RequiredAttendees"), ("optional", "OptionalAttendees")): + for attendee in item.findall(f"./{{{EWS_TYPES_NS}}}{path}/{{{EWS_TYPES_NS}}}Attendee"): + mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox") + record = ews_mailbox_record(mailbox) or {} + record["role"] = role + response = text_of(attendee, "ResponseType") + if response: + record["status"] = response + attendees.append(record) + return attendees + + +def ews_reminders(item: ET.Element) -> list[dict[str, Any]]: + if text_of(item, "ReminderIsSet") == "true": + minutes = int_or_default(text_of(item, "ReminderMinutesBeforeStart"), 15) + return [{"action": "DISPLAY", "trigger_minutes_before": minutes}] + return [] + + +def element_to_dict(element: ET.Element) -> dict[str, Any]: + tag = element.tag.rsplit("}", 1)[-1] + children = list(element) + result: dict[str, Any] = {"tag": tag} + if element.attrib: + result["attributes"] = dict(element.attrib) + if element.text and element.text.strip(): + result["text"] = element.text.strip() + if children: + result["children"] = [element_to_dict(child) for child in children] + return result + + +def int_or_default(value: str | None, default: int) -> int: + try: + return int(value) if value is not None else default + except ValueError: + return default + + +def xml_escape(value: str) -> str: + return value.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +def sync_due_sources( + session: Session, + *, + tenant_id: str | None = None, + user_id: str | None = None, + limit: int = 50, + now: datetime | None = None, + client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None, +) -> list[CalendarCalDavDueSyncResult]: + due_at = normalize_datetime(now or utcnow()) + query = session.query(CalendarSyncSource).filter( + CalendarSyncSource.source_kind.in_(SYNC_SOURCE_KINDS), + CalendarSyncSource.sync_enabled.is_(True), + CalendarSyncSource.deleted_at.is_(None), + or_(CalendarSyncSource.next_sync_at.is_(None), CalendarSyncSource.next_sync_at <= due_at), + ) + if tenant_id is not None: + query = query.filter(CalendarSyncSource.tenant_id == tenant_id) + sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all() + results: list[CalendarCalDavDueSyncResult] = [] + for source in sources: + try: + if source.source_kind == "caldav": + client = client_factory(source) if client_factory else None + refreshed, stats = sync_caldav_source( + session, + tenant_id=source.tenant_id, + user_id=user_id, + source_id=source.id, + client=client, + ) + else: + refreshed, stats = sync_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id) + results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats)) + except Exception as exc: + results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc))) + session.flush() + return results + + def sync_due_caldav_sources( session: Session, *, @@ -524,13 +1557,7 @@ def sync_due_caldav_sources( for source in sources: try: client = client_factory(source) if client_factory else None - refreshed, stats = sync_caldav_source( - session, - tenant_id=source.tenant_id, - user_id=user_id, - source_id=source.id, - client=client, - ) + refreshed, stats = sync_caldav_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id, client=client) results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats)) except Exception as exc: results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc))) @@ -564,8 +1591,13 @@ def apply_caldav_report( seen_hrefs.add(href) calendar_data = item.calendar_data if calendar_data is None: - calendar_data = client.fetch_object(item.href) - stats.fetched += 1 + try: + calendar_data = client.fetch_object(item.href) + stats.fetched += 1 + except CalDAVNotFound: + stats.deleted += soft_delete_caldav_href(session, source=source, href=href) + stats.errors.append(f"CalDAV object disappeared during sync and was treated as deleted: {href}") + continue created, updated, unchanged, deleted = import_caldav_resource( session, source=source, @@ -594,7 +1626,9 @@ def apply_caldav_report( deleted_at = utcnow() for event in query: if event.source_href not in seen_hrefs: + previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at + record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) stats.deleted += 1 @@ -621,10 +1655,12 @@ def import_caldav_resource( .all() } created = updated = unchanged = deleted = 0 + changes: list[tuple[CalendarEvent, str, dict[str, Any] | None]] = [] raw_ics = str(parsed_events[0].get("raw_ics") or ics) for parsed in parsed_events: key = (parsed["uid"], parsed.get("recurrence_id")) event = find_event_for_caldav_component(session, source=source, href=href, parsed=parsed) + previous = calendar_event_change_payload(event, prefix="previous_") if event else None was_deleted = bool(event and event.deleted_at is not None) if event is None: event = CalendarEvent( @@ -636,20 +1672,27 @@ def import_caldav_resource( ) session.add(event) created += 1 + changes.append((event, "created", previous)) else: if was_deleted: created += 1 + changes.append((event, "created", previous)) elif event.etag == etag and event.raw_ics == raw_ics: unchanged += 1 else: updated += 1 + changes.append((event, "updated", previous)) apply_parsed_event_to_model(event, parsed, source=source, href=href, etag=etag, raw_ics=raw_ics, user_id=user_id) remaining.pop(key, None) deleted_at = utcnow() for event in remaining.values(): + previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at deleted += 1 + changes.append((event, "deleted", previous)) session.flush() + for event, operation, previous in changes: + record_calendar_event_change(session, event=event, operation=operation, user_id=user_id, previous=previous) return created, updated, unchanged, deleted @@ -703,14 +1746,14 @@ def apply_parsed_event_to_model( ): setattr(event, field_name, parsed.get(field_name)) event.calendar_id = source.calendar_id - event.source_kind = "caldav" + event.source_kind = source.source_kind event.source_href = href event.etag = etag event.raw_ics = raw_ics event.updated_by_user_id = user_id event.deleted_at = None metadata = dict(event.metadata_ or {}) - metadata["caldav"] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag} + metadata[source.source_kind] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag} event.metadata_ = metadata validate_event_time(event) @@ -729,7 +1772,9 @@ def soft_delete_caldav_href(session: Session, *, source: CalendarSyncSource, hre ) .all() ): + previous = calendar_event_change_payload(event, prefix="previous_") event.deleted_at = deleted_at + record_calendar_event_change(session, event=event, operation="deleted", user_id=None, previous=previous) count += 1 return count @@ -739,9 +1784,13 @@ def normalize_caldav_href(collection_url: str, href: str) -> str: def mark_calendar_caldav(calendar: CalendarCollection, source: CalendarSyncSource) -> None: + mark_calendar_sync_source(calendar, source) + + +def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSyncSource) -> None: metadata = dict(calendar.metadata_ or {}) - metadata.setdefault("source_kind", "caldav") - metadata["connector_kind"] = "caldav" + metadata.setdefault("source_kind", source.source_kind) + metadata["connector_kind"] = source.source_kind metadata["sync_source_id"] = source.id metadata["sync_href"] = source.collection_url metadata["sync_direction"] = source.sync_direction @@ -876,8 +1925,8 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo if not calendar_id: raise CalendarError("calendar_id is required because no default calendar exists") get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) - source = active_caldav_source_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) - assert_caldav_mutation_allowed(source) + source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) + assert_sync_mutation_allowed(source) event = CalendarEvent( tenant_id=tenant_id, calendar_id=calendar_id, @@ -917,27 +1966,29 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo session.flush() if should_push_event_to_caldav(source=source, event=event): push_caldav_event_resource(session, source=source, event=event, create=True) + record_calendar_event_change(session, event=event, operation="created", user_id=user_id) return event def update_event(session: Session, *, tenant_id: str, user_id: str | None, event_id: str, payload: CalendarEventUpdateRequest) -> CalendarEvent: event = get_event(session, tenant_id=tenant_id, event_id=event_id) + previous = calendar_event_change_payload(event, prefix="previous_") original_calendar_id = event.calendar_id - original_source = active_caldav_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) + original_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) original_caldav = event.source_kind == "caldav" and bool(event.source_href) if payload.calendar_id is not None: get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) - target_source = active_caldav_source_for_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) - assert_caldav_mutation_allowed(target_source) + target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) + assert_sync_mutation_allowed(target_source) if payload.calendar_id != event.calendar_id and original_source and original_caldav: - assert_caldav_mutation_allowed(original_source) + assert_sync_mutation_allowed(original_source) push_caldav_delete_for_event(session, source=original_source, event=event) event.source_kind = "local" event.source_href = None event.etag = None event.calendar_id = payload.calendar_id else: - assert_caldav_mutation_allowed(original_source) + assert_sync_mutation_allowed(original_source) scalar_fields = ( "sequence", "summary", @@ -979,7 +2030,7 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event validate_event_time(event) event.raw_ics = None session.flush() - target_source = active_caldav_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) + target_source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) if should_push_event_to_caldav(source=target_source, event=event): push_caldav_event_resource( session, @@ -987,17 +2038,33 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event event=event, create=event.source_kind != "caldav" or not event.source_href or original_calendar_id != event.calendar_id, ) + record_calendar_event_change(session, event=event, operation="updated", user_id=user_id, previous=previous) return event -def delete_event(session: Session, *, tenant_id: str, event_id: str) -> None: +def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None: event = get_event(session, tenant_id=tenant_id, event_id=event_id) - source = active_caldav_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) - assert_caldav_mutation_allowed(source) + previous = calendar_event_change_payload(event, prefix="previous_") + source = active_sync_source_for_calendar(session, tenant_id=tenant_id, calendar_id=event.calendar_id) + assert_sync_mutation_allowed(source) if should_push_event_to_caldav(source=source, event=event): push_caldav_delete_for_event(session, source=source, event=event) event.deleted_at = utcnow() session.flush() + record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) + + +def active_sync_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None: + return ( + session.query(CalendarSyncSource) + .filter( + CalendarSyncSource.tenant_id == tenant_id, + CalendarSyncSource.calendar_id == calendar_id, + CalendarSyncSource.deleted_at.is_(None), + ) + .order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc()) + .first() + ) def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> CalendarSyncSource | None: @@ -1014,13 +2081,19 @@ def active_caldav_source_for_calendar(session: Session, *, tenant_id: str, calen ) +def assert_sync_mutation_allowed(source: CalendarSyncSource | None) -> None: + if source is None: + return + if source.source_kind != "caldav" or source.sync_direction != "two_way": + raise CalendarError(f"This {sync_source_label(source.source_kind)} calendar is inbound-only and cannot be changed locally") + + def assert_caldav_mutation_allowed(source: CalendarSyncSource | None) -> None: - if source is not None and source.sync_direction != "two_way": - raise CalendarError("This CalDAV calendar is inbound-only and cannot be changed locally") + assert_sync_mutation_allowed(source) def should_push_event_to_caldav(*, source: CalendarSyncSource | None, event: CalendarEvent) -> bool: - return bool(source and source.sync_direction == "two_way" and event.deleted_at is None) + return bool(source and source.source_kind == "caldav" and source.sync_direction == "two_way" and event.deleted_at is None) def push_caldav_event_resource(session: Session, *, source: CalendarSyncSource, event: CalendarEvent, create: bool) -> None: diff --git a/src/govoplan_calendar/backend/tasks.py b/src/govoplan_calendar/backend/tasks.py index 9e21986..1488758 100644 --- a/src/govoplan_calendar/backend/tasks.py +++ b/src/govoplan_calendar/backend/tasks.py @@ -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 [ { diff --git a/tests/test_caldav.py b/tests/test_caldav.py index 2d0cc3c..114a5fc 100644 --- a/tests/test_caldav.py +++ b/tests/test_caldav.py @@ -9,7 +9,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables -from govoplan_calendar.backend.caldav import CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus +from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest from govoplan_calendar.backend.service import ( @@ -19,7 +19,9 @@ from govoplan_calendar.backend.service import ( create_calendar, create_caldav_source, create_event, + delete_calendar, delete_event, + list_caldav_sources, list_freebusy, sync_caldav_source, sync_due_caldav_sources, @@ -36,12 +38,14 @@ class FakeCalDAVClient: list_result: CalDAVReportResult | None = None, sync_result: CalDAVReportResult | None = None, objects: dict[str, str] | None = None, + fetch_not_found: set[str] | None = None, put_etags: list[str | None] | None = None, fail_precondition: bool = False, ) -> None: self.list_result = list_result or CalDAVReportResult() self.sync_result = sync_result self.objects = objects or {} + self.fetch_not_found = fetch_not_found or set() self.put_etags = put_etags or ['"etag-written"'] self.fail_precondition = fail_precondition self.fetched: list[str] = [] @@ -61,6 +65,8 @@ class FakeCalDAVClient: def fetch_object(self, href: str) -> str: self.fetched.append(href) + if href in self.fetch_not_found: + raise CalDAVNotFound(f"GET {href} returned HTTP 404") return self.objects[href] def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult: @@ -116,6 +122,104 @@ END:VCALENDAR self.assertIn("BEGIN:VEVENT", result.objects[0].calendar_data or "") self.assertTrue(result.objects[1].deleted) + def test_discovery_resolves_nextcloud_dav_root_to_calendar_collections(self) -> None: + requests: list[tuple[str, str, str | None]] = [] + + def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int): + requests.append((method, url, headers.get("Authorization"))) + if url == "https://cloud.example.test/remote.php/dav/": + return 207, {}, b""" + + + /remote.php/dav/ + + + /remote.php/dav/principals/users/ada/ + + HTTP/1.1 200 OK + + +""" + if url == "https://cloud.example.test/remote.php/dav/principals/users/ada/": + return 207, {}, b""" + + + /remote.php/dav/principals/users/ada/ + + + /remote.php/dav/calendars/ada/ + + HTTP/1.1 200 OK + + +""" + if url == "https://cloud.example.test/remote.php/dav/calendars/ada/": + return 207, {}, b""" + + + /remote.php/dav/calendars/ada/ + + + HTTP/1.1 200 OK + + + + /remote.php/dav/calendars/ada/personal/ + + + Personal + + + #44aa99ff + ctag-1 + + HTTP/1.1 200 OK + + +""" + raise AssertionError(f"Unexpected {method} {url}") + + client = CalDAVClient(collection_url="https://cloud.example.test/remote.php/dav", username="ada", password="secret", transport=transport) + calendars = client.discover_calendars() + + self.assertEqual(len(calendars), 1) + self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/") + self.assertEqual(calendars[0].display_name, "Personal") + self.assertEqual(calendars[0].color, "#44aa99ff") + self.assertEqual(requests[0][2], "Basic YWRhOnNlY3JldA==") + + def test_discovery_accepts_host_path_url_without_scheme(self) -> None: + requested_urls: list[str] = [] + + def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int): + requested_urls.append(url) + return 207, {}, b""" + + + /remote.php/dav/calendars/ada/personal/ + + + Personal + + + + HTTP/1.1 200 OK + + +""" + + client = CalDAVClient(collection_url="cloud.example.test/remote.php/dav", transport=transport) + calendars = client.discover_calendars() + + self.assertEqual(requested_urls[0], "https://cloud.example.test/remote.php/dav/") + self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/") + + def test_discovery_reports_relative_url_as_caldav_error(self) -> None: + client = CalDAVClient(collection_url="/remote.php/dav") + + with self.assertRaisesRegex(CalDAVError, "unknown url type"): + client.discover_calendars() + class CalDAVSyncTests(unittest.TestCase): def setUp(self) -> None: @@ -153,6 +257,79 @@ class CalDAVSyncTests(unittest.TestCase): self.assertEqual(client.username, "ada") self.assertEqual(client.password, "secret") + def test_delete_calendar_retires_caldav_source_and_credential(self) -> None: + session = self.Session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) + source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest( + calendar_id=calendar.id, + collection_url="https://dav.example.test/cal", + auth_type="basic", + username="ada", + password="secret", + ), + ) + session.commit() + + delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id) + session.commit() + + credential = session.query(CalendarSyncCredential).one() + self.assertIsNotNone(source.deleted_at) + self.assertIsNotNone(credential.deleted_at) + self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), []) + + def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None: + session = self.Session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old")) + old_source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest(calendar_id=old_calendar.id, collection_url="https://dav.example.test/cal"), + ) + old_calendar.deleted_at = datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc) + new_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="New")) + session.commit() + + new_source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest(calendar_id=new_calendar.id, collection_url="https://dav.example.test/cal"), + ) + session.commit() + + self.assertIsNotNone(old_source.deleted_at) + self.assertEqual(new_source.calendar_id, new_calendar.id) + self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id]) + + def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None: + session = self.Session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First")) + second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second")) + create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest(calendar_id=first.id, collection_url="https://dav.example.test/cal"), + ) + session.commit() + + with self.assertRaisesRegex(CalendarError, "already linked"): + create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest(calendar_id=second.id, collection_url="https://dav.example.test/cal"), + ) + def test_due_sync_runs_due_sources_and_reschedules(self) -> None: session = self.Session() session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) @@ -381,6 +558,57 @@ class CalDAVSyncTests(unittest.TestCase): self.assertEqual(source.sync_token, "token-2") self.assertIsNotNone(event.deleted_at) + def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None: + session = self.Session() + tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant") + session.add(tenant) + calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) + source = create_caldav_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"), + ) + source.sync_token = "token-1" + event = CalendarEvent( + tenant_id="tenant-1", + calendar_id=calendar.id, + uid="event-1@example.test", + recurrence_id=None, + summary="Remote", + status="CONFIRMED", + transparency="OPAQUE", + classification="PUBLIC", + start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc), + all_day=False, + attendees=[], + categories=[], + rdate=[], + exdate=[], + reminders=[], + attachments=[], + related_to=[], + source_kind="caldav", + source_href="https://dav.example.test/cal/event-1.ics", + icalendar={}, + metadata_={"caldav": {"source_id": source.id}}, + ) + session.add(event) + session.commit() + + client = FakeCalDAVClient( + sync_result=CalDAVReportResult(objects=[CalDAVObject(href="/cal/event-1.ics", etag='"gone"', calendar_data=None)], sync_token="token-2"), + fetch_not_found={"/cal/event-1.ics"}, + ) + source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client) + session.commit() + + self.assertTrue(stats.used_sync_token) + self.assertEqual(stats.deleted, 1) + self.assertEqual(source.last_status, "ok") + self.assertEqual(source.sync_token, "token-2") + self.assertIsNotNone(event.deleted_at) + def recurring_resource() -> str: return """BEGIN:VCALENDAR diff --git a/tests/test_ical.py b/tests/test_ical.py index f72e148..21eae75 100644 --- a/tests/test_ical.py +++ b/tests/test_ical.py @@ -92,6 +92,7 @@ END:VCALENDAR self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc)) self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc)) self.assertEqual(event["duration_seconds"], 5400) + self.assertTrue(event["icalendar"]["standard"]["uses_duration"]) self.assertEqual(event["description"], "Line one\nLine two") self.assertEqual(event["location"], "Room; 1") self.assertEqual(event["timezone"], "Europe/Berlin") @@ -164,6 +165,38 @@ END:VCALENDAR self.assertIn("BEGIN:VALARM", ics) self.assertIn("TRIGGER:-PT15M", ics) + def test_generated_ics_preserves_duration_when_source_used_duration(self) -> None: + event = SimpleNamespace( + uid="event-duration@example.test", + start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc), + duration_seconds=5400, + all_day=False, + timezone="Europe/Berlin", + summary="Duration", + sequence=0, + status="CONFIRMED", + transparency="OPAQUE", + classification="PUBLIC", + description=None, + location=None, + categories=[], + rrule=None, + organizer=None, + attendees=[], + rdate=[], + exdate=[], + reminders=[], + attachments=[], + related_to=[], + icalendar={"standard": {"uses_duration": True}}, + ) + + ics = event_to_ics(event) + + self.assertIn("DURATION:PT1H30M", ics) + self.assertNotIn("DTEND", ics) + def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None: parsed = parse_vevent( """BEGIN:VCALENDAR diff --git a/tests/test_sync_sources.py b/tests/test_sync_sources.py new file mode 100644 index 0000000..efbc639 --- /dev/null +++ b/tests/test_sync_sources.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables +from govoplan_calendar.backend.db.models import CalendarEvent +from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest +from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, sync_source +from govoplan_core.db.base import Base +from govoplan_tenancy.backend.db.models import Tenant + + +class CalendarSyncSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine) + + def tearDown(self) -> None: + Base.metadata.drop_all(bind=self.engine) + + def session_with_calendar(self): + session = self.Session() + session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant")) + calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) + session.flush() + return session, calendar + + def test_ics_subscription_sync_imports_events_and_blocks_local_mutation(self) -> None: + session, calendar = self.session_with_calendar() + source = create_sync_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarSyncSourceCreateRequest( + source_kind="ics", + calendar_id=calendar.id, + collection_url="https://calendar.example.test/feed.ics", + sync_direction="two_way", + ), + ) + + ics = """BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:ics-1@example.test +DTSTART:20260708T090000Z +DTEND:20260708T100000Z +SUMMARY:ICS item +END:VEVENT +END:VCALENDAR +""" + with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {"etag": '"feed-1"'}, ics)): + refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id) + + event = session.query(CalendarEvent).one() + self.assertEqual(refreshed.sync_direction, "inbound") + self.assertEqual(stats.created, 1) + self.assertEqual(event.source_kind, "ics") + self.assertEqual(event.summary, "ICS item") + with self.assertRaisesRegex(CalendarError, "inbound-only"): + create_event( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarEventCreateRequest( + calendar_id=calendar.id, + summary="Local edit", + start_at=datetime(2026, 7, 8, 12, tzinfo=timezone.utc), + ), + ) + + def test_graph_sync_imports_delta_events(self) -> None: + session, calendar = self.session_with_calendar() + source = create_sync_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarSyncSourceCreateRequest( + source_kind="graph", + calendar_id=calendar.id, + collection_url="me/calendar", + auth_type="bearer", + bearer_token="graph-token", + ), + ) + payload = { + "value": [ + { + "id": "graph-event-1", + "iCalUId": "graph-uid-1", + "subject": "Graph item", + "start": {"dateTime": "2026-07-08T09:00:00", "timeZone": "UTC"}, + "end": {"dateTime": "2026-07-08T10:00:00", "timeZone": "UTC"}, + "@odata.etag": "graph-etag-1", + } + ], + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendar/events/delta?$deltatoken=next", + } + + with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, json.dumps(payload))) as request: + refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id) + + event = session.query(CalendarEvent).one() + self.assertEqual(stats.created, 1) + self.assertEqual(refreshed.sync_token, payload["@odata.deltaLink"]) + self.assertEqual(event.source_kind, "graph") + self.assertEqual(event.source_href, "graph-event-1") + self.assertEqual(event.summary, "Graph item") + self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"]) + + def test_ews_sync_imports_calendar_view_items(self) -> None: + session, calendar = self.session_with_calendar() + source = create_sync_source( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarSyncSourceCreateRequest( + source_kind="ews", + calendar_id=calendar.id, + collection_url="https://exchange.example.test/EWS/Exchange.asmx", + auth_type="basic", + username="ada", + password="secret", + ), + ) + response_xml = """ + + + + + + + + + + EWS item + 2026-07-08T09:00:00Z + 2026-07-08T10:00:00Z + false + ews-uid-1 + + + + + + + +""" + + with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, response_xml)) as request: + _refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id) + + event = session.query(CalendarEvent).one() + self.assertEqual(stats.created, 1) + self.assertEqual(event.source_kind, "ews") + self.assertEqual(event.source_href, "ews-event-1") + self.assertEqual(event.summary, "EWS item") + self.assertTrue(request.call_args.kwargs["body"].startswith(" | null; +}; + +export type CalendarCalDavSource = CalendarSyncSource; +export type CalendarSyncSourceListResponse = { sources: CalendarSyncSource[] }; +export type CalendarCalDavSourceListResponse = { sources: CalendarCalDavSource[] }; + +export type CalendarCalDavDiscoveryCandidate = { + collection_url: string; + href: string; + display_name?: string | null; + color?: string | null; + ctag?: string | null; + sync_token?: string | null; +}; + +export type CalendarCalDavDiscoveryPayload = { + url: string; + source_id?: string | null; + auth_type?: CalendarCalDavAuthType | null; + username?: string | null; + credential_ref?: string | null; + password?: string | null; + bearer_token?: string | null; +}; + +export type CalendarCalDavDiscoveryResponse = { calendars: CalendarCalDavDiscoveryCandidate[] }; + +export type CalendarSyncSourceCreatePayload = { + source_kind?: CalendarSyncSourceKind; + calendar_id: string; + collection_url: string; + display_name?: string | null; + auth_type?: CalendarCalDavAuthType; + username?: string | null; + credential_ref?: string | null; + password?: string | null; + bearer_token?: string | null; + sync_enabled?: boolean; + sync_interval_seconds?: number; + sync_direction?: CalendarCalDavSyncDirection; + conflict_policy?: CalendarCalDavConflictPolicy; + metadata?: Record; +}; + +export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload; + +export type CalendarCalDavSourceUpdatePayload = Partial; +export type CalendarSyncSourceUpdatePayload = Partial; + +export type CalendarSyncSourceSyncPayload = { + password?: string | null; + bearer_token?: string | null; + force_full?: boolean; +}; + +export type CalendarCalDavSyncPayload = CalendarSyncSourceSyncPayload; + +export type CalendarSyncSourceSyncResponse = { + source: CalendarSyncSource; + created: number; + updated: number; + deleted: number; + unchanged: number; + fetched: number; + full_sync: boolean; + used_sync_token: boolean; + sync_token?: string | null; + ctag?: string | null; + errors: string[]; +}; + +export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse; export type CalendarCollectionCreatePayload = { name: string; @@ -79,17 +190,34 @@ export type CalendarCollectionDeletePayload = { export type CalendarEventCreatePayload = { calendar_id?: string | null; + uid?: string | null; + recurrence_id?: string | null; + sequence?: number; summary: string; description?: string | null; location?: string | null; - start_at: string; - end_at?: string | null; - all_day?: boolean; - timezone?: string | null; status?: string; transparency?: string; classification?: string; + start_at: string; + end_at?: string | null; + duration_seconds?: number | null; + all_day?: boolean; + timezone?: string | null; + organizer?: Record | null; + attendees?: Record[]; categories?: string[]; + rrule?: Record | null; + rdate?: Record[]; + exdate?: Record[]; + reminders?: Record[]; + attachments?: Record[]; + related_to?: Record[]; + source_kind?: string; + source_href?: string | null; + etag?: string | null; + icalendar?: Record; + metadata?: Record; }; export type CalendarEventUpdatePayload = Partial; @@ -110,6 +238,57 @@ export function deleteCalendar(settings: ApiSettings, calendarId: string, payloa return apiFetch(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "DELETE", body: JSON.stringify(payload) }); } +export function listSyncSources(settings: ApiSettings, params: { calendar_id?: string; source_kind?: CalendarSyncSourceKind } = {}): Promise { + const search = new URLSearchParams(); + if (params.calendar_id) search.set("calendar_id", params.calendar_id); + if (params.source_kind) search.set("source_kind", params.source_kind); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/calendar/sync-sources${suffix}`); +} + +export function listCalDavSources(settings: ApiSettings, params: { calendar_id?: string } = {}): Promise { + const search = new URLSearchParams(); + if (params.calendar_id) search.set("calendar_id", params.calendar_id); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/calendar/caldav/sources${suffix}`); +} + +export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise { + return apiFetch(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) }); +} + +export function createSyncSource(settings: ApiSettings, payload: CalendarSyncSourceCreatePayload): Promise { + return apiFetch(settings, "/api/v1/calendar/sync-sources", { method: "POST", body: JSON.stringify(payload) }); +} + +export function createCalDavSource(settings: ApiSettings, payload: CalendarCalDavSourceCreatePayload): Promise { + return apiFetch(settings, "/api/v1/calendar/caldav/sources", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateSyncSource(settings: ApiSettings, sourceId: string, payload: CalendarSyncSourceUpdatePayload): Promise { + return apiFetch(settings, `/api/v1/calendar/sync-sources/${sourceId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function updateCalDavSource(settings: ApiSettings, sourceId: string, payload: CalendarCalDavSourceUpdatePayload): Promise { + return apiFetch(settings, `/api/v1/calendar/caldav/sources/${sourceId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteSyncSource(settings: ApiSettings, sourceId: string): Promise { + return apiFetch(settings, `/api/v1/calendar/sync-sources/${sourceId}`, { method: "DELETE" }); +} + +export function deleteCalDavSource(settings: ApiSettings, sourceId: string): Promise { + return apiFetch(settings, `/api/v1/calendar/caldav/sources/${sourceId}`, { method: "DELETE" }); +} + +export function syncSyncSource(settings: ApiSettings, sourceId: string, payload: CalendarSyncSourceSyncPayload = {}): Promise { + return apiFetch(settings, `/api/v1/calendar/sync-sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) }); +} + +export function syncCalDavSource(settings: ApiSettings, sourceId: string, payload: CalendarCalDavSyncPayload = {}): Promise { + return apiFetch(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) }); +} + export function listCalendarEvents( settings: ApiSettings, params: { calendar_id?: string; start_at?: string; end_at?: string } = {} @@ -122,6 +301,20 @@ export function listCalendarEvents( return apiFetch(settings, `/api/v1/calendar/events${suffix}`); } +export function listCalendarEventsDelta( + settings: ApiSettings, + params: { calendar_id?: string; start_at?: string; end_at?: string; since?: string | null; limit?: number } = {} +): Promise { + const search = new URLSearchParams(); + if (params.calendar_id) search.set("calendar_id", params.calendar_id); + if (params.start_at) search.set("start_at", params.start_at); + if (params.end_at) search.set("end_at", params.end_at); + if (params.since) search.set("since", params.since); + if (params.limit) search.set("limit", String(params.limit)); + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/calendar/events/delta${suffix}`); +} + export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise { return apiFetch(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) }); } diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index da9eae3..34d40cf 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -6,53 +6,110 @@ import { type CSSProperties, type DragEvent as ReactDragEvent, type FormEvent, - type WheelEvent as ReactWheelEvent -} from "react"; + type ChangeEvent, + type WheelEvent as ReactWheelEvent } from +"react"; import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Button, + ColorPickerField, + DateField, Dialog, DismissibleAlert, - LoadingIndicator, + LoadingFrame, + SegmentedControl, + TimeField, ToggleSwitch, hasScope, + i18nMessage, + useUnsavedDraftGuard, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; import { createCalendar, createCalendarEvent, + createSyncSource, deleteCalendar, deleteCalendarEvent, + discoverCalDavCalendars, listCalendarEvents, + listCalendarEventsDelta, listCalendars, + listSyncSources, + syncSyncSource, updateCalendar, updateCalendarEvent, + updateSyncSource, + type CalendarCalDavAuthType, + type CalendarCalDavConflictPolicy, + type CalendarCalDavDiscoveryCandidate, + type CalendarCalDavDiscoveryPayload, + type CalendarCalDavSyncDirection, type CalendarCollection, type CalendarCollectionDeletePayload, type CalendarDeleteEventAction, type CalendarEvent, - type CalendarEventCreatePayload -} from "../../api/calendar"; + type CalendarEventCreatePayload, + type CalendarEventDeltaResponse, + type CalendarSyncSource, + type CalendarSyncSourceCreatePayload, + type CalendarSyncSourceKind, + type CalendarSyncSourceUpdatePayload } from +"../../api/calendar"; type CalendarMode = "continuous" | "month" | "week" | "workweek" | "day"; -type Range = { start: Date; end: Date }; -type EventDialogState = { kind: "create" } | { kind: "edit"; event: CalendarEvent }; -type CalendarEditDialogState = { +type CalendarSourceMode = "local" | CalendarSyncSourceKind; +type CalendarSourceSwitchMode = "local" | "caldav" | "ics" | "graph" | "ews"; +type CalendarEventEndMode = "end" | "duration"; +type Range = {start: Date;end: Date;}; +type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;}; +type CalendarCollectionDialogState = +{kind: "create";} | +{ + kind: "edit"; calendar: CalendarCollection; eventCount: number | null; loadingEventCount: boolean; - confirmingDelete: boolean; }; -type AgendaGroup = { key: string; date: Date; events: CalendarEvent[] }; -type ContinuousViewport = { scrollTop: number; height: number }; -type CalendarDayDropTarget = { kind: "day"; key: string }; -type CalendarTimeDropTarget = { kind: "time"; key: string; minuteOfDay: number }; +type CalendarDeleteDialogState = { + calendar: CalendarCollection; + eventCount: number | null; + loadingEventCount: boolean; +}; +type CalendarCalDavFormPayload = { + collection_url: string; + dav_url: string; + display_name: string; + auth_type: CalendarCalDavAuthType; + username: string; + credential_ref: string; + password: string; + bearer_token: string; + sync_enabled: boolean; + sync_interval_seconds: number; + sync_direction: CalendarCalDavSyncDirection; + conflict_policy: CalendarCalDavConflictPolicy; +}; +type CalendarCollectionFormPayload = { + sourceMode: CalendarSourceMode; + name: string; + color: string; + caldav: CalendarCalDavFormPayload; +}; +type CalendarEventAdvancedPayload = Pick< + CalendarEventCreatePayload, + "organizer" | "attendees" | "categories" | "rrule" | "rdate" | "exdate" | "reminders" | "attachments" | "related_to" | "icalendar" | "metadata">; + +type AgendaGroup = {key: string;date: Date;events: CalendarEvent[];}; +type ContinuousViewport = {scrollTop: number;height: number;}; +type CalendarDayDropTarget = {kind: "day";key: string;}; +type CalendarTimeDropTarget = {kind: "time";key: string;minuteOfDay: number;}; type CalendarDropTarget = CalendarDayDropTarget | CalendarTimeDropTarget | null; type CalendarDragAction = - | { kind: "move"; event: CalendarEvent } - | { kind: "resize-start"; event: CalendarEvent } - | { kind: "resize-end"; event: CalendarEvent }; +{kind: "move";event: CalendarEvent;} | +{kind: "resize-start";event: CalendarEvent;} | +{kind: "resize-end";event: CalendarEvent;}; type CalendarResizeEdge = "start" | "end"; type CalendarViewPreferences = { dimWeekends: boolean; @@ -81,21 +138,22 @@ const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = { alternateContinuousMonths: true }; -const modeOptions: { id: CalendarMode; label: string }[] = [ - { id: "continuous", label: "Continuous" }, - { id: "month", label: "Month" }, - { id: "week", label: "Week" }, - { id: "workweek", label: "Workweek" }, - { id: "day", label: "Day" } -]; +const modeOptions: {id: CalendarMode;label: string;}[] = [ +{ id: "continuous", label: "i18n:govoplan-calendar.continuous.04f2ccda" }, +{ id: "month", label: "i18n:govoplan-calendar.month.082bc378" }, +{ id: "week", label: "i18n:govoplan-calendar.week.f82be68a" }, +{ id: "workweek", label: "i18n:govoplan-calendar.workweek.2fef6ea4" }, +{ id: "day", label: "i18n:govoplan-calendar.day.987b9ced" }]; -export default function CalendarPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + +export default function CalendarPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { const [calendars, setCalendars] = useState([]); + const [syncSources, setSyncSources] = useState([]); const [visibleCalendarIds, setVisibleCalendarIds] = useState([]); const [eventCalendarId, setEventCalendarId] = useState(""); - const [newCalendarName, setNewCalendarName] = useState(""); - const [newCalendarColor, setNewCalendarColor] = useState(DEFAULT_CALENDAR_COLOR); const [events, setEvents] = useState([]); + const eventsRef = useRef([]); + const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null }); const [mode, setMode] = useState(() => loadCalendarMode()); const [focusDate, setFocusDate] = useState(() => startOfDay(new Date())); const [continuousWeeks, setContinuousWeeks] = useState({ before: 8, after: 12 }); @@ -103,7 +161,9 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const [eventDialog, setEventDialog] = useState(null); - const [calendarEditDialog, setCalendarEditDialog] = useState(null); + const [calendarDialog, setCalendarDialog] = useState(null); + const [calendarDeleteDialog, setCalendarDeleteDialog] = useState(null); + const [syncingSourceId, setSyncingSourceId] = useState(""); const [continuousViewport, setContinuousViewport] = useState({ scrollTop: 0, height: 0 }); const [draggingEventId, setDraggingEventId] = useState(""); const [hoveredEventId, setHoveredEventId] = useState(""); @@ -117,6 +177,7 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []); const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]); const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]); + const syncSourceByCalendarId = useMemo(() => new Map(syncSources.map((source) => [source.calendar_id, source])), [syncSources]); const calendarColorById = useMemo(() => new Map(calendars.map((calendar) => [calendar.id, calendar.color || DEFAULT_CALENDAR_COLOR])), [calendars]); const targetCalendarId = eventCalendarId || calendars[0]?.id || ""; const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]); @@ -125,6 +186,7 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]); const canWrite = hasScope(auth, "calendar:event:write"); const canDelete = canWrite || hasScope(auth, "calendar:event:delete"); + const canSyncCalendars = hasScope(auth, "calendar:event:import"); const canManageCalendars = hasScope(auth, "calendar:calendar:write"); const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin"); const heading = headingForMode(mode, focusDate, visibleRange); @@ -173,7 +235,9 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings setError(""); try { const response = await listCalendars(settings); + const sourceResponse = await listSyncSources(settings); setCalendars(response.calendars); + setSyncSources(sourceResponse.sources); const ids = response.calendars.map((calendar) => calendar.id); setVisibleCalendarIds((current) => { const next = current.filter((id) => ids.includes(id)); @@ -190,14 +254,29 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings async function loadEvents() { setLoading(true); setError(""); + const start = visibleRange.start.toISOString(); + const end = visibleRange.end.toISOString(); + const deltaKey = `${start}|${end}`; try { - const response = await listCalendarEvents(settings, { - start_at: visibleRange.start.toISOString(), - end_at: visibleRange.end.toISOString() - }); - setEvents(response.events); + let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null; + let nextEvents = eventDeltaRef.current.key === deltaKey ? eventsRef.current : []; + let response = null; + do { + response = await listCalendarEventsDelta(settings, { + start_at: start, + end_at: end, + since + }); + nextEvents = mergeCalendarEventDelta(nextEvents, response); + since = response.watermark || null; + } while (response.has_more && response.watermark); + eventsRef.current = nextEvents; + eventDeltaRef.current = { key: deltaKey, watermark: since }; + setEvents(nextEvents); } catch (err) { setError(errorText(err)); + eventsRef.current = []; + eventDeltaRef.current = { key: "", watermark: null }; setEvents([]); } finally { setLoading(false); @@ -214,40 +293,76 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings } function openCalendarEditor(calendar: CalendarCollection) { - setCalendarEditDialog({ + setCalendarDialog({ + kind: "edit", calendar, eventCount: null, - loadingEventCount: true, - confirmingDelete: false + loadingEventCount: true + }); + void listCalendarEvents(settings, { calendar_id: calendar.id }). + then((response) => { + setCalendarDialog((current) => current?.kind === "edit" && current.calendar.id === calendar.id ? + { ...current, eventCount: response.events.length, loadingEventCount: false } : + current); + }). + catch((err) => { + setCalendarDialog((current) => current?.kind === "edit" && current.calendar.id === calendar.id ? + { ...current, eventCount: null, loadingEventCount: false } : + current); + setError(errorText(err)); }); - void listCalendarEvents(settings, { calendar_id: calendar.id }) - .then((response) => { - setCalendarEditDialog((current) => current?.calendar.id === calendar.id - ? { ...current, eventCount: response.events.length, loadingEventCount: false } - : current); - }) - .catch((err) => { - setCalendarEditDialog((current) => current?.calendar.id === calendar.id - ? { ...current, eventCount: null, loadingEventCount: false } - : current); - setError(errorText(err)); - }); } - async function handleCalendarSave(calendar: CalendarCollection, payload: { name: string; color: string }) { + async function handleCalendarSave(payload: CalendarCollectionFormPayload): Promise { + const currentDialog = calendarDialog; const name = payload.name.trim(); - if (!name || !canManageCalendars) return; + if (!name || !canManageCalendars) return false; setSaving(true); setError(""); try { - const updated = await updateCalendar(settings, calendar.id, { - name, - color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR - }); - setCalendars((current) => current.map((item) => item.id === calendar.id ? updated : item)); - setCalendarEditDialog(null); + let reloadSources = false; + if (currentDialog?.kind === "edit") { + const source = syncSourceByCalendarId.get(currentDialog.calendar.id); + const updated = await updateCalendar(settings, currentDialog.calendar.id, { + name, + color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR + }); + if (source && canDeleteCalendars) { + const updatedSource = await updateSyncSource(settings, source.id, syncSourceConnectionUpdatePayload(payload.caldav)); + setSyncSources((current) => current.map((item) => item.id === source.id ? updatedSource : item)); + reloadSources = true; + } + setCalendars((current) => current.map((item) => item.id === currentDialog.calendar.id ? updated : item)); + } else { + const calendar = await createCalendar(settings, { + name, + color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + }); + let createdSource: CalendarSyncSource | null = null; + if (payload.sourceMode !== "local") { + try { + createdSource = await createSyncSource(settings, { + ...syncSourceCreatePayload(payload.sourceMode, payload.caldav), + calendar_id: calendar.id + }); + reloadSources = true; + } catch (sourceError) { + await deleteCalendar(settings, calendar.id, { event_action: "delete" }).catch(() => undefined); + throw sourceError; + } + } + setCalendars((current) => [...current, calendar].sort(compareCalendars)); + setVisibleCalendarIds((current) => current.includes(calendar.id) ? current : [...current, calendar.id]); + setEventCalendarId(calendar.id); + if (createdSource) setSyncSources((current) => [...current, createdSource]); + } + setCalendarDialog(null); + if (reloadSources) await loadCalendars(); + return true; } catch (err) { setError(errorText(err)); + return false; } finally { setSaving(false); } @@ -255,18 +370,14 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings async function handleCalendarDelete(calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) { if (!canDeleteCalendars) return; - const currentDialog = calendarEditDialog; - if (!currentDialog || currentDialog.calendar.id !== calendar.id) return; - if (!currentDialog.confirmingDelete) { - setCalendarEditDialog({ ...currentDialog, confirmingDelete: true }); - return; - } setSaving(true); setError(""); try { await deleteCalendar(settings, calendar.id, payload); - setCalendarEditDialog(null); + setCalendarDialog(null); + setCalendarDeleteDialog(null); setCalendars((current) => current.filter((item) => item.id !== calendar.id)); + setSyncSources((current) => current.filter((item) => item.calendar_id !== calendar.id)); setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id)); setEventCalendarId((current) => current === calendar.id ? "" : current); await loadCalendars(); @@ -277,36 +388,47 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings } } catch (err) { setError(errorText(err)); - } - finally { + } finally + { setSaving(false); } } - async function handleCreateCalendar(formEvent: FormEvent) { - formEvent.preventDefault(); - const name = newCalendarName.trim(); - if (!name || !canManageCalendars) return; - setSaving(true); + function openCalendarDelete(calendar: CalendarCollection, eventCount: number | null = null, loadingEventCount = true) { + setCalendarDeleteDialog({ calendar, eventCount, loadingEventCount }); + if (!loadingEventCount) return; + void listCalendarEvents(settings, { calendar_id: calendar.id }). + then((response) => { + setCalendarDeleteDialog((current) => current?.calendar.id === calendar.id ? + { ...current, eventCount: response.events.length, loadingEventCount: false } : + current); + }). + catch((err) => { + setCalendarDeleteDialog((current) => current?.calendar.id === calendar.id ? + { ...current, eventCount: null, loadingEventCount: false } : + current); + setError(errorText(err)); + }); + } + + async function handleSyncSource(source: CalendarSyncSource, payload: {password?: string | null;bearer_token?: string | null;force_full?: boolean;} = {}) { + setSyncingSourceId(source.id); setError(""); try { - const calendar = await createCalendar(settings, { - name, - color: normalizeHexColor(newCalendarColor) || DEFAULT_CALENDAR_COLOR, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" - }); - setCalendars((current) => [...current, calendar].sort(compareCalendars)); - setVisibleCalendarIds((current) => current.includes(calendar.id) ? current : [...current, calendar.id]); - setEventCalendarId(calendar.id); - setNewCalendarName(""); - setNewCalendarColor(DEFAULT_CALENDAR_COLOR); + const response = await syncSyncSource(settings, source.id, payload); + setSyncSources((current) => current.map((item) => item.id === source.id ? response.source : item)); + await loadEvents(); } catch (err) { setError(errorText(err)); } finally { - setSaving(false); + setSyncingSourceId(""); } } + async function handleCalDavDiscovery(payload: CalendarCalDavDiscoveryPayload) { + return discoverCalDavCalendars(settings, payload); + } + function moveFocus(direction: -1 | 1) { if (mode === "continuous") return; if (mode === "month") { @@ -362,9 +484,9 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings function updateContinuousViewport(node: HTMLDivElement) { const next = { scrollTop: node.scrollTop, height: node.clientHeight }; - setContinuousViewport((current) => ( - Math.abs(current.scrollTop - next.scrollTop) < 1 && Math.abs(current.height - next.height) < 1 ? current : next - )); + setContinuousViewport((current) => + Math.abs(current.scrollTop - next.scrollTop) < 1 && Math.abs(current.height - next.height) < 1 ? current : next + ); } function scrollContinuousDayIntoView(day: Date, behavior: ScrollBehavior = "auto") { @@ -418,18 +540,18 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings function handleEventDragOverDay(dragEvent: ReactDragEvent, day: Date) { if (!allowEventDrop(dragEvent, (action) => action.kind === "move")) return; const key = dayKey(day); - setDropTarget((current) => (current?.kind === "day" && current.key === key ? current : { kind: "day", key })); + setDropTarget((current) => current?.kind === "day" && current.key === key ? current : { kind: "day", key }); } function handleEventDragOverTime(dragEvent: ReactDragEvent, day: Date) { if (!allowEventDrop(dragEvent, (action) => action.kind === "move" || action.kind === "resize-start" || action.kind === "resize-end")) return; const key = dayKey(day); const minuteOfDay = dropSlotMinuteOfDay(dragEvent); - setDropTarget((current) => ( - current?.kind === "time" && current.key === key && current.minuteOfDay === minuteOfDay - ? current - : { kind: "time", key, minuteOfDay } - )); + setDropTarget((current) => + current?.kind === "time" && current.key === key && current.minuteOfDay === minuteOfDay ? + current : + { kind: "time", key, minuteOfDay } + ); } function handleDropTargetLeave(dragEvent: ReactDragEvent) { @@ -451,13 +573,13 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings const action = dragActionRef.current; if (!action || !canWrite) return; setDropTarget(null); - const next = action.kind === "move" - ? moveEventToTime(action.event, day, minuteOfDay) - : resizeEventToTime(action.event, action.kind === "resize-start" ? "start" : "end", day, minuteOfDay); + const next = action.kind === "move" ? + moveEventToTime(action.event, day, minuteOfDay) : + resizeEventToTime(action.event, action.kind === "resize-start" ? "start" : "end", day, minuteOfDay); void moveEvent(action.event, next); } - async function moveEvent(calendarEvent: CalendarEvent, next: { startAt: Date; endAt: Date | null; allDay: boolean }) { + async function moveEvent(calendarEvent: CalendarEvent, next: {startAt: Date;endAt: Date | null;allDay: boolean;}) { setSaving(true); setError(""); try { @@ -484,7 +606,7 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings }); } - async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null) { + async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null): Promise { setSaving(true); setError(""); try { @@ -499,8 +621,10 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings } setEventDialog(null); await loadEvents(); + return true; } catch (err) { setError(errorText(err)); + return false; } finally { setSaving(false); } @@ -524,118 +648,124 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings
{error && {error}} -
+ +
-
-
+
+
-
- {modeOptions.map((option) => ( - - ))} -
+
-
- - - +
@@ -646,118 +776,132 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings
- - {canWrite && ( - - )} + }
- {loading &&
} - {mode === "continuous" ? ( -
+ {mode === "continuous" ? +
setEventDialog({ kind: "edit", event })} - onEventHover={setHoveredEventId} - onEventDragStart={handleEventDragStart} - onEventDragEnd={handleEventDragEnd} - onEventDragOverDay={handleEventDragOverDay} - onDropTargetLeave={handleDropTargetLeave} - onEventDropOnDay={handleEventDropOnDay} - /> -
- ) : mode === "month" ? ( - setEventDialog({ kind: "edit", event })} onEventHover={setHoveredEventId} onEventDragStart={handleEventDragStart} onEventDragEnd={handleEventDragEnd} onEventDragOverDay={handleEventDragOverDay} onDropTargetLeave={handleDropTargetLeave} - onEventDropOnDay={handleEventDropOnDay} - /> - ) : ( - setEventDialog({ kind: "edit", event })} - onEventHover={setHoveredEventId} - onEventDragStart={handleEventDragStart} - onEventResizeDragStart={handleEventResizeDragStart} - onEventDragEnd={handleEventDragEnd} - onEventDragOverDay={handleEventDragOverDay} - onEventDragOverTime={handleEventDragOverTime} - onDropTargetLeave={handleDropTargetLeave} - onEventDropOnDay={handleEventDropOnDay} - onEventDropOnTime={handleEventDropOnTime} - /> - )} + onEventDropOnDay={handleEventDropOnDay} /> + +
: + mode === "month" ? + setEventDialog({ kind: "edit", event })} + onEventHover={setHoveredEventId} + onEventDragStart={handleEventDragStart} + onEventDragEnd={handleEventDragEnd} + onEventDragOverDay={handleEventDragOverDay} + onDropTargetLeave={handleDropTargetLeave} + onEventDropOnDay={handleEventDropOnDay} /> : + + + setEventDialog({ kind: "edit", event })} + onEventHover={setHoveredEventId} + onEventDragStart={handleEventDragStart} + onEventResizeDragStart={handleEventResizeDragStart} + onEventDragEnd={handleEventDragEnd} + onEventDragOverDay={handleEventDragOverDay} + onEventDragOverTime={handleEventDragOverTime} + onDropTargetLeave={handleDropTargetLeave} + onEventDropOnDay={handleEventDropOnDay} + onEventDropOnTime={handleEventDropOnTime} /> + + }
+ + + {eventDialog && calendars.length > 0 && + setEventDialog(null)} + onSave={handleSave} + onDelete={handleDelete} /> + + } + {calendarDialog && + setCalendarDialog(null)} + onSave={handleCalendarSave} + onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)} + onSync={handleSyncSource} + onDiscover={handleCalDavDiscovery} /> + + } + {calendarDeleteDialog && + setCalendarDeleteDialog(null)} + onDelete={handleCalendarDelete} /> + + } +
); - {eventDialog && calendars.length > 0 && ( - setEventDialog(null)} - onSave={handleSave} - onDelete={handleDelete} - /> - )} - {calendarEditDialog && ( - setCalendarEditDialog(null)} - onSave={handleCalendarSave} - onDelete={handleCalendarDelete} - /> - )} -
- ); } function CalendarWeekRows({ @@ -779,86 +923,86 @@ function CalendarWeekRows({ onEventDragOverDay, onDropTargetLeave, onEventDropOnDay -}: { - days: Date[]; - eventsByDay: Map; - calendarColorById: Map; - focusDate: Date; - variant: "month" | "continuous"; - preferences: CalendarViewPreferences; - canWrite: boolean; - draggingEventId: string; - hoveredEventId: string; - dropTarget: CalendarDropTarget; - viewport?: ContinuousViewport; - onEventSelect: (event: CalendarEvent) => void; - onEventHover: (eventId: string) => void; - onEventDragStart: (dragEvent: ReactDragEvent, calendarEvent: CalendarEvent) => void; - onEventDragEnd: () => void; - onEventDragOverDay: (dragEvent: ReactDragEvent, day: Date) => void; - onDropTargetLeave: (dragEvent: ReactDragEvent) => void; - onEventDropOnDay: (dropEvent: ReactDragEvent, day: Date) => void; -}) { + + + + + + + + + + + + + + + + + + + +}: {days: Date[];eventsByDay: Map;calendarColorById: Map;focusDate: Date;variant: "month" | "continuous";preferences: CalendarViewPreferences;canWrite: boolean;draggingEventId: string;hoveredEventId: string;dropTarget: CalendarDropTarget;viewport?: ContinuousViewport;onEventSelect: (event: CalendarEvent) => void;onEventHover: (eventId: string) => void;onEventDragStart: (dragEvent: ReactDragEvent, calendarEvent: CalendarEvent) => void;onEventDragEnd: () => void;onEventDragOverDay: (dragEvent: ReactDragEvent, day: Date) => void;onDropTargetLeave: (dragEvent: ReactDragEvent) => void;onEventDropOnDay: (dropEvent: ReactDragEvent, day: Date) => void;}) { const weeks = chunk(days, 7); - const virtualWindow = variant === "continuous" && preferences.continuousVirtualization - ? continuousVirtualWindow(weeks.length, viewport ?? { scrollTop: 0, height: 0 }, preferences.continuousOverscanWeeks) - : { start: 0, end: weeks.length, topSpacerHeight: 0, bottomSpacerHeight: 0 }; + const virtualWindow = variant === "continuous" && preferences.continuousVirtualization ? + continuousVirtualWindow(weeks.length, viewport ?? { scrollTop: 0, height: 0 }, preferences.continuousOverscanWeeks) : + { start: 0, end: weeks.length, topSpacerHeight: 0, bottomSpacerHeight: 0 }; const visibleWeeks = weeks.slice(virtualWindow.start, virtualWindow.end); return (
- {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => {label})} + {["i18n:govoplan-calendar.mon.24b2a099", "i18n:govoplan-calendar.tue.529541bb", "i18n:govoplan-calendar.wed.23408b19", "i18n:govoplan-calendar.thu.3593ccd9", "i18n:govoplan-calendar.fri.bbd6e32e", "i18n:govoplan-calendar.sat.6b782d41", "i18n:govoplan-calendar.sun.48c98cab"].map((label) => {label})}
{virtualWindow.topSpacerHeight > 0 &&