From b328a6d649760fdc15cccc74a8e3261df5929da9 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 7 Jul 2026 15:49:06 +0200 Subject: [PATCH] Release v0.1.5 --- README.md | 21 +- docs/CALENDAR_INTEGRATION_CONCEPT.md | 9 +- package.json | 4 +- pyproject.toml | 8 +- src/govoplan_calendar/backend/caldav.py | 275 +++ src/govoplan_calendar/backend/db/models.py | 59 +- src/govoplan_calendar/backend/ical.py | 770 +++++-- src/govoplan_calendar/backend/manifest.py | 25 +- .../8d9e0f1a2b3c_caldav_sync_sources.py | 75 + ...2b3c4d_caldav_credentials_outbound_sync.py | 88 + src/govoplan_calendar/backend/router.py | 167 +- src/govoplan_calendar/backend/schemas.py | 147 +- src/govoplan_calendar/backend/service.py | 908 +++++++- src/govoplan_calendar/backend/tasks.py | 27 + tests/test_caldav.py | 435 ++++ tests/test_ical.py | 152 +- tests/test_service.py | 115 + webui/package.json | 4 +- webui/src/api/calendar.ts | 43 + webui/src/features/calendar/CalendarPage.tsx | 1943 +++++++++++++++-- webui/src/styles/calendar.css | 1157 ++++++++-- 21 files changed, 5718 insertions(+), 714 deletions(-) create mode 100644 src/govoplan_calendar/backend/caldav.py create mode 100644 src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py create mode 100644 src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py create mode 100644 src/govoplan_calendar/backend/tasks.py create mode 100644 tests/test_caldav.py create mode 100644 tests/test_service.py diff --git a/README.md b/README.md index 8e3cd31..627781b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,26 @@ The first backend implementation stores VEVENT data in two layers: - normalized fields for API range queries and calendar rendering - raw iCalendar property records and generated `text/calendar` output so unsupported VEVENT properties and parameters can round-trip -This is intentionally not yet a network CalDAV server. CalDAV protocol sync, external ETags, REPORT handling, scheduling inbox/outbox behavior, and full recurrence expansion are calendar-owned follow-up work. +CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source +records the remote collection URL, sync token, ETag/ctag state, username, +credential reference, sync interval, sync direction, and conflict policy. +Credentials can be supplied transiently for manual sync, referenced from +environment variables with `env:NAME`, stored through a platform secret provider +when one is available, or stored as encrypted calendar-owned credentials. + +Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection` +when a sync token exists. It imports all VEVENT components in a resource and +soft-deletes local events when remote resources disappear. Two-way sources also +write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and +ETag preconditions. If a remote resource changed, the local mutation is rejected +and the user must sync before retrying. A calendar-owned task, +`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or +cron-style scheduler. + +This is not yet a full CalDAV network server. Scheduling inbox/outbox behavior, +CalDAV server endpoints, UI credential management, and full user-facing +recurrence editing remain follow-up work. The backend now includes a free/busy +API primitive for scheduling and appointment modules. ## Development diff --git a/docs/CALENDAR_INTEGRATION_CONCEPT.md b/docs/CALENDAR_INTEGRATION_CONCEPT.md index cfdb471..f3d57fe 100644 --- a/docs/CALENDAR_INTEGRATION_CONCEPT.md +++ b/docs/CALENDAR_INTEGRATION_CONCEPT.md @@ -22,9 +22,11 @@ The first standalone module provides: - normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag - iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export - API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS +- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks +- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling - WebUI views: month, week, workweek, day, and continuous week-row scrolling -The first implementation is not yet a full CalDAV network server. It is the internal calendar storage and UI foundation on which CalDAV sync, Open-Xchange integration, recurrence expansion, and free/busy endpoints can be built. +The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built. ## Integration Points @@ -112,8 +114,9 @@ The connector boundary should hand calendar a configured profile and credentials ## Follow-Up Work - Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances. -- Free/busy endpoint and capability for scheduling, appointments, and resource checks. -- CalDAV sync adapter with collection sync tokens, ETags, REPORT handling, and conflict resolution. +- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances. +- UI management for CalDAV credentials, sync status, sync direction, and conflict resolution. +- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections. - Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model. - Attendee RSVP workflow and mail/notification bridge. - Resource calendars for rooms, equipment, counters, and service desks. diff --git a/package.json b/package.json index ffbb364..cf236dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/calendar-webui", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -19,7 +19,7 @@ "LICENSE" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.4", + "@govoplan/core-webui": "^0.1.5", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pyproject.toml b/pyproject.toml index 5959b05..5975ba0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,15 +4,17 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-calendar" -version = "0.1.4" +version = "0.1.5" description = "GovOPlaN calendar module with VEVENT storage and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.4", - "govoplan-access>=0.1.4", + "govoplan-core>=0.1.5", + "govoplan-access>=0.1.5", + "icalendar>=7.2", + "python-dateutil>=2.9", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_calendar/backend/caldav.py b/src/govoplan_calendar/backend/caldav.py new file mode 100644 index 0000000..0736907 --- /dev/null +++ b/src/govoplan_calendar/backend/caldav.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import base64 +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from typing import Mapping, Protocol +from xml.etree import ElementTree + + +class CalDAVError(RuntimeError): + pass + + +class CalDAVSyncUnsupported(CalDAVError): + pass + + +class CalDAVPreconditionFailed(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]: + ... + + +@dataclass(frozen=True, slots=True) +class CalDAVObject: + href: str + etag: str | None = None + calendar_data: str | None = None + deleted: bool = False + + +@dataclass(frozen=True, slots=True) +class CalDAVReportResult: + objects: list[CalDAVObject] = field(default_factory=list) + sync_token: str | None = None + ctag: str | None = None + + +@dataclass(frozen=True, slots=True) +class CalDAVWriteResult: + href: str + etag: str | None = None + status: int = 0 + + +class CalDAVClient: + def __init__( + self, + *, + collection_url: str, + username: str | None = None, + password: str | None = None, + bearer_token: str | None = None, + timeout: int = 30, + transport: CalDAVTransport | None = None, + ) -> None: + self.collection_url = ensure_collection_url(collection_url) + self.username = username + self.password = password + self.bearer_token = bearer_token + self.timeout = timeout + self.transport = transport or urllib_transport + + def propfind_collection(self) -> CalDAVReportResult: + body = b""" + + + + + + +""" + payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207}) + return parse_multistatus(payload) + + def list_objects(self) -> CalDAVReportResult: + body = b""" + + + + + + + + + + +""" + payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207}) + return parse_multistatus(payload) + + def sync_collection(self, sync_token: str) -> CalDAVReportResult: + body = f""" + + {xml_escape(sync_token)} + 1 + + + + +""".encode("utf-8") + try: + payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207}) + except CalDAVError as exc: + raise CalDAVSyncUnsupported(str(exc)) from exc + return parse_multistatus(payload) + + def fetch_object(self, href: str) -> str: + payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200}) + return payload.decode("utf-8") + + def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult: + headers = {"Content-Type": "text/calendar; charset=utf-8"} + if create: + headers["If-None-Match"] = "*" + elif etag and not overwrite: + headers["If-Match"] = etag + elif not overwrite: + raise CalDAVPreconditionFailed("Refusing CalDAV PUT without an ETag") + status, response_headers, _payload = self.request_raw( + "PUT", + self.object_url(href), + body=ics.encode("utf-8"), + depth=None, + expected={200, 201, 204}, + extra_headers=headers, + ) + return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status) + + def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> CalDAVWriteResult: + headers: dict[str, str] = {} + if etag and not overwrite: + headers["If-Match"] = etag + elif not overwrite: + raise CalDAVPreconditionFailed("Refusing CalDAV DELETE without an ETag") + status, response_headers, _payload = self.request_raw( + "DELETE", + self.object_url(href), + body=None, + depth=None, + expected={200, 202, 204, 404}, + extra_headers=headers, + ) + return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status) + + def object_url(self, href: str) -> str: + return urllib.parse.urljoin(self.collection_url, href) + + def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes: + _status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected) + return payload + + def request_raw( + self, + method: str, + url: str, + *, + body: bytes | None, + depth: str | None, + expected: set[int], + extra_headers: Mapping[str, str] | None = None, + ) -> tuple[int, Mapping[str, str], bytes]: + headers: dict[str, str] = { + "Accept": "application/xml,text/calendar,*/*", + "User-Agent": "govoplan-calendar-caldav/0.1", + } + if body is not None: + headers["Content-Type"] = "application/xml; charset=utf-8" + if depth is not None: + headers["Depth"] = depth + if self.bearer_token: + headers["Authorization"] = f"Bearer {self.bearer_token}" + elif self.username and self.password: + token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii") + headers["Authorization"] = f"Basic {token}" + if extra_headers: + headers.update(dict(extra_headers)) + status, response_headers, payload = self.transport(method, url, headers, body, self.timeout) + if status not in expected: + if status == 412: + raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed") + raise CalDAVError(f"{method} {url} returned HTTP {status}") + return status, response_headers, payload + + +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: + 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 + + +def parse_multistatus(payload: bytes) -> CalDAVReportResult: + try: + root = ElementTree.fromstring(payload) + except ElementTree.ParseError as exc: + raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc + objects: list[CalDAVObject] = [] + sync_token = first_child_text(root, "sync-token") + ctag = first_child_text(root, "getctag") + for response in child_elements(root, "response"): + href = first_child_text(response, "href") + if not href: + continue + deleted = False + etag = None + calendar_data = None + for propstat in child_elements(response, "propstat"): + status = first_child_text(propstat, "status") or "" + prop = first_child(propstat, "prop") + if prop is None: + continue + if " 404 " in status or status.endswith(" 404"): + deleted = True + continue + if " 200 " not in status and not status.endswith(" 200"): + continue + etag = first_child_text(prop, "getetag") or etag + calendar_data = first_child_text(prop, "calendar-data") or calendar_data + sync_token = first_child_text(prop, "sync-token") or sync_token + ctag = first_child_text(prop, "getctag") or ctag + objects.append(CalDAVObject(href=href, etag=strip_weak_etag(etag), calendar_data=calendar_data, deleted=deleted)) + return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag) + + +def ensure_collection_url(value: str) -> str: + return value if value.endswith("/") else f"{value}/" + + +def strip_weak_etag(value: str | None) -> str | None: + if value is None: + return None + return value.strip() + + +def response_etag(headers: Mapping[str, str]) -> str | None: + for key, value in headers.items(): + if key.lower() == "etag" and value: + return strip_weak_etag(value) + return None + + +def xml_escape(value: str) -> str: + return value.replace("&", "&").replace("<", "<").replace(">", ">") + + +def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None: + for child in element: + if local_name(child.tag) == name: + return child + return None + + +def first_child_text(element: ElementTree.Element, name: str) -> str | None: + found = first_child(element, name) + if found is None or found.text is None: + return None + return found.text.strip() + + +def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]: + return [child for child in element if local_name(child.tag) == name] + + +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 a7f0b61..758a993 100644 --- a/src/govoplan_calendar/backend/db/models.py +++ b/src/govoplan_calendar/backend/db/models.py @@ -94,4 +94,61 @@ class CalendarEvent(Base, TimestampMixin): calendar: Mapped[CalendarCollection] = relationship(back_populates="events") -__all__ = ["CalendarCollection", "CalendarEvent", "new_uuid"] +class CalendarSyncSource(Base, TimestampMixin): + __tablename__ = "calendar_sync_sources" + __table_args__ = ( + Index("ix_calendar_sync_sources_calendar", "tenant_id", "calendar_id", "source_kind"), + Index( + "uq_calendar_sync_sources_active_url", + "tenant_id", + "source_kind", + "collection_url", + unique=True, + sqlite_where=text("deleted_at IS NULL"), + postgresql_where=text("deleted_at IS NULL"), + ), + ) + + 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) + 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) + display_name: Mapped[str | None] = mapped_column(String(255)) + auth_type: Mapped[str] = mapped_column(String(30), default="none", nullable=False) + username: Mapped[str | None] = mapped_column(String(255)) + credential_ref: Mapped[str | None] = mapped_column(String(255)) + sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + sync_interval_seconds: Mapped[int] = mapped_column(Integer, default=900, nullable=False) + sync_direction: Mapped[str] = mapped_column(String(30), default="two_way", nullable=False) + conflict_policy: Mapped[str] = mapped_column(String(30), default="etag", nullable=False) + sync_token: Mapped[str | None] = mapped_column(Text) + ctag: Mapped[str | None] = mapped_column(String(255)) + last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + next_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + last_status: Mapped[str | None] = mapped_column(String(30)) + last_error: Mapped[str | None] = mapped_column(Text) + 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) + + calendar: Mapped[CalendarCollection] = relationship() + + +class CalendarSyncCredential(Base, TimestampMixin): + __tablename__ = "calendar_sync_credentials" + __table_args__ = ( + Index("ix_calendar_sync_credentials_tenant_kind", "tenant_id", "credential_kind"), + ) + + 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) + 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) + 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) + + +__all__ = ["CalendarCollection", "CalendarEvent", "CalendarSyncCredential", "CalendarSyncSource", "new_uuid"] diff --git a/src/govoplan_calendar/backend/ical.py b/src/govoplan_calendar/backend/ical.py index ce96fd5..06c88b4 100644 --- a/src/govoplan_calendar/backend/ical.py +++ b/src/govoplan_calendar/backend/ical.py @@ -1,239 +1,553 @@ from __future__ import annotations -from dataclasses import dataclass -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time, timedelta, timezone from email.utils import format_datetime from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from dateutil.rrule import rruleset, rrulestr +from icalendar import Alarm, Calendar, Event, vCalAddress +from icalendar.parser import Parameters class ICalendarError(ValueError): """Raised when iCalendar content cannot be parsed as a usable VEVENT.""" -@dataclass(frozen=True, slots=True) -class ICalendarProperty: - name: str - value: str - params: dict[str, list[str]] +GENERATED_EVENT_PROPERTIES = { + "UID", + "DTSTAMP", + "DTSTART", + "DTEND", + "DURATION", + "RECURRENCE-ID", + "SUMMARY", + "SEQUENCE", + "STATUS", + "TRANSP", + "CLASS", + "DESCRIPTION", + "LOCATION", + "CATEGORIES", + "RRULE", + "RDATE", + "EXDATE", + "ORGANIZER", + "ATTENDEE", + "ATTACH", + "RELATED-TO", +} - def as_dict(self) -> dict[str, Any]: - return {"name": self.name, "value": self.value, "params": self.params} + +class RawICalendarValue: + def __init__(self, value: str, params: dict[str, str]) -> None: + self.value = value + self.params = Parameters(params) + + def to_ical(self) -> bytes: + return self.value.encode("utf-8") def parse_vevent(ics: str) -> dict[str, Any]: - lines = unfold_ical_lines(ics) - event_lines = component_lines(lines, "VEVENT") - if not event_lines: + events = parse_vevents(ics) + if not events: raise ICalendarError("No VEVENT component found") + return events[0] - properties = [parse_content_line(line) for line in event_lines] - props = [prop.as_dict() for prop in properties] - by_name = group_properties(properties) - uid = first_value(by_name, "UID") +def parse_vevents(ics: str) -> list[dict[str, Any]]: + try: + calendar = Calendar.from_ical(ics) + except Exception as exc: # noqa: BLE001 - normalize parser errors for API callers. + raise ICalendarError(f"Invalid iCalendar content: {exc}") from exc + + events = [component for component in calendar.walk("VEVENT") if component.name == "VEVENT"] + if not events: + raise ICalendarError("No VEVENT component found") + normalized_ics = normalize_ics(ics) + return [parse_vevent_component(calendar, event, raw_ics=normalized_ics) for event in events] + + +def parse_vevent_component(calendar: Calendar, event: Event, *, raw_ics: str) -> dict[str, Any]: + uid = text_value(first_property_value(event, "UID")) if not uid: raise ICalendarError("VEVENT is missing UID") - dtstart_prop = first_property(by_name, "DTSTART") - if dtstart_prop is None: - raise ICalendarError("VEVENT is missing DTSTART") - start_at, all_day, tzid = parse_temporal_property(dtstart_prop) + dtstart = first_property_value(event, "DTSTART") + if dtstart is None: + raise ICalendarError("VEVENT is missing DTSTART") + start_at, all_day, tzid = temporal_property(dtstart) + end_at = None duration_seconds = None - dtend_prop = first_property(by_name, "DTEND") - if dtend_prop is not None: - end_at, _end_all_day, _end_tzid = parse_temporal_property(dtend_prop) + dtend = first_property_value(event, "DTEND") + duration = decoded_property(event, "DURATION") + if dtend is not None: + end_at, _end_all_day, _end_tzid = temporal_property(dtend) duration_seconds = int((end_at - start_at).total_seconds()) - elif first_value(by_name, "DURATION"): - # Keep full DURATION semantics in raw properties. The first module - # stores it but does not yet calculate every RFC 5545 duration form. - duration_seconds = None + elif isinstance(duration, timedelta): + end_at = start_at + duration + duration_seconds = int(duration.total_seconds()) - summary = first_value(by_name, "SUMMARY") or "(Untitled event)" - organizer = property_party(first_property(by_name, "ORGANIZER")) - attendees = [property_party(prop) for prop in by_name.get("ATTENDEE", [])] - categories = split_csv(first_value(by_name, "CATEGORIES") or "") + properties = component_property_records(event) + alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"] + standard = standard_property_index(properties) return { "uid": uid, - "recurrence_id": first_value(by_name, "RECURRENCE-ID"), - "sequence": int_or_zero(first_value(by_name, "SEQUENCE")), - "summary": summary, - "description": first_value(by_name, "DESCRIPTION"), - "location": first_value(by_name, "LOCATION"), - "status": (first_value(by_name, "STATUS") or "CONFIRMED").upper(), - "transparency": (first_value(by_name, "TRANSP") or "OPAQUE").upper(), - "classification": (first_value(by_name, "CLASS") or "PUBLIC").upper(), + "recurrence_id": recurrence_id_value(first_property_value(event, "RECURRENCE-ID")), + "sequence": int_or_zero(text_value(first_property_value(event, "SEQUENCE"))), + "summary": text_value(first_property_value(event, "SUMMARY")) or "(Untitled event)", + "description": text_value(first_property_value(event, "DESCRIPTION")), + "location": text_value(first_property_value(event, "LOCATION")), + "status": (text_value(first_property_value(event, "STATUS")) or "CONFIRMED").upper(), + "transparency": (text_value(first_property_value(event, "TRANSP")) or "OPAQUE").upper(), + "classification": (text_value(first_property_value(event, "CLASS")) or "PUBLIC").upper(), "start_at": start_at, "end_at": end_at, "duration_seconds": duration_seconds, "all_day": all_day, "timezone": tzid, - "organizer": organizer, - "attendees": attendees, - "categories": categories, - "rrule": parse_key_value_property(first_value(by_name, "RRULE")), - "rdate": [prop.as_dict() for prop in by_name.get("RDATE", [])], - "exdate": [prop.as_dict() for prop in by_name.get("EXDATE", [])], - "attachments": [prop.as_dict() for prop in by_name.get("ATTACH", [])], - "related_to": [prop.as_dict() for prop in by_name.get("RELATED-TO", [])], - "icalendar": {"component": "VEVENT", "properties": props}, - "raw_ics": normalize_ics(ics), + "organizer": party_record(first_property_value(event, "ORGANIZER")), + "attendees": [party_record(value) for value in property_values(event, "ATTENDEE")], + "categories": category_values(event), + "rrule": recurrence_rule(first_property_value(event, "RRULE")), + "rdate": records_for_name(properties, "RDATE"), + "exdate": records_for_name(properties, "EXDATE"), + "reminders": [alarm_to_reminder(alarm) for alarm in alarms], + "attachments": records_for_name(properties, "ATTACH"), + "related_to": records_for_name(properties, "RELATED-TO"), + "icalendar": { + "component": "VEVENT", + "schema_version": 1, + "properties": properties, + "alarms": alarms, + "standard": standard, + "method": text_value(first_property_value(calendar, "METHOD")), + "timezones": calendar_timezones(calendar), + }, + "raw_ics": raw_ics, } def event_to_ics(event: Any) -> str: - properties = [ - ("UID", {}, event.uid), - ("DTSTAMP", {}, format_ical_datetime(datetime.now(timezone.utc))), - ("DTSTART", date_params(event), format_ical_temporal(event.start_at, all_day=event.all_day)), - ] + return events_to_ics([event]) + + +def events_to_ics(events: list[Any]) -> str: + calendar = Calendar() + calendar.add("version", "2.0") + calendar.add("prodid", "-//GovOPlaN//Calendar//EN") + calendar.add("calscale", "GREGORIAN") + + method = next((metadata_value(event, "method") for event in events if metadata_value(event, "method")), None) + if method: + calendar.add("method", method) + + for event in events: + calendar.add_component(event_to_component(event)) + return calendar.to_ical().decode("utf-8") + + +def event_to_component(event: Any) -> Event: + component = 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: - properties.append(("DTEND", date_params(event), format_ical_temporal(event.end_at, all_day=event.all_day))) - properties.extend( - [ - ("SUMMARY", {}, event.summary), - ("SEQUENCE", {}, str(event.sequence or 0)), - ("STATUS", {}, event.status), - ("TRANSP", {}, event.transparency), - ("CLASS", {}, event.classification), - ] - ) + 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))) + if getattr(event, "recurrence_id", None): + add_recurrence_id(component, str(event.recurrence_id)) + component.add("summary", event.summary) + component.add("sequence", int(event.sequence or 0)) + component.add("status", event.status) + component.add("transp", event.transparency) + component.add("class", event.classification) if event.description: - properties.append(("DESCRIPTION", {}, event.description)) + component.add("description", event.description) if event.location: - properties.append(("LOCATION", {}, event.location)) - for category in event.categories or []: - if category: - properties.append(("CATEGORIES", {}, category)) + component.add("location", event.location) + if event.categories: + component.add("categories", list(event.categories)) if event.rrule: - properties.append(("RRULE", {}, serialize_key_value_property(event.rrule))) + component.add("rrule", normalized_rrule_for_export(event.rrule)) if event.organizer: - properties.append(party_to_property("ORGANIZER", event.organizer)) + component.add("organizer", party_for_export(event.organizer)) for attendee in event.attendees or []: - properties.append(party_to_property("ATTENDEE", attendee)) + component.add("attendee", party_for_export(attendee)) + for record in getattr(event, "rdate", None) or []: + add_raw_property(component, "RDATE", record) + for record in getattr(event, "exdate", None) or []: + add_raw_property(component, "EXDATE", record) + for record in getattr(event, "attachments", None) or []: + add_raw_property(component, "ATTACH", record) + for record in getattr(event, "related_to", None) or []: + add_raw_property(component, "RELATED-TO", record) - lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//GovOPlaN//Calendar//EN", "CALSCALE:GREGORIAN", "BEGIN:VEVENT"] - lines.extend(fold_ical_line(serialize_content_line(name, params, value)) for name, params, value in properties if value is not None) - lines.extend(["END:VEVENT", "END:VCALENDAR", ""]) - return "\r\n".join(lines) + add_preserved_properties(component, getattr(event, "icalendar", None) or {}) + for alarm in alarms_for_export(event): + component.add_component(alarm) + return component -def unfold_ical_lines(ics: str) -> list[str]: - raw_lines = ics.replace("\r\n", "\n").replace("\r", "\n").split("\n") - lines: list[str] = [] - for line in raw_lines: - if not line: - continue - if line[:1] in {" ", "\t"} and lines: - lines[-1] += line[1:] - else: - lines.append(line) - return lines +def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]: + """Expand an event's recurrence primitives within a range. + + This is a backend primitive for later API/UI recurrence handling. It expands + RRULE/RDATE/EXDATE data stored by this module and returns occurrence starts + with the event's original duration. + """ + + start_at = normalize_datetime(event.start_at) + end_at = normalize_datetime(event.end_at) if event.end_at is not None else None + range_start = normalize_datetime(range_start) + range_end = normalize_datetime(range_end) + duration = occurrence_duration(event, start_at=start_at, end_at=end_at) + + if not event.rrule and not event.rdate: + return [occurrence_payload(start_at, duration, event)] if occurrence_overlaps(start_at, duration, range_start, range_end) else [] + + rules = rruleset() + if event.rrule: + rules.rrule(rrulestr(serialize_key_value_property(event.rrule), dtstart=start_at)) + for rdate in temporal_values_from_records(event.rdate or []): + rules.rdate(rdate) + for exdate in temporal_values_from_records(event.exdate or []): + rules.exdate(exdate) + + starts = rules.between(range_start - duration, range_end, inc=True) + occurrences: list[dict[str, Any]] = [] + for occurrence_start in starts: + occurrence_start = normalize_datetime(occurrence_start) + if occurrence_overlaps(occurrence_start, duration, range_start, range_end): + occurrences.append(occurrence_payload(occurrence_start, duration, event)) + if len(occurrences) >= limit: + break + return occurrences -def component_lines(lines: list[str], component: str) -> list[str]: - begin = f"BEGIN:{component.upper()}" - end = f"END:{component.upper()}" - depth = 0 - collected: list[str] = [] - for line in lines: - upper = line.upper() - if upper == begin: - depth += 1 - if depth == 1: - collected = [] - continue - if upper == end and depth: - depth -= 1 - if depth == 0: - return collected - continue - if depth: - collected.append(line) - return [] +def first_vevent(calendar: Calendar) -> Event: + for component in calendar.walk("VEVENT"): + if component.name == "VEVENT": + return component + raise ICalendarError("No VEVENT component found") -def parse_content_line(line: str) -> ICalendarProperty: - head, sep, value = line.partition(":") - if not sep: - raise ICalendarError(f"Invalid iCalendar content line: {line}") - parts = head.split(";") - name = parts[0].upper() - params: dict[str, list[str]] = {} - for raw_param in parts[1:]: - key, param_sep, raw_value = raw_param.partition("=") - if not param_sep: - params[key.upper()] = [""] - else: - params[key.upper()] = [unescape_text(item.strip('"')) for item in raw_value.split(",")] - return ICalendarProperty(name=name, params=params, value=unescape_text(value)) +def first_property_value(component: Any, name: str) -> Any | None: + values = property_values(component, name) + return values[0] if values else None -def group_properties(properties: list[ICalendarProperty]) -> dict[str, list[ICalendarProperty]]: - grouped: dict[str, list[ICalendarProperty]] = {} - for prop in properties: - grouped.setdefault(prop.name, []).append(prop) - return grouped +def property_values(component: Any, name: str) -> list[Any]: + value = component.get(name.upper()) + if value is None: + return [] + return value if isinstance(value, list) else [value] -def first_property(grouped: dict[str, list[ICalendarProperty]], name: str) -> ICalendarProperty | None: - items = grouped.get(name.upper()) or [] - return items[0] if items else None - - -def first_value(grouped: dict[str, list[ICalendarProperty]], name: str) -> str | None: - prop = first_property(grouped, name) - return prop.value if prop else None - - -def parse_temporal_property(prop: ICalendarProperty) -> tuple[datetime, bool, str | None]: - value_type = (prop.params.get("VALUE") or [""])[0].upper() - tzid = (prop.params.get("TZID") or [None])[0] - if value_type == "DATE" or (len(prop.value) == 8 and "T" not in prop.value): - parsed_date = datetime.strptime(prop.value, "%Y%m%d").date() - return datetime.combine(parsed_date, time.min, tzinfo=timezone.utc), True, tzid - return parse_ical_datetime(prop.value), False, tzid - - -def parse_ical_datetime(value: str) -> datetime: - if value.endswith("Z"): - return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc) - parsed = datetime.strptime(value, "%Y%m%dT%H%M%S") - return parsed.replace(tzinfo=timezone.utc) - - -def format_ical_temporal(value: datetime, *, all_day: bool) -> str: - if all_day: - return value.date().strftime("%Y%m%d") - return format_ical_datetime(value) - - -def format_ical_datetime(value: datetime) -> str: - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - - -def date_params(event: Any) -> dict[str, list[str]]: - if event.all_day: - return {"VALUE": ["DATE"]} - if event.timezone: - return {"TZID": [event.timezone]} - return {} - - -def property_party(prop: ICalendarProperty | None) -> dict[str, Any] | None: - if prop is None: +def decoded_property(component: Any, name: str) -> Any | None: + try: + return component.decoded(name.upper()) + except KeyError: return None - return {"value": prop.value, "params": prop.params} -def party_to_property(name: str, party: dict[str, Any]) -> tuple[str, dict[str, list[str]], str]: - params = party.get("params") if isinstance(party.get("params"), dict) else {} +def temporal_property(value: Any) -> tuple[datetime, bool, str | None]: + decoded = decoded_temporal_value(value) + params = params_to_json(getattr(value, "params", {})) + tzid = first_param(params, "TZID") + if isinstance(decoded, datetime): + return normalize_datetime(decoded), False, tzid + if isinstance(decoded, date): + return datetime.combine(decoded, time.min, tzinfo=timezone.utc), True, tzid + raise ICalendarError(f"Unsupported temporal value: {value}") + + +def decoded_temporal_value(value: Any) -> Any: + decoded = getattr(value, "dt", None) + return decoded if decoded is not None else value + + +def recurrence_id_value(value: Any | None) -> str | None: + if value is None: + return None + raw_value = ical_value(value) + params = params_to_json(getattr(value, "params", {})) + tzid = first_param(params, "TZID") + return f"TZID={tzid}:{raw_value}" if tzid else raw_value + + +def recurrence_rule(value: Any | None) -> dict[str, Any] | None: + if value is None: + return None + if hasattr(value, "items"): + result: dict[str, Any] = {} + for key, raw_items in value.items(): + items = raw_items if isinstance(raw_items, list) else [raw_items] + normalized = [rrule_item(item) for item in items] + result[str(key).upper()] = normalized if len(normalized) > 1 else normalized[0] + return result + return parse_key_value_property(ical_value(value)) + + +def rrule_item(value: Any) -> str: + if isinstance(value, datetime): + return format_ical_datetime(value) + if isinstance(value, date): + return value.strftime("%Y%m%d") + return str(value).upper() if isinstance(value, str) else str(value) + + +def category_values(component: Any) -> list[str]: + decoded = decoded_property(component, "CATEGORIES") + if decoded is None: + return [] + groups = decoded if isinstance(decoded, list) else [decoded] + categories: list[str] = [] + for group in groups: + values = group if isinstance(group, list) else [group] + categories.extend(str(value) for value in values if str(value)) + return categories + + +def component_property_records(component: Any) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for name, value in component.items(): + for item in value if isinstance(value, list) else [value]: + records.append(property_record(name, item)) + return records + + +def property_record(name: str, value: Any) -> dict[str, Any]: + record: dict[str, Any] = { + "name": str(name).upper(), + "value": ical_value(value), + "params": params_to_json(getattr(value, "params", {})), + } + decoded = decoded_temporal_value(value) + json_decoded = json_value(decoded) + if json_decoded is not None and json_decoded != record["value"]: + record["decoded"] = json_decoded + return record + + +def component_alarm_record(alarm: Any) -> dict[str, Any]: + return { + "component": "VALARM", + "properties": component_property_records(alarm), + } + + +def alarm_to_reminder(alarm: dict[str, Any]) -> dict[str, Any]: + properties = alarm.get("properties") or [] + trigger = first_record(properties, "TRIGGER") + return { + "action": record_value(properties, "ACTION") or "DISPLAY", + "trigger": trigger, + "description": record_value(properties, "DESCRIPTION"), + "summary": record_value(properties, "SUMMARY"), + "attendees": records_for_name(properties, "ATTENDEE"), + "duration": first_record(properties, "DURATION"), + "repeat": record_value(properties, "REPEAT"), + "attachments": records_for_name(properties, "ATTACH"), + "properties": properties, + } + + +def standard_property_index(properties: list[dict[str, Any]]) -> dict[str, Any]: + names = ( + "DTSTAMP", + "CREATED", + "LAST-MODIFIED", + "PRIORITY", + "URL", + "GEO", + "COMMENT", + "CONTACT", + "REQUEST-STATUS", + "RESOURCES", + ) + result: dict[str, Any] = {} + for name in names: + matching = records_for_name(properties, name) + if not matching: + continue + result[name.lower().replace("-", "_")] = matching if len(matching) > 1 else matching[0] + return result + + +def records_for_name(records: list[dict[str, Any]], name: str) -> list[dict[str, Any]]: + return [record for record in records if record.get("name") == name.upper()] + + +def first_record(records: list[dict[str, Any]], name: str) -> dict[str, Any] | None: + matching = records_for_name(records, name) + return matching[0] if matching else None + + +def record_value(records: list[dict[str, Any]], name: str) -> str | None: + record = first_record(records, name) + if not record: + return None + return str(record.get("value") or "") + + +def party_record(value: Any | None) -> dict[str, Any] | None: + if value is None: + return None + return {"value": text_value(value) or "", "params": params_to_json(getattr(value, "params", {}))} + + +def party_for_export(party: dict[str, Any]) -> Any: value = str(party.get("value") or party.get("email") or "") - normalized_params = {str(key).upper(): [str(item) for item in value] for key, value in params.items() if isinstance(value, list)} - return name, normalized_params, value + cal_address = vCalAddress(value) + for key, item in flattened_params(party.get("params") if isinstance(party.get("params"), dict) else {}).items(): + cal_address.params[key] = item + return cal_address + + +def text_value(value: Any | None) -> str | None: + if value is None: + return None + raw = value.decode("utf-8") if isinstance(value, bytes) else str(value) + return raw if raw != "" else None + + +def ical_value(value: Any) -> str: + if hasattr(value, "to_ical"): + raw = value.to_ical() + return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw) + if isinstance(value, bytes): + return value.decode("utf-8") + return str(value) + + +def params_to_json(params: Any) -> dict[str, list[str]]: + result: dict[str, list[str]] = {} + for key, value in dict(params or {}).items(): + values = value if isinstance(value, list) else [value] + result[str(key).upper()] = [str(item) for item in values] + return result + + +def flattened_params(params: dict[str, Any]) -> dict[str, str]: + result: dict[str, str] = {} + for key, value in (params or {}).items(): + values = value if isinstance(value, list) else [value] + if values: + result[str(key).upper()] = ",".join(str(item) for item in values) + return result + + +def first_param(params: dict[str, list[str]], name: str) -> str | None: + values = params.get(name.upper()) or [] + return values[0] if values else None + + +def json_value(value: Any) -> Any: + if isinstance(value, datetime): + return normalize_datetime(value).isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, timedelta): + return int(value.total_seconds()) + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return None + + +def calendar_timezones(calendar: Calendar) -> list[str]: + timezones: list[str] = [] + for component in calendar.walk("VTIMEZONE"): + tzid = text_value(first_property_value(component, "TZID")) + if tzid and tzid not in timezones: + timezones.append(tzid) + return timezones + + +def metadata_value(event: Any, key: str) -> str | None: + raw_metadata = getattr(event, "icalendar", None) + metadata = raw_metadata if isinstance(raw_metadata, dict) else {} + value = metadata.get(key) + return str(value) if value else None + + +def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime: + value = normalize_datetime(value) + if all_day: + return value.date() + if timezone_id: + try: + return value.astimezone(ZoneInfo(timezone_id)) + except ZoneInfoNotFoundError: + return value + return value + + +def add_raw_property(component: Event | Alarm, default_name: str, record: dict[str, Any]) -> None: + name = str(record.get("name") or default_name).upper() + value = record.get("value") + if not name or value is None: + return + raw_value = RawICalendarValue(str(value), flattened_params(record.get("params") or {})) + component.add(name, raw_value, encode=False) + + +def add_recurrence_id(component: Event, value: str) -> None: + params: dict[str, list[str]] = {} + raw_value = value + if value.startswith("TZID=") and ":" in value: + tzid, raw_value = value.removeprefix("TZID=").split(":", 1) + params["TZID"] = [tzid] + add_raw_property(component, "RECURRENCE-ID", {"name": "RECURRENCE-ID", "value": raw_value, "params": params}) + + +def add_preserved_properties(component: Event, metadata: dict[str, Any]) -> None: + for record in metadata.get("properties") or []: + if not isinstance(record, dict): + continue + name = str(record.get("name") or "").upper() + if not name or name in GENERATED_EVENT_PROPERTIES: + continue + add_raw_property(component, name, record) + + +def alarms_for_export(event: Any) -> list[Alarm]: + reminders = getattr(event, "reminders", None) or [] + raw_metadata = getattr(event, "icalendar", None) + if not reminders and isinstance(raw_metadata, dict): + reminders = raw_metadata.get("alarms") or [] + alarms: list[Alarm] = [] + for reminder in reminders: + if not isinstance(reminder, dict): + continue + alarm = Alarm() + properties = reminder.get("properties") + if isinstance(properties, list): + for record in properties: + if isinstance(record, dict): + add_raw_property(alarm, str(record.get("name") or ""), record) + else: + alarm.add("action", reminder.get("action") or "DISPLAY") + trigger = reminder.get("trigger") + if isinstance(trigger, dict): + add_raw_property(alarm, "TRIGGER", trigger) + if reminder.get("description"): + alarm.add("description", reminder["description"]) + if reminder.get("summary"): + alarm.add("summary", reminder["summary"]) + if alarm: + alarms.append(alarm) + return alarms + + +def normalized_rrule_for_export(value: dict[str, Any]) -> dict[str, list[Any]]: + result: dict[str, list[Any]] = {} + for key, item in value.items(): + result[str(key).lower()] = item if isinstance(item, list) else [item] + return result def parse_key_value_property(value: str | None) -> dict[str, Any] | None: @@ -243,7 +557,8 @@ def parse_key_value_property(value: str | None) -> dict[str, Any] | None: for item in value.split(";"): key, sep, raw = item.partition("=") if sep: - result[key.upper()] = raw.split(",") if "," in raw else raw + values = raw.split(",") if "," in raw else [raw] + result[key.upper()] = values if len(values) > 1 else values[0] return result or {"raw": value} @@ -257,8 +572,59 @@ def serialize_key_value_property(value: dict[str, Any]) -> str: return ";".join(parts) -def split_csv(value: str) -> list[str]: - return [item.strip() for item in value.split(",") if item.strip()] +def temporal_values_from_records(records: list[dict[str, Any]]) -> list[datetime]: + values: list[datetime] = [] + for record in records: + params = record.get("params") if isinstance(record.get("params"), dict) else {} + for raw_value in str(record.get("value") or "").split(","): + parsed = parse_ical_temporal(raw_value, params) + if parsed is not None: + values.append(parsed) + return values + + +def parse_ical_temporal(value: str, params: dict[str, Any]) -> datetime | None: + if not value: + return None + value_type = first_param(params_to_json(params), "VALUE") + if value_type == "DATE" or (len(value) == 8 and "T" not in value): + return datetime.combine(datetime.strptime(value, "%Y%m%d").date(), time.min, tzinfo=timezone.utc) + try: + if value.endswith("Z"): + return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc) + parsed = datetime.strptime(value, "%Y%m%dT%H%M%S") + except ValueError: + return None + tzid = first_param(params_to_json(params), "TZID") + if tzid: + try: + return parsed.replace(tzinfo=ZoneInfo(tzid)).astimezone(timezone.utc) + except ZoneInfoNotFoundError: + return parsed.replace(tzinfo=timezone.utc) + return parsed.replace(tzinfo=timezone.utc) + + +def occurrence_duration(event: Any, *, start_at: datetime, end_at: datetime | None) -> timedelta: + if end_at is not None: + return end_at - start_at + if event.duration_seconds is not None: + return timedelta(seconds=int(event.duration_seconds)) + return timedelta(days=1) if event.all_day else timedelta(0) + + +def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: datetime, range_end: datetime) -> bool: + end_at = start_at + duration + return end_at >= range_start and start_at <= range_end + + +def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]: + return { + "uid": event.uid, + "recurrence_id": format_ical_datetime(start_at), + "start_at": start_at, + "end_at": start_at + duration if duration else None, + "all_day": event.all_day, + } def int_or_zero(value: str | None) -> int: @@ -268,42 +634,20 @@ def int_or_zero(value: str | None) -> int: return 0 +def normalize_datetime(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def normalize_ics(ics: str) -> str: stripped = ics.strip().replace("\r\n", "\n").replace("\r", "\n") return "\r\n".join(stripped.split("\n")) + "\r\n" -def serialize_content_line(name: str, params: dict[str, list[str]], value: str) -> str: - param_text = "".join(f";{key}={','.join(escape_param(item) for item in items)}" for key, items in params.items() if items) - return f"{name}{param_text}:{escape_text(value)}" - - -def fold_ical_line(line: str, limit: int = 75) -> str: - if len(line) <= limit: - return line - parts = [line[:limit]] - line = line[limit:] - while line: - parts.append(" " + line[: limit - 1]) - line = line[limit - 1 :] - return "\r\n".join(parts) - - -def escape_text(value: str) -> str: - return value.replace("\\", "\\\\").replace("\n", "\\n").replace(";", "\\;").replace(",", "\\,") - - -def unescape_text(value: str) -> str: - return value.replace("\\n", "\n").replace("\\N", "\n").replace("\\;", ";").replace("\\,", ",").replace("\\\\", "\\") - - -def escape_param(value: str) -> str: - if any(char in value for char in [":", ";", ","]): - return '"' + value.replace('"', "'") + '"' - return value +def format_ical_datetime(value: datetime) -> str: + return normalize_datetime(value).strftime("%Y%m%dT%H%M%SZ") def http_last_modified(value: datetime) -> str: - if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return format_datetime(value.astimezone(timezone.utc), usegmt=True) + return format_datetime(normalize_datetime(value), usegmt=True) diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index caecfc7..aa6397c 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata +from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.db.base import Base @@ -64,11 +65,13 @@ ROLE_TEMPLATES = ( def _tenant_summary(session, tenant_id: str) -> dict[str, int]: - from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent + from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource return { "calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(), "calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(), + "calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(), + "calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(), } @@ -84,7 +87,7 @@ def _calendar_router(context: ModuleContext): manifest = ModuleManifest( id="calendar", name="Calendar", - version="0.1.4", + version="0.1.5", dependencies=("access",), optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"), permissions=PERMISSIONS, @@ -101,6 +104,24 @@ manifest = ModuleManifest( module_id="calendar", metadata=Base.metadata, script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + calendar_models.CalendarCollection, + calendar_models.CalendarEvent, + calendar_models.CalendarSyncCredential, + calendar_models.CalendarSyncSource, + label="Calendar", + ), + retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.", + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + calendar_models.CalendarCollection, + calendar_models.CalendarEvent, + calendar_models.CalendarSyncCredential, + calendar_models.CalendarSyncSource, + label="Calendar", + ), ), ) 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 new file mode 100644 index 0000000..2c5295a --- /dev/null +++ b/src/govoplan_calendar/backend/migrations/versions/8d9e0f1a2b3c_caldav_sync_sources.py @@ -0,0 +1,75 @@ +"""CalDAV sync sources + +Revision ID: 8d9e0f1a2b3c +Revises: 7c8d9e0f1a2b +Create Date: 2026-07-07 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "8d9e0f1a2b3c" +down_revision = "7c8d9e0f1a2b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "calendar_sync_sources" in tables: + return + op.create_table( + "calendar_sync_sources", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("calendar_id", sa.String(length=36), nullable=False), + sa.Column("source_kind", sa.String(length=30), nullable=False), + sa.Column("collection_url", sa.String(length=1000), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=True), + sa.Column("auth_type", sa.String(length=30), nullable=False), + sa.Column("username", sa.String(length=255), nullable=True), + sa.Column("credential_ref", sa.String(length=255), nullable=True), + sa.Column("sync_token", sa.Text(), nullable=True), + sa.Column("ctag", sa.String(length=255), nullable=True), + sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_status", sa.String(length=30), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + 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(["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.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_sources")), + ) + for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"): + op.create_index(op.f(f"ix_calendar_sync_sources_{column}"), "calendar_sync_sources", [column], unique=False) + op.create_index("ix_calendar_sync_sources_calendar", "calendar_sync_sources", ["tenant_id", "calendar_id", "source_kind"], unique=False) + op.create_index( + "uq_calendar_sync_sources_active_url", + "calendar_sync_sources", + ["tenant_id", "source_kind", "collection_url"], + unique=True, + sqlite_where=sa.text("deleted_at IS NULL"), + postgresql_where=sa.text("deleted_at IS NULL"), + ) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "calendar_sync_sources" not in tables: + return + for name in ( + "uq_calendar_sync_sources_active_url", + "ix_calendar_sync_sources_calendar", + op.f("ix_calendar_sync_sources_deleted_at"), + op.f("ix_calendar_sync_sources_source_kind"), + op.f("ix_calendar_sync_sources_calendar_id"), + op.f("ix_calendar_sync_sources_tenant_id"), + ): + op.drop_index(name, table_name="calendar_sync_sources") + op.drop_table("calendar_sync_sources") 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 new file mode 100644 index 0000000..0d3b2ec --- /dev/null +++ b/src/govoplan_calendar/backend/migrations/versions/9e0f1a2b3c4d_caldav_credentials_outbound_sync.py @@ -0,0 +1,88 @@ +"""CalDAV credentials and outbound sync metadata + +Revision ID: 9e0f1a2b3c4d +Revises: 8d9e0f1a2b3c +Create Date: 2026-07-07 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "9e0f1a2b3c4d" +down_revision = "8d9e0f1a2b3c" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "calendar_sync_sources" in tables: + columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")} + add_column_if_missing(columns, "sync_enabled", sa.Column("sync_enabled", sa.Boolean(), server_default=sa.true(), nullable=False)) + add_column_if_missing(columns, "sync_interval_seconds", sa.Column("sync_interval_seconds", sa.Integer(), server_default="900", nullable=False)) + add_column_if_missing(columns, "sync_direction", sa.Column("sync_direction", sa.String(length=30), server_default="two_way", nullable=False)) + add_column_if_missing(columns, "conflict_policy", sa.Column("conflict_policy", sa.String(length=30), server_default="etag", nullable=False)) + add_column_if_missing(columns, "last_attempt_at", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True)) + add_column_if_missing(columns, "next_sync_at", sa.Column("next_sync_at", sa.DateTime(timezone=True), nullable=True)) + indexes = {index["name"] for index in inspector.get_indexes("calendar_sync_sources")} + if "ix_calendar_sync_sources_sync_enabled" not in indexes: + op.create_index(op.f("ix_calendar_sync_sources_sync_enabled"), "calendar_sync_sources", ["sync_enabled"], unique=False) + if "ix_calendar_sync_sources_next_sync_at" not in indexes: + op.create_index(op.f("ix_calendar_sync_sources_next_sync_at"), "calendar_sync_sources", ["next_sync_at"], unique=False) + + if "calendar_sync_credentials" not in tables: + op.create_table( + "calendar_sync_credentials", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("credential_kind", sa.String(length=30), nullable=False), + sa.Column("label", sa.String(length=255), nullable=True), + sa.Column("secret_encrypted", sa.Text(), nullable=True), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + 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.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_credentials")), + ) + for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"): + op.create_index(op.f(f"ix_calendar_sync_credentials_{column}"), "calendar_sync_credentials", [column], unique=False) + op.create_index("ix_calendar_sync_credentials_tenant_kind", "calendar_sync_credentials", ["tenant_id", "credential_kind"], unique=False) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "calendar_sync_credentials" in tables: + for name in ( + "ix_calendar_sync_credentials_tenant_kind", + op.f("ix_calendar_sync_credentials_deleted_at"), + op.f("ix_calendar_sync_credentials_created_by_user_id"), + op.f("ix_calendar_sync_credentials_credential_kind"), + op.f("ix_calendar_sync_credentials_tenant_id"), + ): + op.drop_index(name, table_name="calendar_sync_credentials") + op.drop_table("calendar_sync_credentials") + if "calendar_sync_sources" in tables: + indexes = {index["name"] for index in inspector.get_indexes("calendar_sync_sources")} + if "ix_calendar_sync_sources_next_sync_at" in indexes: + op.drop_index(op.f("ix_calendar_sync_sources_next_sync_at"), table_name="calendar_sync_sources") + if "ix_calendar_sync_sources_sync_enabled" in indexes: + op.drop_index(op.f("ix_calendar_sync_sources_sync_enabled"), table_name="calendar_sync_sources") + columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")} + for column in ("next_sync_at", "last_attempt_at", "conflict_policy", "sync_direction", "sync_interval_seconds", "sync_enabled"): + if column in columns: + op.drop_column("calendar_sync_sources", column) + + +def add_column_if_missing(existing: set[str], name: str, column: sa.Column) -> None: + if name in existing: + return + op.add_column("calendar_sync_sources", column) + existing.add(name) diff --git a/src/govoplan_calendar/backend/router.py b/src/govoplan_calendar/backend/router.py index c51d9a6..94b9944 100644 --- a/src/govoplan_calendar/backend/router.py +++ b/src/govoplan_calendar/backend/router.py @@ -2,13 +2,23 @@ from __future__ import annotations from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified +from govoplan_calendar.backend.caldav import CalDAVError from govoplan_calendar.backend.schemas import ( + CalendarCalDavDueSyncItemResponse, + CalendarCalDavDueSyncResponse, + CalendarCalDavSourceCreateRequest, + CalendarCalDavSourceListResponse, + CalendarCalDavSourceResponse, + CalendarCalDavSourceUpdateRequest, + CalendarCalDavSyncRequest, + CalendarCalDavSyncResponse, CalendarCollectionCreateRequest, + CalendarCollectionDeleteRequest, CalendarCollectionListResponse, CalendarCollectionResponse, CalendarCollectionUpdateRequest, @@ -16,21 +26,33 @@ from govoplan_calendar.backend.schemas import ( CalendarEventListResponse, CalendarEventResponse, CalendarEventUpdateRequest, + CalendarFreeBusyRequest, + CalendarFreeBusyResponse, CalendarIcsImportRequest, ) from govoplan_calendar.backend.service import ( CalendarError, + caldav_source_response, + caldav_sync_response, calendar_response, create_calendar, + create_caldav_source, create_event, delete_calendar, + delete_caldav_source, delete_event, event_response, + get_caldav_source, get_event, import_ics_event, + list_freebusy, + list_caldav_sources, list_calendars, list_events, + sync_due_caldav_sources, + sync_caldav_source, update_calendar, + update_caldav_source, update_event, ) from govoplan_core.db.session import get_session @@ -56,6 +78,10 @@ def _event_response(event) -> CalendarEventResponse: return CalendarEventResponse.model_validate(event_response(event)) +def _caldav_source_response(source) -> CalendarCalDavSourceResponse: + return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source)) + + @router.get("/calendars", response_model=CalendarCollectionListResponse) def api_list_calendars( principal: ApiPrincipal = Depends(get_api_principal), @@ -105,12 +131,13 @@ def api_update_calendar( @router.delete("/calendars/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_calendar( calendar_id: str, + payload: CalendarCollectionDeleteRequest | None = Body(default=None), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:calendar:admin") try: - delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id) + delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload) session.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) except CalendarError as exc: @@ -118,6 +145,122 @@ def api_delete_calendar( raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc +@router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse) +def api_list_caldav_sources( + calendar_id: str | None = None, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:calendar:read") + sources = list_caldav_sources(session, tenant_id=principal.tenant_id, calendar_id=calendar_id) + return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in sources]) + + +@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED) +def api_create_caldav_source( + payload: CalendarCalDavSourceCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:calendar:admin") + try: + source = create_caldav_source(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload) + session.commit() + session.refresh(source) + return _caldav_source_response(source) + except CalendarError as exc: + session.rollback() + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse) +def api_update_caldav_source( + source_id: str, + payload: CalendarCalDavSourceUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:calendar:admin") + try: + source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) + session.commit() + session.refresh(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 + + +@router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_caldav_source( + source_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:calendar:admin") + try: + delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id) + 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("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse) +def api_sync_caldav_source( + source_id: str, + payload: CalendarCalDavSyncRequest | None = None, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:event:import") + payload = payload or CalendarCalDavSyncRequest() + try: + source, stats = sync_caldav_source( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + source_id=source_id, + password=payload.password, + bearer_token=payload.bearer_token, + force_full=payload.force_full, + ) + session.commit() + session.refresh(source) + return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats)) + except (CalendarError, CalDAVError, ICalendarError) as exc: + session.rollback() + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse) +def api_sync_due_caldav_sources( + limit: int = Query(default=50, ge=1, le=200), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:event:import") + results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit) + session.commit() + return CalendarCalDavDueSyncResponse( + results=[ + CalendarCalDavDueSyncItemResponse( + source_id=item.source_id, + calendar_id=item.calendar_id, + status=item.status, + error=item.error, + created=item.stats.created if item.stats else 0, + updated=item.stats.updated if item.stats else 0, + deleted=item.stats.deleted if item.stats else 0, + unchanged=item.stats.unchanged if item.stats else 0, + fetched=item.stats.fetched if item.stats else 0, + ) + for item in results + ] + ) + + @router.get("/events", response_model=CalendarEventListResponse) def api_list_events( calendar_id: str | None = None, @@ -242,3 +385,23 @@ def api_export_ics_event( "Last-Modified": http_last_modified(event.updated_at), }, ) + + +@router.post("/availability/freebusy", response_model=CalendarFreeBusyResponse) +def api_freebusy( + payload: CalendarFreeBusyRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:availability:read") + try: + busy = list_freebusy( + session, + tenant_id=principal.tenant_id, + start_at=payload.start_at, + end_at=payload.end_at, + calendar_ids=payload.calendar_ids, + ) + return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy) + except CalendarError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc diff --git a/src/govoplan_calendar/backend/schemas.py b/src/govoplan_calendar/backend/schemas.py index f68304e..c1f5cff 100644 --- a/src/govoplan_calendar/backend/schemas.py +++ b/src/govoplan_calendar/backend/schemas.py @@ -3,11 +3,15 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, SecretStr 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"] +CalendarCalDavConflictPolicy = Literal["etag", "overwrite"] class CalendarCollectionCreateRequest(BaseModel): @@ -37,6 +41,14 @@ class CalendarCollectionUpdateRequest(BaseModel): metadata: dict[str, Any] | None = None +class CalendarCollectionDeleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + event_action: CalendarDeleteEventAction = "delete" + target_calendar_id: str | None = None + make_target_default: bool = False + + class CalendarCollectionResponse(BaseModel): id: str tenant_id: str @@ -58,6 +70,113 @@ class CalendarCollectionListResponse(BaseModel): calendars: list[CalendarCollectionResponse] = Field(default_factory=list) +class CalendarCalDavSourceCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + calendar_id: str + collection_url: str = Field(min_length=1, max_length=1000) + display_name: str | None = Field(default=None, max_length=255) + auth_type: CalendarSyncAuthType = "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 + sync_enabled: bool = True + sync_interval_seconds: int = Field(default=900, ge=60) + sync_direction: CalendarSyncDirection = "two_way" + conflict_policy: CalendarCalDavConflictPolicy = "etag" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class CalendarCalDavSourceUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + calendar_id: str | None = None + collection_url: str | None = Field(default=None, min_length=1, max_length=1000) + display_name: str | None = Field(default=None, max_length=255) + 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 + sync_enabled: bool | None = None + sync_interval_seconds: int | None = Field(default=None, ge=60) + sync_direction: CalendarSyncDirection | None = None + conflict_policy: CalendarCalDavConflictPolicy | None = None + sync_token: str | None = None + ctag: str | None = Field(default=None, max_length=255) + metadata: dict[str, Any] | None = None + + +class CalendarCalDavSourceResponse(BaseModel): + id: str + tenant_id: str + calendar_id: str + source_kind: str + collection_url: str + display_name: str | None = None + auth_type: str + username: str | None = None + credential_ref: str | None = None + has_credential: bool = False + sync_enabled: bool + sync_interval_seconds: int + sync_direction: str + conflict_policy: str + sync_token: str | None = None + ctag: str | None = None + last_attempt_at: datetime | None = None + last_synced_at: datetime | None = None + next_sync_at: datetime | None = None + last_status: str | None = None + last_error: str | None = None + created_at: datetime + updated_at: datetime + metadata: dict[str, Any] = Field(default_factory=dict) + + +class CalendarCalDavSourceListResponse(BaseModel): + sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list) + + +class CalendarCalDavSyncRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + password: str | None = None + bearer_token: str | None = None + force_full: bool = False + + +class CalendarCalDavSyncResponse(BaseModel): + source: CalendarCalDavSourceResponse + created: int = 0 + updated: int = 0 + deleted: int = 0 + unchanged: int = 0 + fetched: int = 0 + full_sync: bool = False + used_sync_token: bool = False + sync_token: str | None = None + ctag: str | None = None + errors: list[str] = Field(default_factory=list) + + +class CalendarCalDavDueSyncItemResponse(BaseModel): + source_id: str + calendar_id: str + status: str + error: str | None = None + created: int = 0 + updated: int = 0 + deleted: int = 0 + unchanged: int = 0 + fetched: int = 0 + + +class CalendarCalDavDueSyncResponse(BaseModel): + results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list) + + class CalendarEventCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -164,6 +283,32 @@ class CalendarEventListResponse(BaseModel): events: list[CalendarEventResponse] = Field(default_factory=list) +class CalendarFreeBusyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + start_at: datetime + end_at: datetime + calendar_ids: list[str] | None = None + + +class CalendarBusyBlockResponse(BaseModel): + calendar_id: str + event_id: str + uid: str + recurrence_id: str | None = None + start_at: datetime + end_at: datetime | None = None + all_day: bool = False + transparency: str + status: str + + +class CalendarFreeBusyResponse(BaseModel): + start_at: datetime + end_at: datetime + busy: list[CalendarBusyBlockResponse] = Field(default_factory=list) + + class CalendarIcsImportRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index 4054085..9d77329 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -2,22 +2,65 @@ from __future__ import annotations import re import uuid -from datetime import datetime, timezone -from typing import Any +import os +import urllib.parse +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from typing import Any, Callable from sqlalchemy import or_ from sqlalchemy.orm import Session -from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent -from govoplan_calendar.backend.ical import parse_vevent -from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarCollectionUpdateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest +from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, 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 ( + CalendarCalDavSourceCreateRequest, + CalendarCalDavSourceUpdateRequest, + CalendarCollectionCreateRequest, + CalendarCollectionDeleteRequest, + CalendarCollectionUpdateRequest, + CalendarEventCreateRequest, + CalendarEventUpdateRequest, +) +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 class CalendarError(ValueError): pass +CALDAV_INTERNAL_CREDENTIAL_PREFIX = "calendar-sync-credential:" +CALDAV_ENV_CREDENTIAL_PREFIX = "env:" +CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900 + + +@dataclass(slots=True) +class CalendarCalDavSyncStats: + created: int = 0 + updated: int = 0 + deleted: int = 0 + unchanged: int = 0 + fetched: int = 0 + full_sync: bool = False + used_sync_token: bool = False + sync_token: str | None = None + ctag: str | None = None + errors: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class CalendarCalDavDueSyncResult: + source_id: str + calendar_id: str + status: str + stats: CalendarCalDavSyncStats | None = None + error: str | None = None + + def slugify(value: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug or "calendar" @@ -50,7 +93,16 @@ def ensure_default_calendar(session: Session, *, tenant_id: str, user_id: str | return calendar -def list_calendars(session: Session, *, tenant_id: str, ensure_default: bool = True, user_id: str | None = None) -> list[CalendarCollection]: +def get_default_calendar(session: Session, *, tenant_id: str) -> CalendarCollection | None: + return ( + session.query(CalendarCollection) + .filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.is_default.is_(True), CalendarCollection.deleted_at.is_(None)) + .order_by(CalendarCollection.created_at.asc()) + .first() + ) + + +def list_calendars(session: Session, *, tenant_id: str, ensure_default: bool = False, user_id: str | None = None) -> list[CalendarCollection]: if ensure_default: ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id) return ( @@ -103,14 +155,30 @@ def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo return calendar -def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str) -> None: +def delete_calendar(session: Session, *, tenant_id: str, calendar_id: str, payload: CalendarCollectionDeleteRequest | None = None) -> None: + payload = payload or CalendarCollectionDeleteRequest() calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=calendar_id) + deleted_at = utcnow() + if payload.event_action == "move": + if not payload.target_calendar_id: + raise CalendarError("Target calendar is required when moving events") + if payload.target_calendar_id == calendar_id: + raise CalendarError("Target calendar must be different from the deleted calendar") + target_calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.target_calendar_id) + for event in calendar.events: + if event.deleted_at is None: + event.calendar_id = target_calendar.id + if payload.make_target_default: + clear_default_calendar(session, tenant_id=tenant_id) + target_calendar.is_default = True + elif payload.event_action != "delete": + raise CalendarError(f"Unsupported calendar delete event action: {payload.event_action}") if calendar.is_default: - raise CalendarError("Default calendar cannot be deleted") - calendar.deleted_at = utcnow() + calendar.is_default = False + calendar.deleted_at = deleted_at for event in calendar.events: - if event.deleted_at is None: - event.deleted_at = calendar.deleted_at + if payload.event_action == "delete" and event.deleted_at is None: + event.deleted_at = deleted_at session.flush() @@ -143,6 +211,588 @@ 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( + CalendarSyncSource.tenant_id == tenant_id, + CalendarSyncSource.source_kind == "caldav", + CalendarSyncSource.deleted_at.is_(None), + ) + if calendar_id: + query = query.filter(CalendarSyncSource.calendar_id == calendar_id) + return query.order_by(CalendarSyncSource.display_name.asc(), CalendarSyncSource.created_at.asc()).all() + + +def get_caldav_source(session: Session, *, tenant_id: str, source_id: str) -> 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 not source: + raise CalendarError("CalDAV sync source not found") + return source + + +def create_caldav_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarCalDavSourceCreateRequest) -> CalendarSyncSource: + calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id) + source = CalendarSyncSource( + tenant_id=tenant_id, + calendar_id=calendar.id, + source_kind="caldav", + collection_url=ensure_collection_url(payload.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, + conflict_policy=payload.conflict_policy, + metadata_=payload.metadata, + ) + session.add(source) + session.flush() + 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=user_id, source=source, secret=credential_value) + source.next_sync_at = utcnow() if source.sync_enabled else None + mark_calendar_caldav(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) + 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 + for field_name in ( + "display_name", + "auth_type", + "username", + "credential_ref", + "sync_enabled", + "sync_interval_seconds", + "sync_direction", + "conflict_policy", + "sync_token", + "ctag", + ): + value = getattr(payload, field_name) + 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) + if payload.metadata is not None: + source.metadata_ = payload.metadata + 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) + 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) + session.flush() + + +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) + if auth_type == "bearer" and bearer_token is not None: + return secret_value(bearer_token) + if auth_type == "none": + return None + return None + + +def secret_value(value: Any) -> str: + if hasattr(value, "get_secret_value"): + return str(value.get_secret_value()) + return str(value) + + +def store_caldav_credential( + session: Session, + *, + tenant_id: str, + user_id: str | None, + source: CalendarSyncSource, + secret: str, +) -> str: + provider = secret_provider() + name = f"caldav:{source.id}:{source.auth_type}" + if provider is not None: + return str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret)) + + existing = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref) + if existing is None: + existing = CalendarSyncCredential( + tenant_id=tenant_id, + credential_kind=credential_kind_for_auth_type(source.auth_type), + label=source.display_name or source.collection_url, + created_by_user_id=user_id, + metadata_={"source_id": source.id}, + ) + session.add(existing) + session.flush() + existing.credential_kind = credential_kind_for_auth_type(source.auth_type) + existing.label = source.display_name or source.collection_url + existing.secret_encrypted = encrypt_secret(secret) + existing.deleted_at = None + existing.metadata_ = {"source_id": source.id} + session.flush() + return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}" + + +def delete_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> None: + if not credential_ref: + return + provider = secret_provider() + if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX) and not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): + provider.delete_secret(credential_ref) + return + credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref) + if credential is not None: + credential.deleted_at = utcnow() + + +def resolve_caldav_secret( + session: Session, + *, + source: CalendarSyncSource, + password: str | None = None, + bearer_token: str | None = None, +) -> str | None: + if source.auth_type == "none": + return None + if source.auth_type == "basic" and password: + return password + if source.auth_type == "bearer" and bearer_token: + return bearer_token + if not source.credential_ref: + return None + if source.credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX): + return os.environ.get(source.credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)) + provider = secret_provider() + if provider is not None and not source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX): + secret = provider.read_secret(source.credential_ref) + if secret: + return secret + credential = internal_caldav_credential(session, tenant_id=source.tenant_id, credential_ref=source.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, + *, + password: str | None = None, + bearer_token: str | None = None, +) -> CalDAVClient: + secret = resolve_caldav_secret(session, source=source, password=password, bearer_token=bearer_token) + if source.auth_type == "basic": + if not source.username: + raise CalendarError("CalDAV basic auth requires a username") + if not secret: + raise CalendarError("CalDAV basic auth requires a stored or transient password") + return CalDAVClient(collection_url=source.collection_url, username=source.username, password=secret) + if source.auth_type == "bearer": + if not secret: + raise CalendarError("CalDAV bearer auth requires a stored or transient token") + return CalDAVClient(collection_url=source.collection_url, bearer_token=secret) + return CalDAVClient(collection_url=source.collection_url) + + +def internal_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> CalendarSyncCredential | None: + if not credential_ref: + return None + credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX) + return ( + session.query(CalendarSyncCredential) + .filter( + CalendarSyncCredential.tenant_id == tenant_id, + CalendarSyncCredential.id == credential_id, + CalendarSyncCredential.deleted_at.is_(None), + ) + .first() + ) + + +def credential_kind_for_auth_type(auth_type: str) -> str: + return "bearer_token" if auth_type == "bearer" else "password" + + +def secret_provider() -> Any | None: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_SECURITY_SECRET_PROVIDER): + return None + try: + return registry.capability(CAPABILITY_SECURITY_SECRET_PROVIDER) + except Exception: + return None + + +def sync_caldav_source( + session: Session, + *, + tenant_id: str, + user_id: str | None, + source_id: str, + client: CalDAVClient | None = None, + password: str | None = None, + bearer_token: str | None = None, + force_full: bool = False, +) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]: + source = get_caldav_source(session, tenant_id=tenant_id, source_id=source_id) + get_calendar(session, tenant_id=tenant_id, calendar_id=source.calendar_id) + client = client or caldav_client_for_source(session, source, password=password, bearer_token=bearer_token) + stats = CalendarCalDavSyncStats() + collection_props = CalDAVReportResult() + source.last_attempt_at = utcnow() + try: + try: + collection_props = client.propfind_collection() + except CalDAVError as exc: + stats.errors.append(f"Collection PROPFIND failed; continuing with REPORT: {exc}") + + if source.sync_token and not force_full: + try: + report = client.sync_collection(source.sync_token) + stats.used_sync_token = True + except CalDAVSyncUnsupported as exc: + stats.errors.append(f"Sync token REPORT failed; falling back to full sync: {exc}") + report = client.list_objects() + stats.full_sync = True + else: + report = client.list_objects() + stats.full_sync = True + + apply_caldav_report(session, source=source, client=client, report=report, stats=stats, user_id=user_id) + source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token + source.ctag = report.ctag or collection_props.ctag or source.ctag + source.last_synced_at = utcnow() + source.last_status = "ok" + source.last_error = None + schedule_next_caldav_sync(source) + stats.sync_token = source.sync_token + stats.ctag = source.ctag + mark_calendar_caldav(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 sync_due_caldav_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 == "caldav", + 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: + 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, + ) + 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 schedule_next_caldav_sync(source: CalendarSyncSource, *, now: datetime | None = None) -> None: + if not source.sync_enabled: + source.next_sync_at = None + return + interval = max(int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS), 60) + source.next_sync_at = normalize_datetime(now or utcnow()) + timedelta(seconds=interval) + + +def apply_caldav_report( + session: Session, + *, + source: CalendarSyncSource, + client: CalDAVClient, + report: CalDAVReportResult, + stats: CalendarCalDavSyncStats, + user_id: str | None, +) -> None: + seen_hrefs: set[str] = set() + for item in report.objects: + href = normalize_caldav_href(source.collection_url, item.href) + if item.deleted: + stats.deleted += soft_delete_caldav_href(session, source=source, href=href) + continue + seen_hrefs.add(href) + calendar_data = item.calendar_data + if calendar_data is None: + calendar_data = client.fetch_object(item.href) + stats.fetched += 1 + created, updated, unchanged, deleted = import_caldav_resource( + session, + source=source, + href=href, + etag=item.etag, + ics=calendar_data, + user_id=user_id, + ) + stats.created += created + stats.updated += updated + stats.unchanged += unchanged + stats.deleted += deleted + + if stats.full_sync: + query = ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.source_kind == "caldav", + CalendarEvent.deleted_at.is_(None), + CalendarEvent.source_href.like(f"{source.collection_url}%"), + ) + .all() + ) + deleted_at = utcnow() + for event in query: + if event.source_href not in seen_hrefs: + event.deleted_at = deleted_at + stats.deleted += 1 + + +def import_caldav_resource( + session: Session, + *, + source: CalendarSyncSource, + href: str, + etag: str | None, + ics: str, + 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 == "caldav", + CalendarEvent.source_href == href, + CalendarEvent.deleted_at.is_(None), + ) + .all() + } + created = updated = unchanged = deleted = 0 + 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) + 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 + else: + if was_deleted: + created += 1 + elif event.etag == etag and event.raw_ics == raw_ics: + unchanged += 1 + else: + updated += 1 + 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(): + event.deleted_at = deleted_at + deleted += 1 + session.flush() + return created, updated, unchanged, deleted + + +def find_event_for_caldav_component(session: Session, *, source: CalendarSyncSource, href: str, 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"), + ) + .order_by((CalendarEvent.source_href == href).desc(), CalendarEvent.deleted_at.is_(None).desc()) + .first() + ) + + +def apply_parsed_event_to_model( + event: CalendarEvent, + parsed: dict[str, Any], + *, + source: CalendarSyncSource, + href: str, + etag: str | None, + raw_ics: str, + user_id: str | None, +) -> None: + 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", + ): + setattr(event, field_name, parsed.get(field_name)) + event.calendar_id = source.calendar_id + event.source_kind = "caldav" + 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} + event.metadata_ = metadata + validate_event_time(event) + + +def soft_delete_caldav_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 == "caldav", + CalendarEvent.source_href == href, + CalendarEvent.deleted_at.is_(None), + ) + .all() + ): + event.deleted_at = deleted_at + count += 1 + return count + + +def normalize_caldav_href(collection_url: str, href: str) -> str: + return urllib.parse.urljoin(ensure_collection_url(collection_url), href) + + +def mark_calendar_caldav(calendar: CalendarCollection, source: CalendarSyncSource) -> None: + metadata = dict(calendar.metadata_ or {}) + metadata.setdefault("source_kind", "caldav") + metadata["connector_kind"] = "caldav" + metadata["sync_source_id"] = source.id + metadata["sync_href"] = source.collection_url + metadata["sync_direction"] = source.sync_direction + calendar.metadata_ = metadata + + +def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]: + return { + "id": source.id, + "tenant_id": source.tenant_id, + "calendar_id": source.calendar_id, + "source_kind": source.source_kind, + "collection_url": source.collection_url, + "display_name": source.display_name, + "auth_type": source.auth_type, + "username": source.username, + "credential_ref": source.credential_ref, + "has_credential": bool(source.credential_ref), + "sync_enabled": bool(source.sync_enabled), + "sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS), + "sync_direction": source.sync_direction, + "conflict_policy": source.conflict_policy, + "sync_token": source.sync_token, + "ctag": source.ctag, + "last_attempt_at": response_datetime(source.last_attempt_at), + "last_synced_at": response_datetime(source.last_synced_at), + "next_sync_at": response_datetime(source.next_sync_at), + "last_status": source.last_status, + "last_error": source.last_error, + "created_at": response_datetime(source.created_at), + "updated_at": response_datetime(source.updated_at), + "metadata": source.metadata_ or {}, + } + + +def caldav_sync_response(source: CalendarSyncSource, stats: CalendarCalDavSyncStats) -> dict[str, Any]: + return { + "source": caldav_source_response(source), + "created": stats.created, + "updated": stats.updated, + "deleted": stats.deleted, + "unchanged": stats.unchanged, + "fetched": stats.fetched, + "full_sync": stats.full_sync, + "used_sync_token": stats.used_sync_token, + "sync_token": stats.sync_token, + "ctag": stats.ctag, + "errors": stats.errors, + } + + def list_events( session: Session, *, @@ -161,6 +811,54 @@ def list_events( return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all() +def list_freebusy( + session: Session, + *, + tenant_id: str, + start_at: datetime, + end_at: datetime, + calendar_ids: list[str] | None = None, +) -> list[dict[str, Any]]: + range_start = normalize_datetime(start_at) + range_end = normalize_datetime(end_at) + if range_end < range_start: + raise CalendarError("Free/busy end must be after start") + query = session.query(CalendarEvent).filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.deleted_at.is_(None), + CalendarEvent.status != "CANCELLED", + CalendarEvent.transparency != "TRANSPARENT", + ) + if calendar_ids: + query = query.filter(CalendarEvent.calendar_id.in_(calendar_ids)) + events = query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all() + busy: list[dict[str, Any]] = [] + for event in events: + if event.rrule or event.rdate: + for occurrence in expand_event_occurrences(event, range_start, range_end): + busy.append(freebusy_block(event, occurrence["start_at"], occurrence["end_at"], occurrence.get("recurrence_id"))) + continue + event_start = normalize_datetime(event.start_at) + event_end = normalize_datetime(event.end_at) if event.end_at else event_start + if event_end >= range_start and event_start <= range_end: + busy.append(freebusy_block(event, event_start, event_end, event.recurrence_id)) + return sorted(busy, key=lambda item: (item["start_at"], item["calendar_id"], item["uid"])) + + +def freebusy_block(event: CalendarEvent, start_at: datetime, end_at: datetime | None, recurrence_id: str | None) -> dict[str, Any]: + return { + "calendar_id": event.calendar_id, + "event_id": event.id, + "uid": event.uid, + "recurrence_id": recurrence_id, + "start_at": response_datetime(start_at), + "end_at": response_datetime(end_at), + "all_day": event.all_day, + "transparency": event.transparency, + "status": event.status, + } + + def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEvent: event = ( session.query(CalendarEvent) @@ -173,8 +871,13 @@ def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEve def create_event(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarEventCreateRequest) -> CalendarEvent: - calendar_id = payload.calendar_id or ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id).id + default_calendar = None if payload.calendar_id else get_default_calendar(session, tenant_id=tenant_id) + calendar_id = payload.calendar_id or (default_calendar.id if default_calendar else None) + 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) event = CalendarEvent( tenant_id=tenant_id, calendar_id=calendar_id, @@ -212,14 +915,29 @@ def create_event(session: Session, *, tenant_id: str, user_id: str | None, paylo validate_event_time(event) session.add(event) session.flush() + if should_push_event_to_caldav(source=source, event=event): + push_caldav_event_resource(session, source=source, event=event, create=True) 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) + original_calendar_id = event.calendar_id + original_source = active_caldav_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) + if payload.calendar_id != event.calendar_id and original_source and original_caldav: + assert_caldav_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) scalar_fields = ( "sequence", "summary", @@ -259,16 +977,159 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event if payload.sequence is None: event.sequence += 1 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) + if should_push_event_to_caldav(source=target_source, event=event): + push_caldav_event_resource( + session, + source=target_source, + event=event, + create=event.source_kind != "caldav" or not event.source_href or original_calendar_id != event.calendar_id, + ) return event def delete_event(session: Session, *, tenant_id: str, event_id: str) -> 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) + 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() +def active_caldav_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.source_kind == "caldav", + CalendarSyncSource.deleted_at.is_(None), + ) + .order_by((CalendarSyncSource.sync_direction == "two_way").desc(), CalendarSyncSource.created_at.asc()) + .first() + ) + + +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") + + +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) + + +def push_caldav_event_resource(session: Session, *, source: CalendarSyncSource, event: CalendarEvent, create: bool) -> None: + href = event.source_href or generated_caldav_href(session, source=source, event=event) + full_href = normalize_caldav_href(source.collection_url, href) + resource_events = caldav_resource_events(session, source=source, href=full_href) if event.source_href else [event] + if event not in resource_events: + resource_events.append(event) + resource_events = sorted(resource_events, key=caldav_resource_sort_key) + ics = events_to_ics(resource_events) + client = caldav_client_for_source(session, source) + overwrite = source.conflict_policy == "overwrite" + etag = None if create else resource_etag(resource_events) + try: + result = client.put_object(full_href, ics, etag=etag, create=create, overwrite=overwrite) + except CalDAVPreconditionFailed as exc: + raise CalendarError("CalDAV resource changed remotely; sync the calendar before saving again") from exc + except CalDAVError as exc: + raise CalendarError(f"CalDAV write failed: {exc}") from exc + next_etag = result.etag + for item in resource_events: + apply_caldav_write_metadata(item, source=source, href=full_href, etag=next_etag, raw_ics=ics) + source.last_status = "outbound_ok" + source.last_error = None + schedule_next_caldav_sync(source) + session.flush() + + +def push_caldav_delete_for_event(session: Session, *, source: CalendarSyncSource, event: CalendarEvent) -> None: + if not event.source_href: + return + full_href = normalize_caldav_href(source.collection_url, event.source_href) + remaining = [ + item + for item in caldav_resource_events(session, source=source, href=full_href) + if item.id != event.id + ] + client = caldav_client_for_source(session, source) + overwrite = source.conflict_policy == "overwrite" + try: + if remaining: + remaining = sorted(remaining, key=caldav_resource_sort_key) + ics = events_to_ics(remaining) + result = client.put_object(full_href, ics, etag=event.etag or resource_etag(remaining), create=False, overwrite=overwrite) + for item in remaining: + apply_caldav_write_metadata(item, source=source, href=full_href, etag=result.etag, raw_ics=ics) + else: + client.delete_object(full_href, etag=event.etag, overwrite=overwrite) + except CalDAVPreconditionFailed as exc: + raise CalendarError("CalDAV resource changed remotely; sync the calendar before deleting again") from exc + except CalDAVError as exc: + raise CalendarError(f"CalDAV delete failed: {exc}") from exc + source.last_status = "outbound_ok" + source.last_error = None + schedule_next_caldav_sync(source) + session.flush() + + +def caldav_resource_events(session: Session, *, source: CalendarSyncSource, href: str) -> list[CalendarEvent]: + return ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.source_kind == "caldav", + CalendarEvent.source_href == href, + CalendarEvent.deleted_at.is_(None), + ) + .all() + ) + + +def caldav_resource_sort_key(event: CalendarEvent) -> tuple[int, str, datetime]: + return (1 if event.recurrence_id else 0, event.recurrence_id or "", event.start_at) + + +def resource_etag(events: list[CalendarEvent]) -> str | None: + return next((event.etag for event in events if event.etag), None) + + +def generated_caldav_href(session: Session, *, source: CalendarSyncSource, event: CalendarEvent) -> str: + safe_uid = urllib.parse.quote(event.uid, safe="-_.~@") + candidate = normalize_caldav_href(source.collection_url, f"{safe_uid}.ics") + existing = ( + session.query(CalendarEvent.id) + .filter( + CalendarEvent.tenant_id == source.tenant_id, + CalendarEvent.calendar_id == source.calendar_id, + CalendarEvent.source_href == candidate, + CalendarEvent.deleted_at.is_(None), + CalendarEvent.id != event.id, + ) + .first() + ) + if existing is None: + return candidate + return normalize_caldav_href(source.collection_url, f"{safe_uid}-{event.id}.ics") + + +def apply_caldav_write_metadata(event: CalendarEvent, *, source: CalendarSyncSource, href: str, etag: str | None, raw_ics: str) -> None: + event.source_kind = "caldav" + event.source_href = href + event.etag = etag + event.raw_ics = raw_ics + metadata = dict(event.metadata_ or {}) + metadata["caldav"] = {"source_id": source.id, "collection_url": source.collection_url, "href": href, "etag": etag} + event.metadata_ = metadata + + def import_ics_event( session: Session, *, @@ -283,7 +1144,10 @@ def import_ics_event( ) -> CalendarEvent: parsed = parse_vevent(ics) raw_ics = str(parsed.pop("raw_ics", "")) - target_calendar_id = calendar_id or ensure_default_calendar(session, tenant_id=tenant_id, user_id=user_id).id + default_calendar = None if calendar_id else get_default_calendar(session, tenant_id=tenant_id) + target_calendar_id = calendar_id or (default_calendar.id if default_calendar else None) + if not target_calendar_id: + raise CalendarError("calendar_id is required because no default calendar exists") existing = None if upsert: existing = ( @@ -321,6 +1185,12 @@ def normalize_datetime(value: datetime) -> datetime: return value.astimezone(timezone.utc) +def response_datetime(value: datetime | None) -> datetime | None: + if value is None: + return None + return normalize_datetime(value) + + def validate_event_time(event: CalendarEvent) -> None: if event.end_at is not None and event.end_at < event.start_at: raise CalendarError("Event end must be after start") @@ -339,8 +1209,8 @@ def calendar_response(calendar: CalendarCollection) -> dict[str, Any]: "owner_id": calendar.owner_id, "visibility": calendar.visibility, "is_default": calendar.is_default, - "created_at": calendar.created_at, - "updated_at": calendar.updated_at, + "created_at": response_datetime(calendar.created_at), + "updated_at": response_datetime(calendar.updated_at), "metadata": calendar.metadata_ or {}, } @@ -359,8 +1229,8 @@ def event_response(event: CalendarEvent) -> dict[str, Any]: "status": event.status, "transparency": event.transparency, "classification": event.classification, - "start_at": event.start_at, - "end_at": event.end_at, + "start_at": response_datetime(event.start_at), + "end_at": response_datetime(event.end_at), "duration_seconds": event.duration_seconds, "all_day": event.all_day, "timezone": event.timezone, @@ -377,7 +1247,7 @@ def event_response(event: CalendarEvent) -> dict[str, Any]: "source_href": event.source_href, "etag": event.etag, "icalendar": event.icalendar or {}, - "created_at": event.created_at, - "updated_at": event.updated_at, + "created_at": response_datetime(event.created_at), + "updated_at": response_datetime(event.updated_at), "metadata": event.metadata_ or {}, } diff --git a/src/govoplan_calendar/backend/tasks.py b/src/govoplan_calendar/backend/tasks.py new file mode 100644 index 0000000..9e21986 --- /dev/null +++ b/src/govoplan_calendar/backend/tasks.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +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_core.db.session import get_database + + with get_database().SessionLocal() as session: + results = sync_due_caldav_sources(session, tenant_id=tenant_id, limit=limit) + session.commit() + return [ + { + "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 + ] diff --git a/tests/test_caldav.py b/tests/test_caldav.py new file mode 100644 index 0000000..2d0cc3c --- /dev/null +++ b/tests/test_caldav.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone +from types import SimpleNamespace +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.caldav import 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 ( + CALDAV_INTERNAL_CREDENTIAL_PREFIX, + CalendarError, + caldav_client_for_source, + create_calendar, + create_caldav_source, + create_event, + delete_event, + list_freebusy, + sync_caldav_source, + sync_due_caldav_sources, + update_event, +) +from govoplan_core.db.base import Base +from govoplan_tenancy.backend.db.models import Tenant + + +class FakeCalDAVClient: + def __init__( + self, + *, + list_result: CalDAVReportResult | None = None, + sync_result: CalDAVReportResult | None = None, + objects: dict[str, 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.put_etags = put_etags or ['"etag-written"'] + self.fail_precondition = fail_precondition + self.fetched: list[str] = [] + self.puts: list[dict[str, object]] = [] + self.deletes: list[dict[str, object]] = [] + + def propfind_collection(self) -> CalDAVReportResult: + return CalDAVReportResult(sync_token="prop-token", ctag="ctag-1") + + def list_objects(self) -> CalDAVReportResult: + return self.list_result + + def sync_collection(self, sync_token: str) -> CalDAVReportResult: + if self.sync_result is None: + raise AssertionError(f"Unexpected sync_collection call with {sync_token}") + return self.sync_result + + def fetch_object(self, href: str) -> str: + self.fetched.append(href) + return self.objects[href] + + def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult: + if self.fail_precondition: + raise CalDAVPreconditionFailed("changed") + self.puts.append({"href": href, "ics": ics, "etag": etag, "create": create, "overwrite": overwrite}) + result_etag = self.put_etags.pop(0) if self.put_etags else None + return CalDAVWriteResult(href=href, etag=result_etag, status=201 if create else 204) + + def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> CalDAVWriteResult: + if self.fail_precondition: + raise CalDAVPreconditionFailed("changed") + self.deletes.append({"href": href, "etag": etag, "overwrite": overwrite}) + return CalDAVWriteResult(href=href, status=204) + + +class CalDAVParsingTests(unittest.TestCase): + def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None: + result = parse_multistatus( + b""" + + token-2 + + /cal/event-1.ics + + + "etag-1" + BEGIN:VCALENDAR +BEGIN:VEVENT +UID:event-1@example.test +DTSTART:20260708T090000Z +SUMMARY:One +END:VEVENT +END:VCALENDAR + + HTTP/1.1 200 OK + + + + /cal/event-2.ics + + + HTTP/1.1 404 Not Found + + + +""" + ) + + self.assertEqual(result.sync_token, "token-2") + self.assertEqual(result.objects[0].href, "/cal/event-1.ics") + self.assertEqual(result.objects[0].etag, '"etag-1"') + self.assertIn("BEGIN:VEVENT", result.objects[0].calendar_data or "") + self.assertTrue(result.objects[1].deleted) + + +class CalDAVSyncTests(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 test_source_creation_encrypts_credential_and_resolves_client_secret(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() + + credential = session.query(CalendarSyncCredential).one() + client = caldav_client_for_source(session, source) + + self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)) + self.assertNotEqual(credential.secret_encrypted, "secret") + self.assertEqual(client.username, "ada") + self.assertEqual(client.password, "secret") + + 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")) + 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.next_sync_at = datetime(2026, 7, 7, 8, 0, tzinfo=timezone.utc) + session.commit() + + fake = FakeCalDAVClient(list_result=CalDAVReportResult(sync_token="token-1")) + results = sync_due_caldav_sources( + session, + tenant_id="tenant-1", + now=datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc), + client_factory=lambda _source: fake, + ) + session.commit() + + self.assertEqual(results[0].status, "ok") + self.assertEqual(source.sync_token, "token-1") + next_sync_at = source.next_sync_at if source.next_sync_at.tzinfo else source.next_sync_at.replace(tzinfo=timezone.utc) + self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc)) + + def test_create_and_update_event_puts_caldav_resource_with_etag(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")) + create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal")) + session.commit() + + fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"']) + with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake): + event = create_event( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarEventCreateRequest( + calendar_id=calendar.id, + uid="event-1@example.test", + summary="Planning", + start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc), + ), + ) + update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated")) + session.commit() + + self.assertEqual(len(fake.puts), 2) + self.assertTrue(fake.puts[0]["create"]) + self.assertEqual(fake.puts[1]["etag"], '"etag-1"') + self.assertFalse(fake.puts[1]["create"]) + self.assertEqual(event.source_kind, "caldav") + self.assertEqual(event.etag, '"etag-2"') + self.assertIn("SUMMARY:Updated", event.raw_ics or "") + + def test_update_event_reports_remote_etag_conflict(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")) + create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal")) + event = caldav_event(calendar.id, summary="Remote", href="https://dav.example.test/cal/event-1.ics", etag='"etag-1"') + session.add(event) + session.commit() + + fake = FakeCalDAVClient(fail_precondition=True) + with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake): + with self.assertRaisesRegex(CalendarError, "changed remotely"): + update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated")) + + def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(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")) + create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal")) + href = "https://dav.example.test/cal/series.ics" + master = caldav_event(calendar.id, summary="Master", href=href, etag='"etag-1"') + override = caldav_event(calendar.id, summary="Override", href=href, etag='"etag-1"', recurrence_id="20260715T090000Z") + session.add_all([master, override]) + session.commit() + + fake = FakeCalDAVClient(put_etags=['"etag-2"']) + with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake): + delete_event(session, tenant_id="tenant-1", event_id=override.id) + session.commit() + + self.assertEqual(len(fake.puts), 1) + self.assertEqual(fake.deletes, []) + self.assertIn("SUMMARY:Master", fake.puts[0]["ics"]) + self.assertNotIn("SUMMARY:Override", fake.puts[0]["ics"]) + self.assertEqual(master.etag, '"etag-2"') + self.assertIsNotNone(override.deleted_at) + + def test_freebusy_expands_recurring_events_and_skips_transparent_items(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="Local")) + create_event( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarEventCreateRequest( + calendar_id=calendar.id, + uid="busy@example.test", + summary="Busy", + start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc), + rrule={"FREQ": "WEEKLY", "COUNT": "2"}, + ), + ) + create_event( + session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarEventCreateRequest( + calendar_id=calendar.id, + uid="free@example.test", + summary="Free", + transparency="TRANSPARENT", + start_at=datetime(2026, 7, 8, 11, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 8, 12, 0, tzinfo=timezone.utc), + ), + ) + session.commit() + + busy = list_freebusy( + session, + tenant_id="tenant-1", + start_at=datetime(2026, 7, 1, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 31, tzinfo=timezone.utc), + ) + + self.assertEqual([item["uid"] for item in busy], ["busy@example.test", "busy@example.test"]) + self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc)) + + def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(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", metadata={}), + ) + 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"), + ) + session.commit() + + href = "/cal/series.ics" + client = FakeCalDAVClient( + list_result=CalDAVReportResult( + objects=[CalDAVObject(href=href, etag='"etag-1"', calendar_data=None)], + sync_token="token-1", + ctag="ctag-2", + ), + objects={href: recurring_resource()}, + ) + source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client) + session.commit() + + events = session.query(CalendarEvent).filter(CalendarEvent.calendar_id == calendar.id, CalendarEvent.deleted_at.is_(None)).order_by(CalendarEvent.recurrence_id.asc()).all() + self.assertEqual(stats.created, 2) + self.assertEqual(stats.fetched, 1) + self.assertTrue(stats.full_sync) + self.assertEqual(source.sync_token, "token-1") + self.assertEqual(source.ctag, "ctag-2") + self.assertEqual(client.fetched, [href]) + self.assertEqual({event.summary for event in events}, {"Series", "Moved instance"}) + self.assertEqual({event.source_href for event in events}, {"https://dav.example.test/cal/series.ics"}) + self.assertEqual(calendar.metadata_["source_kind"], "caldav") + + def test_sync_token_deletion_soft_deletes_remote_resource_events(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" + session.add( + 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.commit() + + client = FakeCalDAVClient(sync_result=CalDAVReportResult(objects=[CalDAVObject(href="/cal/event-1.ics", deleted=True)], sync_token="token-2")) + source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client) + session.commit() + + event = session.query(CalendarEvent).filter(CalendarEvent.uid == "event-1@example.test").one() + self.assertTrue(stats.used_sync_token) + self.assertEqual(stats.deleted, 1) + self.assertEqual(source.sync_token, "token-2") + self.assertIsNotNone(event.deleted_at) + + +def recurring_resource() -> str: + return """BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:series@example.test +DTSTART;TZID=Europe/Berlin:20260708T090000 +DTEND;TZID=Europe/Berlin:20260708T100000 +RRULE:FREQ=WEEKLY;COUNT=2 +SUMMARY:Series +END:VEVENT +BEGIN:VEVENT +UID:series@example.test +RECURRENCE-ID;TZID=Europe/Berlin:20260715T090000 +DTSTART;TZID=Europe/Berlin:20260715T110000 +DTEND;TZID=Europe/Berlin:20260715T120000 +SUMMARY:Moved instance +END:VEVENT +END:VCALENDAR +""" + + +def caldav_event(calendar_id: str, *, summary: str, href: str, etag: str, recurrence_id: str | None = None) -> CalendarEvent: + return CalendarEvent( + tenant_id="tenant-1", + calendar_id=calendar_id, + uid="series@example.test" if recurrence_id else "event-1@example.test", + recurrence_id=recurrence_id, + summary=summary, + status="CONFIRMED", + transparency="OPAQUE", + classification="PUBLIC", + start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc), + all_day=False, + attendees=[], + categories=[], + rdate=[], + exdate=[], + reminders=[], + attachments=[], + related_to=[], + source_kind="caldav", + source_href=href, + etag=etag, + icalendar={}, + metadata_={}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ical.py b/tests/test_ical.py index 7ca61c1..f72e148 100644 --- a/tests/test_ical.py +++ b/tests/test_ical.py @@ -1,8 +1,10 @@ from __future__ import annotations import unittest +from datetime import datetime, timezone +from types import SimpleNamespace -from govoplan_calendar.backend.ical import event_to_ics, parse_vevent +from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent class ICalendarParsingTests(unittest.TestCase): @@ -48,6 +50,154 @@ END:VCALENDAR self.assertTrue(event["all_day"]) self.assertEqual(event["start_at"].hour, 0) + def test_parse_advanced_vevent_fields_and_valarm(self) -> None: + event = parse_vevent( + """BEGIN:VCALENDAR +VERSION:2.0 +METHOD:REQUEST +BEGIN:VEVENT +UID:event-advanced@example.test +DTSTAMP:20260707T080000Z +CREATED:20260707T070000Z +LAST-MODIFIED:20260707T073000Z +DTSTART;TZID=Europe/Berlin:20260708T090000 +DURATION:PT1H30M +SUMMARY:Planning +DESCRIPTION:Line one\\nLine two +LOCATION:Room\\; 1 +PRIORITY:5 +URL:https://example.test/events/event-advanced +GEO:52.5200;13.4050 +COMMENT:First comment +RESOURCES:Projector,Room +REQUEST-STATUS:2.0;Success +ORGANIZER;CN=Orga:mailto:orga@example.test +ATTENDEE;CN=Ada;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:ada@example.test +RRULE:FREQ=WEEKLY;COUNT=2 +EXDATE;TZID=Europe/Berlin:20260715T090000 +RDATE;TZID=Europe/Berlin:20260722T090000 +ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf +RELATED-TO;RELTYPE=PARENT:parent@example.test +X-GOVOPLAN-CUSTOM:kept +BEGIN:VALARM +ACTION:DISPLAY +DESCRIPTION:Reminder +TRIGGER:-PT15M +END:VALARM +END:VEVENT +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.assertEqual(event["description"], "Line one\nLine two") + self.assertEqual(event["location"], "Room; 1") + self.assertEqual(event["timezone"], "Europe/Berlin") + self.assertEqual(event["organizer"]["params"]["CN"], ["Orga"]) + self.assertEqual(event["attendees"][0]["params"]["PARTSTAT"], ["ACCEPTED"]) + self.assertEqual(event["icalendar"]["method"], "REQUEST") + self.assertEqual(event["icalendar"]["standard"]["priority"]["value"], "5") + self.assertEqual(event["icalendar"]["standard"]["url"]["value"], "https://example.test/events/event-advanced") + self.assertEqual(event["reminders"][0]["trigger"]["value"], "-PT15M") + self.assertEqual(event["reminders"][0]["trigger"]["decoded"], -900) + property_names = [item["name"] for item in event["icalendar"]["properties"]] + self.assertIn("REQUEST-STATUS", property_names) + self.assertIn("X-GOVOPLAN-CUSTOM", property_names) + + def test_generated_ics_preserves_unmapped_properties_and_alarms(self) -> None: + event = SimpleNamespace( + uid="event-export@example.test", + start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc), + duration_seconds=None, + all_day=False, + timezone="Europe/Berlin", + summary="Generated", + sequence=4, + status="CONFIRMED", + transparency="OPAQUE", + classification="PUBLIC", + description=None, + location=None, + categories=["GovOPlaN"], + rrule={"FREQ": "WEEKLY", "COUNT": "2"}, + organizer={"value": "mailto:orga@example.test", "params": {"CN": ["Orga"]}}, + attendees=[{"value": "mailto:ada@example.test", "params": {"CN": ["Ada"], "ROLE": ["REQ-PARTICIPANT"]}}], + rdate=[], + exdate=[], + reminders=[], + attachments=[{"name": "ATTACH", "value": "https://example.test/a.pdf", "params": {"FMTTYPE": ["application/pdf"]}}], + related_to=[{"name": "RELATED-TO", "value": "parent@example.test", "params": {"RELTYPE": ["PARENT"]}}], + icalendar={ + "method": "PUBLISH", + "properties": [ + {"name": "URL", "value": "https://example.test/events/event-export", "params": {}}, + {"name": "X-GOVOPLAN-CUSTOM", "value": "kept", "params": {}}, + {"name": "SUMMARY", "value": "Stale summary", "params": {}}, + ], + "alarms": [ + { + "component": "VALARM", + "properties": [ + {"name": "ACTION", "value": "DISPLAY", "params": {}}, + {"name": "DESCRIPTION", "value": "Reminder", "params": {}}, + {"name": "TRIGGER", "value": "-PT15M", "params": {}}, + ], + } + ], + }, + ) + + ics = event_to_ics(event) + + self.assertIn("METHOD:PUBLISH", ics) + self.assertIn("DTSTART;TZID=Europe/Berlin:20260708T090000", ics) + self.assertIn("SUMMARY:Generated", ics) + self.assertNotIn("SUMMARY:Stale summary", ics) + self.assertIn("RRULE:FREQ=WEEKLY;COUNT=2", ics) + self.assertIn("ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf", ics) + self.assertIn("RELATED-TO;RELTYPE=PARENT:parent@example.test", ics) + self.assertIn("URL:https://example.test/events/event-export", ics) + self.assertIn("X-GOVOPLAN-CUSTOM:kept", ics) + self.assertIn("BEGIN:VALARM", ics) + self.assertIn("TRIGGER:-PT15M", ics) + + def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None: + parsed = parse_vevent( + """BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event-recur@example.test +DTSTART;TZID=Europe/Berlin:20260708T090000 +DTEND;TZID=Europe/Berlin:20260708T100000 +SUMMARY:Recurring +RRULE:FREQ=WEEKLY;COUNT=3 +EXDATE;TZID=Europe/Berlin:20260715T090000 +RDATE;TZID=Europe/Berlin:20260729T090000 +END:VEVENT +END:VCALENDAR +""" + ) + event = SimpleNamespace(**parsed) + + occurrences = expand_event_occurrences( + event, + datetime(2026, 7, 1, tzinfo=timezone.utc), + datetime(2026, 7, 31, 23, 59, tzinfo=timezone.utc), + ) + + self.assertEqual( + [item["start_at"] for item in occurrences], + [ + datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc), + datetime(2026, 7, 22, 7, 0, tzinfo=timezone.utc), + datetime(2026, 7, 29, 7, 0, tzinfo=timezone.utc), + ], + ) + self.assertEqual(occurrences[0]["end_at"], datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc)) + def test_generated_ics_contains_required_vevent_fields(self) -> None: class Event: uid = "event-3@example.test" diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..79684a3 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import patch + +from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse +from govoplan_calendar.backend.service import delete_calendar, event_response + + +class FakeSession: + def __init__(self) -> None: + self.flushed = False + + def flush(self) -> None: + self.flushed = True + + +class CalendarServiceResponseTests(unittest.TestCase): + def test_event_response_marks_naive_database_datetimes_as_utc(self) -> None: + event = SimpleNamespace( + id="event-1", + tenant_id="tenant-1", + calendar_id="calendar-1", + uid="event-1@example.test", + recurrence_id=None, + sequence=0, + summary="Planning", + description=None, + location=None, + status="CONFIRMED", + transparency="OPAQUE", + classification="PUBLIC", + start_at=datetime(2026, 7, 7, 18, 0), + end_at=datetime(2026, 7, 7, 19, 0), + duration_seconds=None, + all_day=False, + timezone=None, + organizer=None, + attendees=[], + categories=[], + rrule=None, + rdate=[], + exdate=[], + reminders=[], + attachments=[], + related_to=[], + source_kind="local", + source_href=None, + etag=None, + icalendar={}, + created_at=datetime(2026, 7, 7, 17, 55), + updated_at=datetime(2026, 7, 7, 17, 55), + metadata_={}, + ) + + payload = event_response(event) + response = CalendarEventResponse.model_validate(payload) + + self.assertEqual(response.start_at.utcoffset(), timedelta(0)) + self.assertEqual(response.end_at.utcoffset(), timedelta(0)) + self.assertEqual(response.created_at.utcoffset(), timedelta(0)) + self.assertEqual(response.updated_at.utcoffset(), timedelta(0)) + self.assertEqual(response.start_at.tzinfo, timezone.utc) + + +class CalendarServiceDeleteTests(unittest.TestCase): + def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None: + session = FakeSession() + event = SimpleNamespace(calendar_id="calendar-1", deleted_at=None) + calendar = SimpleNamespace(id="calendar-1", is_default=True, deleted_at=None, events=[event]) + + with patch("govoplan_calendar.backend.service.get_calendar", return_value=calendar): + delete_calendar( + session, + tenant_id="tenant-1", + calendar_id="calendar-1", + payload=CalendarCollectionDeleteRequest(event_action="delete"), + ) + + self.assertTrue(session.flushed) + self.assertFalse(calendar.is_default) + self.assertIsNotNone(calendar.deleted_at) + self.assertEqual(event.deleted_at, calendar.deleted_at) + self.assertEqual(event.calendar_id, "calendar-1") + + def test_delete_calendar_can_move_events_to_another_calendar_and_make_it_default(self) -> None: + session = FakeSession() + event = SimpleNamespace(calendar_id="calendar-1", deleted_at=None) + calendar = SimpleNamespace(id="calendar-1", is_default=True, deleted_at=None, events=[event]) + target = SimpleNamespace(id="calendar-2", is_default=False, deleted_at=None, events=[]) + + with ( + patch("govoplan_calendar.backend.service.get_calendar", side_effect=[calendar, target]), + patch("govoplan_calendar.backend.service.clear_default_calendar") as clear_default_calendar, + ): + delete_calendar( + session, + tenant_id="tenant-1", + calendar_id="calendar-1", + payload=CalendarCollectionDeleteRequest(event_action="move", target_calendar_id="calendar-2", make_target_default=True), + ) + + self.assertTrue(session.flushed) + clear_default_calendar.assert_called_once_with(session, tenant_id="tenant-1") + self.assertFalse(calendar.is_default) + self.assertTrue(target.is_default) + self.assertIsNotNone(calendar.deleted_at) + self.assertIsNone(event.deleted_at) + self.assertEqual(event.calendar_id, "calendar-2") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 3586b05..a7ff364 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/calendar-webui", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "main": "src/index.ts", @@ -14,7 +14,7 @@ "./styles/calendar.css": "./src/styles/calendar.css" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.4", + "@govoplan/core-webui": "^0.1.5", "lucide-react": "^0.555.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/webui/src/api/calendar.ts b/webui/src/api/calendar.ts index 7c5079a..74b056a 100644 --- a/webui/src/api/calendar.ts +++ b/webui/src/api/calendar.ts @@ -56,6 +56,27 @@ export type CalendarEvent = { export type CalendarCollectionListResponse = { calendars: CalendarCollection[] }; export type CalendarEventListResponse = { events: CalendarEvent[] }; +export type CalendarCollectionCreatePayload = { + name: string; + slug?: string | null; + description?: string | null; + timezone?: string; + color?: string | null; + owner_type?: "tenant" | "user" | "group" | "resource"; + owner_id?: string | null; + visibility?: "private" | "tenant" | "shared" | "public"; + is_default?: boolean; + metadata?: Record; +}; + +export type CalendarCollectionUpdatePayload = Partial; +export type CalendarDeleteEventAction = "delete" | "move"; +export type CalendarCollectionDeletePayload = { + event_action?: CalendarDeleteEventAction; + target_calendar_id?: string | null; + make_target_default?: boolean; +}; + export type CalendarEventCreatePayload = { calendar_id?: string | null; summary: string; @@ -71,10 +92,24 @@ export type CalendarEventCreatePayload = { categories?: string[]; }; +export type CalendarEventUpdatePayload = Partial; + export function listCalendars(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/calendar/calendars"); } +export function createCalendar(settings: ApiSettings, payload: CalendarCollectionCreatePayload): Promise { + return apiFetch(settings, "/api/v1/calendar/calendars", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateCalendar(settings: ApiSettings, calendarId: string, payload: CalendarCollectionUpdatePayload): Promise { + return apiFetch(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteCalendar(settings: ApiSettings, calendarId: string, payload: CalendarCollectionDeletePayload = { event_action: "delete" }): Promise { + return apiFetch(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "DELETE", body: JSON.stringify(payload) }); +} + export function listCalendarEvents( settings: ApiSettings, params: { calendar_id?: string; start_at?: string; end_at?: string } = {} @@ -90,3 +125,11 @@ export function listCalendarEvents( export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise { return apiFetch(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) }); } + +export function updateCalendarEvent(settings: ApiSettings, eventId: string, payload: CalendarEventUpdatePayload): Promise { + return apiFetch(settings, `/api/v1/calendar/events/${eventId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise { + return apiFetch(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" }); +} diff --git a/webui/src/features/calendar/CalendarPage.tsx b/webui/src/features/calendar/CalendarPage.tsx index cd46372..da9eae3 100644 --- a/webui/src/features/calendar/CalendarPage.tsx +++ b/webui/src/features/calendar/CalendarPage.tsx @@ -1,37 +1,132 @@ -import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; -import { CalendarDays, ChevronLeft, ChevronRight, Plus, RefreshCw, X } from "lucide-react"; -import { DismissibleAlert, LoadingIndicator, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; -import { createCalendarEvent, listCalendarEvents, listCalendars, type CalendarCollection, type CalendarEvent } from "../../api/calendar"; +import { + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type DragEvent as ReactDragEvent, + type FormEvent, + type WheelEvent as ReactWheelEvent +} from "react"; +import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { + Button, + Dialog, + DismissibleAlert, + LoadingIndicator, + ToggleSwitch, + hasScope, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + createCalendar, + createCalendarEvent, + deleteCalendar, + deleteCalendarEvent, + listCalendarEvents, + listCalendars, + updateCalendar, + updateCalendarEvent, + type CalendarCollection, + type CalendarCollectionDeletePayload, + type CalendarDeleteEventAction, + type CalendarEvent, + type CalendarEventCreatePayload +} from "../../api/calendar"; -type CalendarMode = "month" | "week" | "workweek" | "day" | "continuous"; +type CalendarMode = "continuous" | "month" | "week" | "workweek" | "day"; type Range = { start: Date; end: Date }; +type EventDialogState = { kind: "create" } | { kind: "edit"; event: CalendarEvent }; +type CalendarEditDialogState = { + 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 CalendarDropTarget = CalendarDayDropTarget | CalendarTimeDropTarget | null; +type CalendarDragAction = + | { kind: "move"; event: CalendarEvent } + | { kind: "resize-start"; event: CalendarEvent } + | { kind: "resize-end"; event: CalendarEvent }; +type CalendarResizeEdge = "start" | "end"; +type CalendarViewPreferences = { + dimWeekends: boolean; + dimOffHours: boolean; + workdayStartHour: number; + workdayEndHour: number; + continuousVirtualization: boolean; + continuousOverscanWeeks: number; + alternateContinuousMonths: boolean; +}; + +const HOUR_ROW_HEIGHT = 64; +const INITIAL_SCROLL_HOUR = 7; +const MAX_TIMED_EVENT_COLUMNS = 3; +const CONTINUOUS_WEEK_ROW_HEIGHT = 92; +const DEFAULT_CALENDAR_COLOR = "#5aa99b"; +const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode"; +const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY = "govoplan.calendar.viewPreferences"; +const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = { + dimWeekends: true, + dimOffHours: true, + workdayStartHour: 6, + workdayEndHour: 20, + continuousVirtualization: true, + continuousOverscanWeeks: 6, + 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" }, - { id: "continuous", label: "Continuous" } + { id: "day", label: "Day" } ]; export default function CalendarPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { const [calendars, setCalendars] = useState([]); - const [selectedCalendarId, setSelectedCalendarId] = 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 [mode, setMode] = useState("month"); + const [mode, setMode] = useState(() => loadCalendarMode()); const [focusDate, setFocusDate] = useState(() => startOfDay(new Date())); const [continuousWeeks, setContinuousWeeks] = useState({ before: 8, after: 12 }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); - const [createOpen, setCreateOpen] = useState(false); + const [eventDialog, setEventDialog] = useState(null); + const [calendarEditDialog, setCalendarEditDialog] = useState(null); + const [continuousViewport, setContinuousViewport] = useState({ scrollTop: 0, height: 0 }); + const [draggingEventId, setDraggingEventId] = useState(""); + const [hoveredEventId, setHoveredEventId] = useState(""); + const [dropTarget, setDropTarget] = useState(null); const scrollRef = useRef(null); + const continuousPrependPendingRef = useRef(false); + const continuousAppendPendingRef = useRef(false); + const pendingContinuousScrollDayRef = useRef(null); + const dragActionRef = useRef(null); - const selectedCalendar = calendars.find((calendar) => calendar.id === selectedCalendarId) ?? calendars[0] ?? null; + 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 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]); const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]); - const eventsByDay = useMemo(() => groupEventsByDay(events), [events]); + const eventsByDay = useMemo(() => groupEventsByDay(visibleEvents), [visibleEvents]); + const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]); const canWrite = hasScope(auth, "calendar:event:write"); + const canDelete = canWrite || hasScope(auth, "calendar:event:delete"); + const canManageCalendars = hasScope(auth, "calendar:calendar:write"); + const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin"); const heading = headingForMode(mode, focusDate, visibleRange); useEffect(() => { @@ -39,8 +134,39 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => { - if (calendars.length) void loadEvents(); - }, [calendars.length, selectedCalendarId, visibleRange.start.getTime(), visibleRange.end.getTime()]); + saveCalendarMode(mode); + }, [mode]); + + useEffect(() => { + if (calendars.length) { + void loadEvents(); + } else { + setEvents([]); + } + }, [calendars.length, visibleRange.start.getTime(), visibleRange.end.getTime()]); + + useEffect(() => { + if (mode !== "continuous") return undefined; + const frame = window.requestAnimationFrame(() => { + const node = scrollRef.current; + if (!node || node.scrollTop > 0) return; + const firstWeek = node.querySelector(".calendar-week-row"); + const rowHeight = firstWeek?.offsetHeight || 92; + node.scrollTop = Math.max(0, continuousWeeks.before * rowHeight - node.clientHeight / 3); + updateContinuousViewport(node); + }); + return () => window.cancelAnimationFrame(frame); + }, [focusDate, mode]); + + useEffect(() => { + if (mode !== "continuous" || !pendingContinuousScrollDayRef.current) return; + const targetDay = pendingContinuousScrollDayRef.current; + pendingContinuousScrollDayRef.current = null; + const frame = window.requestAnimationFrame(() => { + scrollContinuousDayIntoView(targetDay, "smooth"); + }); + return () => window.cancelAnimationFrame(frame); + }, [days, mode]); async function loadCalendars() { setLoading(true); @@ -48,7 +174,12 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings try { const response = await listCalendars(settings); setCalendars(response.calendars); - setSelectedCalendarId((current) => current && response.calendars.some((calendar) => calendar.id === current) ? current : response.calendars[0]?.id ?? ""); + const ids = response.calendars.map((calendar) => calendar.id); + setVisibleCalendarIds((current) => { + const next = current.filter((id) => ids.includes(id)); + return next.length > 0 ? next : ids; + }); + setEventCalendarId((current) => current && ids.includes(current) ? current : ids[0] ?? ""); } catch (err) { setError(errorText(err)); } finally { @@ -61,7 +192,6 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings setError(""); try { const response = await listCalendarEvents(settings, { - calendar_id: selectedCalendarId || undefined, start_at: visibleRange.start.toISOString(), end_at: visibleRange.end.toISOString() }); @@ -74,32 +204,314 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings } } + function toggleCalendarVisibility(calendarId: string) { + setVisibleCalendarIds((current) => { + const next = current.includes(calendarId) ? current.filter((id) => id !== calendarId) : [...current, calendarId]; + if (next.length > 0 && !next.includes(eventCalendarId)) setEventCalendarId(next[0]); + return next; + }); + setEventCalendarId((current) => current || calendarId); + } + + function openCalendarEditor(calendar: CalendarCollection) { + setCalendarEditDialog({ + calendar, + eventCount: null, + loadingEventCount: true, + confirmingDelete: false + }); + 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 }) { + const name = payload.name.trim(); + if (!name || !canManageCalendars) return; + 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); + } catch (err) { + setError(errorText(err)); + } finally { + setSaving(false); + } + } + + 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); + setCalendars((current) => current.filter((item) => item.id !== calendar.id)); + setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id)); + setEventCalendarId((current) => current === calendar.id ? "" : current); + await loadCalendars(); + if (calendars.length > 1 || payload.event_action === "move") { + await loadEvents(); + } else { + setEvents([]); + } + } catch (err) { + setError(errorText(err)); + } + finally { + setSaving(false); + } + } + + async function handleCreateCalendar(formEvent: FormEvent) { + formEvent.preventDefault(); + const name = newCalendarName.trim(); + if (!name || !canManageCalendars) return; + setSaving(true); + 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); + } catch (err) { + setError(errorText(err)); + } finally { + setSaving(false); + } + } + function moveFocus(direction: -1 | 1) { - const daysToMove = mode === "month" ? 31 : mode === "day" ? 1 : 7; - setFocusDate((current) => mode === "month" ? addMonths(current, direction) : addDays(current, direction * daysToMove)); + if (mode === "continuous") return; + if (mode === "month") { + setFocusDate((current) => addMonths(current, direction)); + return; + } + const daysToMove = mode === "day" ? 1 : 7; + setFocusDate((current) => addDays(current, direction * daysToMove)); + } + + function handleToday() { + const today = startOfDay(new Date()); + if (mode !== "continuous") { + setFocusDate(today); + return; + } + pendingContinuousScrollDayRef.current = today; + setFocusDate(today); + window.requestAnimationFrame(() => { + if (pendingContinuousScrollDayRef.current) { + pendingContinuousScrollDayRef.current = null; + scrollContinuousDayIntoView(today, "smooth"); + } + }); } function handleContinuousScroll() { const node = scrollRef.current; if (!node || mode !== "continuous") return; - if (node.scrollTop < 160) { - const oldHeight = node.scrollHeight; - setContinuousWeeks((current) => ({ ...current, before: current.before + 4 })); - window.requestAnimationFrame(() => { - if (scrollRef.current) scrollRef.current.scrollTop += scrollRef.current.scrollHeight - oldHeight; - }); - } - if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) { - setContinuousWeeks((current) => ({ ...current, after: current.after + 4 })); - } + updateContinuousViewport(node); + if (node.scrollTop < 160) prependContinuousWeeks(); + if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) appendContinuousWeeks(); } - async function handleCreate(payload: QuickCreatePayload) { + function handleContinuousWheel(event: ReactWheelEvent) { + const node = scrollRef.current; + if (!node || mode !== "continuous") return; + if (event.deltaY < 0 && node.scrollTop <= 0) prependContinuousWeeks(); + } + + function prependContinuousWeeks() { + const node = scrollRef.current; + if (!node || continuousPrependPendingRef.current) return; + continuousPrependPendingRef.current = true; + const oldHeight = node.scrollHeight; + setContinuousWeeks((current) => ({ ...current, before: current.before + 4 })); + window.requestAnimationFrame(() => { + const nextNode = scrollRef.current; + if (nextNode) nextNode.scrollTop += nextNode.scrollHeight - oldHeight; + continuousPrependPendingRef.current = false; + }); + } + + 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 + )); + } + + function scrollContinuousDayIntoView(day: Date, behavior: ScrollBehavior = "auto") { + const node = scrollRef.current; + if (!node || days.length === 0) return; + const firstWeek = startOfWeek(days[0]); + const targetWeek = startOfWeek(day); + const weekIndex = Math.max(0, Math.floor(daysBetweenCount(firstWeek, targetWeek) / 7)); + node.scrollTo({ top: weekIndex * CONTINUOUS_WEEK_ROW_HEIGHT, behavior }); + window.requestAnimationFrame(() => updateContinuousViewport(node)); + } + + function beginEventDrag(dragEvent: ReactDragEvent, action: CalendarDragAction) { + if (!canWrite) { + dragEvent.preventDefault(); + return; + } + dragActionRef.current = action; + setDraggingEventId(action.event.id); + dragEvent.dataTransfer.effectAllowed = "move"; + dragEvent.dataTransfer.setData("text/plain", action.event.id); + } + + function handleEventDragStart(dragEvent: ReactDragEvent, calendarEvent: CalendarEvent) { + beginEventDrag(dragEvent, { kind: "move", event: calendarEvent }); + } + + function handleEventResizeDragStart(dragEvent: ReactDragEvent, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) { + dragEvent.stopPropagation(); + if (calendarEvent.all_day) { + dragEvent.preventDefault(); + return; + } + beginEventDrag(dragEvent, { kind: edge === "start" ? "resize-start" : "resize-end", event: calendarEvent }); + } + + function handleEventDragEnd() { + dragActionRef.current = null; + setDraggingEventId(""); + setDropTarget(null); + } + + function allowEventDrop(dragEvent: ReactDragEvent, canDrop: (action: CalendarDragAction) => boolean): boolean { + const action = dragActionRef.current; + if (!canWrite || !action || !canDrop(action)) return false; + dragEvent.preventDefault(); + dragEvent.dataTransfer.dropEffect = "move"; + return true; + } + + 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 })); + } + + 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 } + )); + } + + function handleDropTargetLeave(dragEvent: ReactDragEvent) { + const related = dragEvent.relatedTarget; + if (related instanceof Node && dragEvent.currentTarget.contains(related)) return; + setDropTarget(null); + } + + function handleEventDropOnDay(dropEvent: ReactDragEvent, day: Date) { + dropEvent.preventDefault(); + const action = dragActionRef.current; + if (!action || action.kind !== "move" || !canWrite) return; + setDropTarget(null); + void moveEvent(action.event, moveEventToDay(action.event, day)); + } + + function handleEventDropOnTime(dropEvent: ReactDragEvent, day: Date, minuteOfDay: number) { + dropEvent.preventDefault(); + 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); + void moveEvent(action.event, next); + } + + async function moveEvent(calendarEvent: CalendarEvent, next: { startAt: Date; endAt: Date | null; allDay: boolean }) { setSaving(true); setError(""); try { - await createCalendarEvent(settings, payload); - setCreateOpen(false); + await updateCalendarEvent(settings, calendarEvent.id, { + start_at: next.startAt.toISOString(), + end_at: next.endAt ? next.endAt.toISOString() : null, + all_day: next.allDay + }); + await loadEvents(); + } catch (err) { + setError(errorText(err)); + } finally { + handleEventDragEnd(); + setSaving(false); + } + } + + function appendContinuousWeeks() { + if (continuousAppendPendingRef.current) return; + continuousAppendPendingRef.current = true; + setContinuousWeeks((current) => ({ ...current, after: current.after + 4 })); + window.requestAnimationFrame(() => { + continuousAppendPendingRef.current = false; + }); + } + + async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null) { + setSaving(true); + setError(""); + try { + if (event) { + await updateCalendarEvent(settings, event.id, payload); + } else { + await createCalendarEvent(settings, payload); + } + if (payload.calendar_id) { + setVisibleCalendarIds((current) => current.includes(payload.calendar_id as string) ? current : [...current, payload.calendar_id as string]); + setEventCalendarId(payload.calendar_id); + } + setEventDialog(null); + await loadEvents(); + } catch (err) { + setError(errorText(err)); + } finally { + setSaving(false); + } + } + + async function handleDelete(event: CalendarEvent) { + setSaving(true); + setError(""); + try { + await deleteCalendarEvent(settings, event.id); + setEventDialog(null); await loadEvents(); } catch (err) { setError(errorText(err)); @@ -109,280 +521,962 @@ export default function CalendarPage({ settings, auth }: { settings: ApiSettings } return ( -
-
-
-
-
- {heading} -
+
+ {error && {error}} -
-
- - - -
- -
- {modeOptions.map((option) => ( - - ))} -
- - - - - {canWrite && ( - - )} -
-
- - {error && {error}} - -
+
-
- {loading &&
} - {mode === "continuous" ? ( -
- +
+
+
+
+ {modeOptions.map((option) => ( + + ))} +
- ) : mode === "month" ? ( - - ) : ( - - )} -
-
- {createOpen && selectedCalendar && ( - +
+ + + +
+
+
+
+ +
+ + {canWrite && ( + + )} +
+
+ +
+ {loading &&
} + {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} + /> + )} +
+
+ + + {eventDialog && calendars.length > 0 && ( + setCreateOpen(false)} - onCreate={handleCreate} + canWrite={canWrite} + canDelete={canDelete} + onCancel={() => setEventDialog(null)} + onSave={handleSave} + onDelete={handleDelete} /> )} -
+ {calendarEditDialog && ( + setCalendarEditDialog(null)} + onSave={handleCalendarSave} + onDelete={handleCalendarDelete} + /> + )} + ); } -function CalendarWeekRows({ days, eventsByDay, focusDate, continuous = false }: { days: Date[]; eventsByDay: Map; focusDate: Date; continuous?: boolean }) { +function CalendarWeekRows({ + days, + eventsByDay, + calendarColorById, + focusDate, + variant, + preferences, + canWrite, + draggingEventId, + hoveredEventId, + dropTarget, + viewport, + onEventSelect, + onEventHover, + onEventDragStart, + onEventDragEnd, + 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; +}) { 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 visibleWeeks = weeks.slice(virtualWindow.start, virtualWindow.end); return ( -
+
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => {label})}
- {weeks.map((week) => ( + {virtualWindow.topSpacerHeight > 0 &&