From f00cff1d8cd5a6a7311002b347ee25d7a555ecb6 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 05:46:50 +0200 Subject: [PATCH] Complete recurrence editing and calendar preferences --- README.md | 10 +- docs/CALENDAR_INTEGRATION_CONCEPT.md | 15 +- src/govoplan_calendar/backend/db/models.py | 31 + src/govoplan_calendar/backend/ews.py | 236 +++++ src/govoplan_calendar/backend/graph.py | 157 ++++ src/govoplan_calendar/backend/ical.py | 43 +- src/govoplan_calendar/backend/manifest.py | 11 + .../b02c3d4e5f60_calendar_view_preferences.py | 28 + .../b02c3d4e5f60_calendar_view_preferences.py | 83 ++ src/govoplan_calendar/backend/router.py | 147 +++ src/govoplan_calendar/backend/schemas.py | 38 + src/govoplan_calendar/backend/service.py | 858 ++++++++++++------ tests/test_provider_adapters.py | 113 +++ tests/test_recurrence_preferences.py | 256 ++++++ webui/src/api/calendar.ts | 80 +- .../features/calendar/CalendarEventDialog.tsx | 64 +- webui/src/features/calendar/CalendarPage.tsx | 233 ++++- .../calendar/CalendarSettingsPanel.tsx | 241 +++++ webui/src/features/calendar/CalendarViews.tsx | 27 +- .../calendar/UpcomingEventsWidget.tsx | 7 +- .../features/calendar/calendarViewModel.ts | 8 +- webui/src/module.ts | 30 +- webui/tsconfig.calendar-page-tests.json | 6 + 23 files changed, 2354 insertions(+), 368 deletions(-) create mode 100644 src/govoplan_calendar/backend/ews.py create mode 100644 src/govoplan_calendar/backend/graph.py create mode 100644 src/govoplan_calendar/backend/migrations/dev_versions/b02c3d4e5f60_calendar_view_preferences.py create mode 100644 src/govoplan_calendar/backend/migrations/versions/b02c3d4e5f60_calendar_view_preferences.py create mode 100644 tests/test_provider_adapters.py create mode 100644 tests/test_recurrence_preferences.py create mode 100644 webui/src/features/calendar/CalendarSettingsPanel.tsx diff --git a/README.md b/README.md index 83274f7..0b96f08 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,12 @@ 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. +Calendar exposes collection and credential management, sync status/actions, +durable per-user view preferences, recurrence expansion, detached occurrence +overrides, instance/series editing, and a free/busy API primitive for scheduling +and appointment modules. It is not yet a CalDAV network server: external clients +cannot use GovOPlaN itself as their CalDAV endpoint, and scheduling inbox/outbox +delivery remains owned by the scheduling and mail integration work. ## Development diff --git a/docs/CALENDAR_INTEGRATION_CONCEPT.md b/docs/CALENDAR_INTEGRATION_CONCEPT.md index 71cd684..f42957e 100644 --- a/docs/CALENDAR_INTEGRATION_CONCEPT.md +++ b/docs/CALENDAR_INTEGRATION_CONCEPT.md @@ -6,7 +6,7 @@ - calendar collections - VEVENT storage and iCalendar import/export -- event recurrence data, recurrence exceptions, and future recurrence expansion +- event recurrence data, recurrence expansion, and recurrence exceptions - availability and free/busy semantics - resources such as rooms, shared equipment, and service desks - groupware calendar adapter boundaries, including CalDAV and Open-Xchange @@ -22,7 +22,10 @@ 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 +- bounded occurrence expansion with RRULE, RDATE, EXDATE, detached overrides, + instance/series editing, and free/busy reconciliation +- durable per-user calendar view preferences exposed through the platform + Settings surface - 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 - a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event state and the exact external resource snapshot commit atomically; workers use @@ -30,7 +33,10 @@ The first standalone module provides: exponential retry, and semantic GET reconciliation after ambiguous outcomes - 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, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built. +The implementation is not yet a full CalDAV network server. It is the internal +calendar storage, recurrence, sync, availability, and UI foundation on which +Open-Xchange integration, scheduling inbox/outbox behavior, and CalDAV server +endpoints can be built. ## Outbound delivery operations @@ -197,9 +203,6 @@ 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. -- 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. diff --git a/src/govoplan_calendar/backend/db/models.py b/src/govoplan_calendar/backend/db/models.py index a110681..2ad0dcb 100644 --- a/src/govoplan_calendar/backend/db/models.py +++ b/src/govoplan_calendar/backend/db/models.py @@ -94,6 +94,36 @@ class CalendarEvent(Base, TimestampMixin): calendar: Mapped[CalendarCollection] = relationship(back_populates="events") +class CalendarViewPreference(Base, TimestampMixin): + __tablename__ = "calendar_view_preferences" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "user_id", + name="uq_calendar_view_preferences_tenant_user", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + index=True, + ) + user_id: Mapped[str] = mapped_column( + ForeignKey("access_users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + dim_weekends: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + dim_off_hours: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + workday_start_hour: Mapped[int | None] = mapped_column(Integer, nullable=True) + workday_end_hour: Mapped[int | None] = mapped_column(Integer, nullable=True) + continuous_virtualization: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + continuous_overscan_weeks: Mapped[int | None] = mapped_column(Integer, nullable=True) + alternate_continuous_months: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + + class CalendarSyncSource(Base, TimestampMixin): __tablename__ = "calendar_sync_sources" __table_args__ = ( @@ -215,5 +245,6 @@ __all__ = [ "CalendarOutboxOperation", "CalendarSyncCredential", "CalendarSyncSource", + "CalendarViewPreference", "new_uuid", ] diff --git a/src/govoplan_calendar/backend/ews.py b/src/govoplan_calendar/backend/ews.py new file mode 100644 index 0000000..e5afcca --- /dev/null +++ b/src/govoplan_calendar/backend/ews.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from defusedxml import ElementTree as SafeElementTree + + +EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/" +EWS_MESSAGES_NS = ( + "http://schemas.microsoft.com/exchange/services/2006/messages" +) +EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types" + + +class EwsAdapterError(ValueError): + """Raised when an EWS response cannot be translated safely.""" + + +def ews_find_item_body( + *, + start: datetime, + end: datetime, + mailbox: Any | None = None, +) -> str: + start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z") + end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z") + mailbox_xml = "" + if mailbox: + mailbox_xml = ( + "" + f"{xml_escape(str(mailbox))}" + "" + ) + return f""" + + + + + + + + AllProperties + + + + {mailbox_xml} + + + +""" + + +def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]: + try: + root = SafeElementTree.fromstring(xml_text) + except SafeElementTree.ParseError as exc: + raise EwsAdapterError(f"Invalid EWS response XML: {exc}") from exc + items: list[dict[str, Any]] = [] + for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"): + item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId") + href = item_id.get("Id") if item_id is not None else None + if not href: + continue + change_key = ( + item_id.get("ChangeKey") if item_id is not None else None + ) + start = parse_ews_datetime(text_of(item, "Start")) + end_text = text_of(item, "End") + end = parse_ews_datetime(end_text) if end_text else start + uid = text_of(item, "UID") or href + subject = text_of(item, "Subject") or "(Untitled event)" + all_day = text_of(item, "IsAllDayEvent") == "true" + free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy" + sensitivity = text_of(item, "Sensitivity") or "Normal" + body = item.find(f"./{{{EWS_TYPES_NS}}}Body") + provider_payload = element_to_dict(item) + items.append( + { + "href": href, + "uid": uid, + "recurrence_id": ( + href + if text_of(item, "CalendarItemType") + in {"Occurrence", "Exception"} + else None + ), + "sequence": int_or_default( + text_of(item, "AppointmentSequenceNumber"), + 0, + ), + "summary": subject, + "description": body.text if body is not None else None, + "location": text_of(item, "Location"), + "status": ( + "CANCELLED" + if text_of(item, "IsCancelled") == "true" + else "CONFIRMED" + ), + "transparency": ( + "TRANSPARENT" + if free_busy.lower() == "free" + else "OPAQUE" + ), + "classification": ( + "PRIVATE" + if sensitivity.lower() == "private" + else "PUBLIC" + ), + "start_at": start, + "end_at": end, + "duration_seconds": int((end - start).total_seconds()), + "all_day": all_day, + "timezone": "UTC", + "organizer": ews_mailbox_record( + item.find( + f"./{{{EWS_TYPES_NS}}}Organizer/" + f"{{{EWS_TYPES_NS}}}Mailbox" + ) + ), + "attendees": ews_attendees(item), + "categories": [ + category.text + for category in item.findall( + f"./{{{EWS_TYPES_NS}}}Categories/" + f"{{{EWS_TYPES_NS}}}String" + ) + if category.text + ], + "rrule": None, + "rdate": [], + "exdate": [], + "reminders": ews_reminders(item), + "attachments": [], + "related_to": [], + "etag": change_key, + "icalendar": { + "component": "VEVENT", + "schema_version": 1, + "provider": "ews", + "ews": provider_payload, + }, + "metadata": {"ews": provider_payload}, + } + ) + return items + + +def parse_ews_datetime(value: str | None) -> datetime: + if not value: + return datetime.now(timezone.utc) + return normalize_datetime( + datetime.fromisoformat(value.replace("Z", "+00:00")) + ) + + +def text_of(item: Any, name: str) -> str | None: + child = item.find(f"./{{{EWS_TYPES_NS}}}{name}") + return child.text if child is not None else None + + +def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None: + if mailbox is None: + return None + return { + "name": text_of(mailbox, "Name") or "", + "email": text_of(mailbox, "EmailAddress") or "", + "routing_type": text_of(mailbox, "RoutingType") or "", + } + + +def ews_attendees(item: Any) -> list[dict[str, Any]]: + attendees: list[dict[str, Any]] = [] + for role, path in ( + ("required", "RequiredAttendees"), + ("optional", "OptionalAttendees"), + ): + for attendee in item.findall( + f"./{{{EWS_TYPES_NS}}}{path}/" + f"{{{EWS_TYPES_NS}}}Attendee" + ): + mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox") + record = ews_mailbox_record(mailbox) or {} + record["role"] = role + response = text_of(attendee, "ResponseType") + if response: + record["status"] = response + attendees.append(record) + return attendees + + +def ews_reminders(item: Any) -> list[dict[str, Any]]: + if text_of(item, "ReminderIsSet") == "true": + minutes = int_or_default( + text_of(item, "ReminderMinutesBeforeStart"), + 15, + ) + return [{"action": "DISPLAY", "trigger_minutes_before": minutes}] + return [] + + +def element_to_dict(element: Any) -> dict[str, Any]: + tag = element.tag.rsplit("}", 1)[-1] + children = list(element) + result: dict[str, Any] = {"tag": tag} + if element.attrib: + result["attributes"] = dict(element.attrib) + if element.text and element.text.strip(): + result["text"] = element.text.strip() + if children: + result["children"] = [ + element_to_dict(child) for child in children + ] + return result + + +def int_or_default(value: str | None, default: int) -> int: + try: + return int(value) if value is not None else default + except ValueError: + return default + + +def xml_escape(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def normalize_datetime(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/src/govoplan_calendar/backend/graph.py b/src/govoplan_calendar/backend/graph.py new file mode 100644 index 0000000..d313574 --- /dev/null +++ b/src/govoplan_calendar/backend/graph.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +import uuid +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + +def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]: + """Translate one Microsoft Graph event into the calendar event contract.""" + + start = graph_datetime(item.get("start")) + end = graph_datetime(item.get("end")) + all_day = bool(item.get("isAllDay")) + uid = str( + item.get("iCalUId") + or item.get("uid") + or item.get("id") + or uuid.uuid4() + ) + return { + "uid": uid, + "recurrence_id": ( + str(item.get("id")) + if item.get("type") == "occurrence" + else None + ), + "sequence": int(item.get("sequence") or 0), + "summary": str(item.get("subject") or "(Untitled event)"), + "description": graph_body_text(item), + "location": graph_location_text(item.get("location")), + "status": ( + "CANCELLED" if item.get("isCancelled") else "CONFIRMED" + ), + "transparency": ( + "TRANSPARENT" + if item.get("showAs") in {"free", "workingElsewhere"} + else "OPAQUE" + ), + "classification": ( + "PRIVATE" + if item.get("sensitivity") == "private" + else "PUBLIC" + ), + "start_at": start, + "end_at": end, + "duration_seconds": int((end - start).total_seconds()), + "all_day": all_day, + "timezone": ( + (item.get("start") or {}).get("timeZone") + if isinstance(item.get("start"), dict) + else None + ) + or "UTC", + "organizer": graph_party(item.get("organizer")), + "attendees": [ + graph_party(attendee) + for attendee in item.get("attendees") or [] + ], + "categories": [ + str(category) for category in item.get("categories") or [] + ], + "rrule": ( + item.get("recurrence") + if isinstance(item.get("recurrence"), dict) + else None + ), + "rdate": [], + "exdate": [], + "reminders": graph_reminders(item), + "attachments": [], + "related_to": [], + "etag": item.get("@odata.etag"), + "icalendar": { + "component": "VEVENT", + "schema_version": 1, + "provider": "graph", + "graph": item, + }, + "metadata": {"graph": item}, + } + + +def graph_datetime(value: Any) -> datetime: + if not isinstance(value, dict) or not value.get("dateTime"): + return datetime.now(timezone.utc) + raw = str(value["dateTime"]).replace("Z", "+00:00") + parsed = datetime.fromisoformat(raw) + tz_name = str(value.get("timeZone") or "UTC") + if parsed.tzinfo is None: + try: + parsed = parsed.replace(tzinfo=ZoneInfo(tz_name)) + except ZoneInfoNotFoundError: + parsed = parsed.replace(tzinfo=timezone.utc) + return normalize_datetime(parsed) + + +def graph_body_text(item: dict[str, Any]) -> str | None: + body = item.get("body") + if isinstance(body, dict) and body.get("content"): + return str(body["content"]) + return str(item.get("bodyPreview")) if item.get("bodyPreview") else None + + +def graph_location_text(value: Any) -> str | None: + if isinstance(value, dict) and value.get("displayName"): + return str(value["displayName"]) + return None + + +def graph_party(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + email = ( + value.get("emailAddress") + if isinstance(value.get("emailAddress"), dict) + else value + ) + result = { + "name": ( + str(email.get("name") or "") + if isinstance(email, dict) + else "" + ), + "email": ( + str(email.get("address") or "") + if isinstance(email, dict) + else "" + ), + } + if value.get("type"): + result["role"] = value["type"] + if value.get("status"): + result["status"] = value["status"] + return result + + +def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]: + if ( + item.get("isReminderOn") + and item.get("reminderMinutesBeforeStart") is not None + ): + return [ + { + "action": "DISPLAY", + "trigger_minutes_before": int( + item["reminderMinutesBeforeStart"] + ), + } + ] + return [] + + +def normalize_datetime(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/src/govoplan_calendar/backend/ical.py b/src/govoplan_calendar/backend/ical.py index 340fe86..c6f7640 100644 --- a/src/govoplan_calendar/backend/ical.py +++ b/src/govoplan_calendar/backend/ical.py @@ -302,6 +302,30 @@ def recurrence_id_value(value: Any | None) -> str | None: return f"TZID={tzid}:{raw_value}" if tzid else raw_value +def recurrence_id_datetime(value: str | None) -> datetime | None: + """Parse the canonical/raw RECURRENCE-ID forms stored by the module.""" + + if not value: + return None + raw_value = value + params: dict[str, Any] = {} + if value.startswith("TZID=") and ":" in value: + tzid, raw_value = value.removeprefix("TZID=").split(":", 1) + params["TZID"] = [tzid] + parsed = parse_ical_temporal(raw_value, params) + if parsed is not None: + return parsed + try: + return normalize_datetime(datetime.fromisoformat(raw_value)) + except ValueError: + return None + + +def normalized_recurrence_id(value: str | None) -> str | None: + parsed = recurrence_id_datetime(value) + return format_ical_datetime(parsed) if parsed is not None else value + + def recurrence_rule(value: Any | None) -> dict[str, Any] | None: if value is None: return None @@ -653,13 +677,30 @@ def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: da def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]: return { "uid": event.uid, - "recurrence_id": format_ical_datetime(start_at), + "recurrence_id": occurrence_recurrence_id(start_at, event), "start_at": start_at, "end_at": start_at + duration if duration else None, "all_day": event.all_day, } +def occurrence_recurrence_id(start_at: datetime, event: Any) -> str: + start_at = normalize_datetime(start_at) + if event.all_day: + return start_at.strftime("%Y%m%d") + timezone_id = getattr(event, "timezone", None) + if timezone_id: + try: + local_start = start_at.astimezone(ZoneInfo(timezone_id)) + return ( + f"TZID={timezone_id}:" + f"{local_start.strftime('%Y%m%dT%H%M%S')}" + ) + except ZoneInfoNotFoundError: + pass + return format_ical_datetime(start_at) + + def int_or_zero(value: str | None) -> int: try: return int(value or "0") diff --git a/src/govoplan_calendar/backend/manifest.py b/src/govoplan_calendar/backend/manifest.py index 11880d6..55e0a4a 100644 --- a/src/govoplan_calendar/backend/manifest.py +++ b/src/govoplan_calendar/backend/manifest.py @@ -35,6 +35,7 @@ _calendar_table_retirement_provider = drop_table_retirement_provider( calendar_models.CalendarOutboxOperation, calendar_models.CalendarSyncCredential, calendar_models.CalendarSyncSource, + calendar_models.CalendarViewPreference, label="Calendar", ) @@ -127,6 +128,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]: CalendarOutboxOperation, CalendarSyncCredential, CalendarSyncSource, + CalendarViewPreference, ) return { @@ -134,6 +136,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]: "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(), + "calendar_view_preferences": session.query(CalendarViewPreference).filter(CalendarViewPreference.tenant_id == tenant_id).count(), "calendar_outbox_pending": session.query(CalendarOutboxOperation) .filter( CalendarOutboxOperation.tenant_id == tenant_id, @@ -213,6 +216,13 @@ manifest = ModuleManifest( label="Upcoming events widget", order=40, ), + ViewSurface( + id="calendar.settings.preferences", + module_id="calendar", + kind="section", + label="Calendar preferences", + order=45, + ), ), ), migration_spec=MigrationSpec( @@ -230,6 +240,7 @@ manifest = ModuleManifest( calendar_models.CalendarOutboxOperation, calendar_models.CalendarSyncCredential, calendar_models.CalendarSyncSource, + calendar_models.CalendarViewPreference, label="Calendar", ), ), diff --git a/src/govoplan_calendar/backend/migrations/dev_versions/b02c3d4e5f60_calendar_view_preferences.py b/src/govoplan_calendar/backend/migrations/dev_versions/b02c3d4e5f60_calendar_view_preferences.py new file mode 100644 index 0000000..7df1f2c --- /dev/null +++ b/src/govoplan_calendar/backend/migrations/dev_versions/b02c3d4e5f60_calendar_view_preferences.py @@ -0,0 +1,28 @@ +"""Add durable per-user calendar view preferences on the development track. + +Revision ID: b02c3d4e5f60 +Revises: af1b2c3d4e5f +Create Date: 2026-07-31 00:00:00.000000 +""" +from __future__ import annotations + +from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import ( + downgrade as _downgrade, +) +from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import ( + upgrade as _upgrade, +) + + +revision = "b02c3d4e5f60" +down_revision = "af1b2c3d4e5f" +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + _upgrade() + + +def downgrade() -> None: + _downgrade() diff --git a/src/govoplan_calendar/backend/migrations/versions/b02c3d4e5f60_calendar_view_preferences.py b/src/govoplan_calendar/backend/migrations/versions/b02c3d4e5f60_calendar_view_preferences.py new file mode 100644 index 0000000..70a3b91 --- /dev/null +++ b/src/govoplan_calendar/backend/migrations/versions/b02c3d4e5f60_calendar_view_preferences.py @@ -0,0 +1,83 @@ +"""Add durable per-user calendar view preferences. + +Revision ID: b02c3d4e5f60 +Revises: af1b2c3d4e5f +Create Date: 2026-07-31 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "b02c3d4e5f60" +down_revision = "af1b2c3d4e5f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "calendar_view_preferences", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("dim_weekends", sa.Boolean(), nullable=True), + sa.Column("dim_off_hours", sa.Boolean(), nullable=True), + sa.Column("workday_start_hour", sa.Integer(), nullable=True), + sa.Column("workday_end_hour", sa.Integer(), nullable=True), + sa.Column("continuous_virtualization", sa.Boolean(), nullable=True), + sa.Column("continuous_overscan_weeks", sa.Integer(), nullable=True), + sa.Column("alternate_continuous_months", sa.Boolean(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["core_scopes.id"], + name=op.f( + "fk_calendar_view_preferences_tenant_id_core_scopes" + ), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["access_users.id"], + name=op.f( + "fk_calendar_view_preferences_user_id_access_users" + ), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_calendar_view_preferences"), + ), + sa.UniqueConstraint( + "tenant_id", + "user_id", + name="uq_calendar_view_preferences_tenant_user", + ), + ) + op.create_index( + op.f("ix_calendar_view_preferences_tenant_id"), + "calendar_view_preferences", + ["tenant_id"], + unique=False, + ) + op.create_index( + op.f("ix_calendar_view_preferences_user_id"), + "calendar_view_preferences", + ["user_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_calendar_view_preferences_user_id"), + table_name="calendar_view_preferences", + ) + op.drop_index( + op.f("ix_calendar_view_preferences_tenant_id"), + table_name="calendar_view_preferences", + ) + op.drop_table("calendar_view_preferences") diff --git a/src/govoplan_calendar/backend/router.py b/src/govoplan_calendar/backend/router.py index e975735..3db66e6 100644 --- a/src/govoplan_calendar/backend/router.py +++ b/src/govoplan_calendar/backend/router.py @@ -33,6 +33,8 @@ from govoplan_calendar.backend.schemas import ( CalendarEventCreateRequest, CalendarEventDeltaResponse, CalendarEventListResponse, + CalendarEventOccurrenceDeleteRequest, + CalendarEventOccurrenceUpdateRequest, CalendarEventResponse, CalendarEventUpdateRequest, CalendarFreeBusyRequest, @@ -49,6 +51,8 @@ from govoplan_calendar.backend.schemas import ( CalendarSyncSourceSyncRequest, CalendarSyncSourceSyncResponse, CalendarSyncSourceUpdateRequest, + CalendarViewPreferencesResponse, + CalendarViewPreferencesUpdateRequest, ) from govoplan_calendar.backend.outbox import ( calendar_outbox_operation_response, @@ -75,14 +79,17 @@ from govoplan_calendar.backend.service import ( delete_caldav_source, delete_sync_source, delete_event, + delete_event_occurrence, discover_caldav_calendars, event_response, + get_calendar_view_preferences, get_event, import_ics_event, list_freebusy, list_caldav_sources, list_calendars, list_events, + list_event_occurrences, list_sync_sources, normalize_datetime, sync_due_sources, @@ -93,6 +100,8 @@ from govoplan_calendar.backend.service import ( update_caldav_source, update_sync_source, update_event, + update_event_occurrence, + update_calendar_view_preferences, ) from govoplan_core.db.session import get_session @@ -743,10 +752,39 @@ def api_list_events( calendar_id: str | None = None, start_at: datetime | None = Query(default=None), end_at: datetime | None = Query(default=None), + expand_recurring: bool = Query(default=False), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): _require_scope(principal, "calendar:event:read") + if expand_recurring: + if start_at is None or end_at is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=( + "start_at and end_at are required when expanding " + "recurring events" + ), + ) + try: + occurrences = list_event_occurrences( + session, + tenant_id=principal.tenant_id, + calendar_id=calendar_id, + start_at=start_at, + end_at=end_at, + ) + return CalendarEventListResponse( + events=[ + CalendarEventResponse.model_validate(item) + for item in occurrences + ] + ) + except CalendarError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at) return CalendarEventListResponse(events=[_event_response(event) for event in events]) @@ -850,6 +888,70 @@ def api_update_event( raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc +@router.patch( + "/events/{series_event_id}/occurrence", + response_model=CalendarEventResponse, +) +def api_update_event_occurrence( + series_event_id: str, + payload: CalendarEventOccurrenceUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE) + try: + event = update_event_occurrence( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + series_event_id=series_event_id, + payload=payload, + ) + session.commit() + session.refresh(event) + return _event_response(event) + except CalendarError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.delete( + "/events/{series_event_id}/occurrence", + response_model=CalendarEventResponse, +) +def api_delete_event_occurrence( + series_event_id: str, + payload: CalendarEventOccurrenceDeleteRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_any_scope( + principal, + "calendar:event:delete", + CALENDAR_EVENT_WRITE_SCOPE, + ) + try: + event = delete_event_occurrence( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + series_event_id=series_event_id, + recurrence_id=payload.recurrence_id, + ) + session.commit() + session.refresh(event) + return _event_response(event) + except CalendarError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + @router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_event( event_id: str, @@ -866,6 +968,51 @@ def api_delete_event( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc +@router.get( + "/preferences/view", + response_model=CalendarViewPreferencesResponse, +) +def api_get_calendar_view_preferences( + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:event:read") + return CalendarViewPreferencesResponse.model_validate( + get_calendar_view_preferences( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + ) + ) + + +@router.patch( + "/preferences/view", + response_model=CalendarViewPreferencesResponse, +) +def api_update_calendar_view_preferences( + payload: CalendarViewPreferencesUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_scope(principal, "calendar:event:read") + try: + response = update_calendar_view_preferences( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + payload=payload, + ) + session.commit() + return CalendarViewPreferencesResponse.model_validate(response) + except CalendarError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + @router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED) def api_import_ics_event( payload: CalendarIcsImportRequest, diff --git a/src/govoplan_calendar/backend/schemas.py b/src/govoplan_calendar/backend/schemas.py index 7e51566..8762246 100644 --- a/src/govoplan_calendar/backend/schemas.py +++ b/src/govoplan_calendar/backend/schemas.py @@ -375,6 +375,16 @@ class CalendarEventUpdateRequest(BaseModel): metadata: dict[str, Any] | None = None +class CalendarEventOccurrenceUpdateRequest(CalendarEventUpdateRequest): + recurrence_id: str = Field(min_length=1, max_length=255) + + +class CalendarEventOccurrenceDeleteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + recurrence_id: str = Field(min_length=1, max_length=255) + + class CalendarEventResponse(BaseModel): id: str tenant_id: str @@ -409,6 +419,10 @@ class CalendarEventResponse(BaseModel): created_at: datetime updated_at: datetime metadata: dict[str, Any] = Field(default_factory=dict) + instance_id: str | None = None + series_event_id: str | None = None + is_occurrence: bool = False + is_override: bool = False class CalendarEventListResponse(BaseModel): @@ -423,6 +437,30 @@ class CalendarEventDeltaResponse(BaseModel): full: bool = False +class CalendarViewPreferencesUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + dim_weekends: bool | None = None + dim_off_hours: bool | None = None + workday_start_hour: int | None = Field(default=None, ge=0, le=23) + workday_end_hour: int | None = Field(default=None, ge=1, le=24) + continuous_virtualization: bool | None = None + continuous_overscan_weeks: int | None = Field(default=None, ge=2, le=20) + alternate_continuous_months: bool | None = None + + +class CalendarViewPreferencesResponse(BaseModel): + dim_weekends: bool + dim_off_hours: bool + workday_start_hour: int + workday_end_hour: int + continuous_virtualization: bool + continuous_overscan_weeks: int + alternate_continuous_months: bool + overridden_fields: list[str] = Field(default_factory=list) + defaults: dict[str, bool | int] = Field(default_factory=dict) + + class CalendarFreeBusyRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/src/govoplan_calendar/backend/service.py b/src/govoplan_calendar/backend/service.py index fbee2a7..3f11b1e 100644 --- a/src/govoplan_calendar/backend/service.py +++ b/src/govoplan_calendar/backend/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import json import posixpath import re @@ -11,10 +12,8 @@ import urllib.request from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any, Callable, Iterable -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from defusedxml import ElementTree as SafeElementTree -from sqlalchemy import or_ +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from govoplan_core.security.outbound_http import ( @@ -35,8 +34,26 @@ from govoplan_core.security.credential_envelopes import ( from govoplan_core.audit.logging import audit_event from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url -from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource -from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents +from govoplan_calendar.backend.db.models import ( + CalendarCollection, + CalendarEvent, + CalendarSyncCredential, + CalendarSyncSource, + CalendarViewPreference, +) +from govoplan_calendar.backend.ews import ( + EwsAdapterError, + ews_find_item_body, + parse_ews_calendar_items, +) +from govoplan_calendar.backend.graph import graph_event_payload +from govoplan_calendar.backend.ical import ( + expand_event_occurrences, + normalized_recurrence_id, + parse_vevent, + parse_vevents, + recurrence_id_datetime, +) from govoplan_calendar.backend.runtime import get_registry from govoplan_calendar.backend.schemas import ( CalendarCalDavDiscoveryRequest, @@ -46,9 +63,11 @@ from govoplan_calendar.backend.schemas import ( CalendarCollectionDeleteRequest, CalendarCollectionUpdateRequest, CalendarEventCreateRequest, + CalendarEventOccurrenceUpdateRequest, CalendarEventUpdateRequest, CalendarSyncSourceCreateRequest, CalendarSyncSourceUpdateRequest, + CalendarViewPreferencesUpdateRequest, ) from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider from govoplan_core.db.base import utcnow @@ -67,14 +86,21 @@ CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900 SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"} READ_ONLY_SYNC_SOURCE_KINDS = {"ics", "webcal", "graph", "ews"} GRAPH_DEFAULT_BASE_URL = "https://graph.microsoft.com/v1.0/" -EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/" -EWS_MESSAGES_NS = "http://schemas.microsoft.com/exchange/services/2006/messages" -EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types" CALENDAR_MODULE_ID = "calendar" CALENDAR_EVENTS_COLLECTION = "calendar.events" CALENDAR_EVENT_RESOURCE = "calendar_event" SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500 REMOTE_SYNC_MAX_ITEMS = 10_000 +MAX_OCCURRENCE_RANGE_DAYS = 400 +DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = { + "dim_weekends": True, + "dim_off_hours": True, + "workday_start_hour": 6, + "workday_end_hour": 20, + "continuous_virtualization": True, + "continuous_overscan_weeks": 6, + "alternate_continuous_months": True, +} def calendar_credential_context( @@ -3027,89 +3053,6 @@ def sync_graph_source( raise -def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]: - start = graph_datetime(item.get("start")) - end = graph_datetime(item.get("end")) - all_day = bool(item.get("isAllDay")) - uid = str(item.get("iCalUId") or item.get("uid") or item.get("id") or uuid.uuid4()) - return { - "uid": uid, - "recurrence_id": str(item.get("id")) if item.get("type") == "occurrence" else None, - "sequence": int(item.get("sequence") or 0), - "summary": str(item.get("subject") or "(Untitled event)"), - "description": graph_body_text(item), - "location": graph_location_text(item.get("location")), - "status": "CANCELLED" if item.get("isCancelled") else "CONFIRMED", - "transparency": "TRANSPARENT" if item.get("showAs") in {"free", "workingElsewhere"} else "OPAQUE", - "classification": "PRIVATE" if item.get("sensitivity") == "private" else "PUBLIC", - "start_at": start, - "end_at": end, - "duration_seconds": int((end - start).total_seconds()) if start and end else None, - "all_day": all_day, - "timezone": ((item.get("start") or {}).get("timeZone") if isinstance(item.get("start"), dict) else None) or "UTC", - "organizer": graph_party(item.get("organizer")), - "attendees": [graph_party(attendee) for attendee in item.get("attendees") or []], - "categories": [str(category) for category in item.get("categories") or []], - "rrule": item.get("recurrence") if isinstance(item.get("recurrence"), dict) else None, - "rdate": [], - "exdate": [], - "reminders": graph_reminders(item), - "attachments": [], - "related_to": [], - "etag": item.get("@odata.etag"), - "icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "graph", "graph": item}, - "metadata": {"graph": item}, - } - - -def graph_datetime(value: Any) -> datetime: - if not isinstance(value, dict) or not value.get("dateTime"): - return utcnow() - raw = str(value["dateTime"]).replace("Z", "+00:00") - parsed = datetime.fromisoformat(raw) - tz_name = str(value.get("timeZone") or "UTC") - if parsed.tzinfo is None: - try: - parsed = parsed.replace(tzinfo=ZoneInfo(tz_name)) - except ZoneInfoNotFoundError: - parsed = parsed.replace(tzinfo=timezone.utc) - return normalize_datetime(parsed) - - -def graph_body_text(item: dict[str, Any]) -> str | None: - body = item.get("body") - if isinstance(body, dict) and body.get("content"): - return str(body["content"]) - return str(item.get("bodyPreview")) if item.get("bodyPreview") else None - - -def graph_location_text(value: Any) -> str | None: - if isinstance(value, dict): - return str(value.get("displayName")) if value.get("displayName") else None - return None - - -def graph_party(value: Any) -> dict[str, Any]: - if not isinstance(value, dict): - return {} - email = value.get("emailAddress") if isinstance(value.get("emailAddress"), dict) else value - result = { - "name": str(email.get("name") or "") if isinstance(email, dict) else "", - "email": str(email.get("address") or "") if isinstance(email, dict) else "", - } - if value.get("type"): - result["role"] = value["type"] - if value.get("status"): - result["status"] = value["status"] - return result - - -def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]: - if item.get("isReminderOn") and item.get("reminderMinutesBeforeStart") is not None: - return [{"action": "DISPLAY", "trigger_minutes_before": int(item["reminderMinutesBeforeStart"])}] - return [] - - def sync_ews_source( session: Session, *, @@ -3153,7 +3096,10 @@ def sync_ews_source( mailbox=metadata.get("mailbox"), ), ) - items = parse_ews_calendar_items(body) + try: + items = parse_ews_calendar_items(body) + except EwsAdapterError as exc: + raise CalendarError(str(exc)) from exc if len(items) > REMOTE_SYNC_MAX_ITEMS: raise CalendarError( "Exchange Web Services sync exceeded the configured item limit." @@ -3349,152 +3295,6 @@ def soft_delete_unseen_source_events( return count -def ews_find_item_body(*, start: datetime, end: datetime, mailbox: Any | None = None) -> str: - start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z") - end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z") - mailbox_xml = "" - if mailbox: - mailbox_xml = f"{xml_escape(str(mailbox))}" - return f""" - - - - - - - - AllProperties - - - - {mailbox_xml} - - - -""" - - -def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]: - try: - root = SafeElementTree.fromstring(xml_text) - except SafeElementTree.ParseError as exc: - raise CalendarError(f"Invalid EWS response XML: {exc}") from exc - items: list[dict[str, Any]] = [] - for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"): - item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId") - href = item_id.get("Id") if item_id is not None else None - if not href: - continue - change_key = item_id.get("ChangeKey") if item_id is not None else None - start = parse_ews_datetime(text_of(item, "Start")) - end = parse_ews_datetime(text_of(item, "End")) if text_of(item, "End") else start - uid = text_of(item, "UID") or href - subject = text_of(item, "Subject") or "(Untitled event)" - all_day = text_of(item, "IsAllDayEvent") == "true" - free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy" - sensitivity = text_of(item, "Sensitivity") or "Normal" - body = item.find(f"./{{{EWS_TYPES_NS}}}Body") - provider_payload = element_to_dict(item) - items.append( - { - "href": href, - "uid": uid, - "recurrence_id": href if text_of(item, "CalendarItemType") in {"Occurrence", "Exception"} else None, - "sequence": int_or_default(text_of(item, "AppointmentSequenceNumber"), 0), - "summary": subject, - "description": body.text if body is not None else None, - "location": text_of(item, "Location"), - "status": "CANCELLED" if text_of(item, "IsCancelled") == "true" else "CONFIRMED", - "transparency": "TRANSPARENT" if free_busy.lower() == "free" else "OPAQUE", - "classification": "PRIVATE" if sensitivity.lower() == "private" else "PUBLIC", - "start_at": start, - "end_at": end, - "duration_seconds": int((end - start).total_seconds()) if start and end else None, - "all_day": all_day, - "timezone": "UTC", - "organizer": ews_mailbox_record(item.find(f"./{{{EWS_TYPES_NS}}}Organizer/{{{EWS_TYPES_NS}}}Mailbox")), - "attendees": ews_attendees(item), - "categories": [category.text for category in item.findall(f"./{{{EWS_TYPES_NS}}}Categories/{{{EWS_TYPES_NS}}}String") if category.text], - "rrule": None, - "rdate": [], - "exdate": [], - "reminders": ews_reminders(item), - "attachments": [], - "related_to": [], - "etag": change_key, - "icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "ews", "ews": provider_payload}, - "metadata": {"ews": provider_payload}, - } - ) - return items - - -def parse_ews_datetime(value: str | None) -> datetime: - if not value: - return utcnow() - return normalize_datetime(datetime.fromisoformat(value.replace("Z", "+00:00"))) - - -def text_of(item: Any, name: str) -> str | None: - child = item.find(f"./{{{EWS_TYPES_NS}}}{name}") - return child.text if child is not None else None - - -def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None: - if mailbox is None: - return None - return { - "name": text_of(mailbox, "Name") or "", - "email": text_of(mailbox, "EmailAddress") or "", - "routing_type": text_of(mailbox, "RoutingType") or "", - } - - -def ews_attendees(item: Any) -> list[dict[str, Any]]: - attendees: list[dict[str, Any]] = [] - for role, path in (("required", "RequiredAttendees"), ("optional", "OptionalAttendees")): - for attendee in item.findall(f"./{{{EWS_TYPES_NS}}}{path}/{{{EWS_TYPES_NS}}}Attendee"): - mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox") - record = ews_mailbox_record(mailbox) or {} - record["role"] = role - response = text_of(attendee, "ResponseType") - if response: - record["status"] = response - attendees.append(record) - return attendees - - -def ews_reminders(item: Any) -> list[dict[str, Any]]: - if text_of(item, "ReminderIsSet") == "true": - minutes = int_or_default(text_of(item, "ReminderMinutesBeforeStart"), 15) - return [{"action": "DISPLAY", "trigger_minutes_before": minutes}] - return [] - - -def element_to_dict(element: Any) -> dict[str, Any]: - tag = element.tag.rsplit("}", 1)[-1] - children = list(element) - result: dict[str, Any] = {"tag": tag} - if element.attrib: - result["attributes"] = dict(element.attrib) - if element.text and element.text.strip(): - result["text"] = element.text.strip() - if children: - result["children"] = [element_to_dict(child) for child in children] - return result - - -def int_or_default(value: str | None, default: int) -> int: - try: - return int(value) if value is not None else default - except ValueError: - return default - - -def xml_escape(value: str) -> str: - return value.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) - - def sync_due_sources( session: Session, *, @@ -3928,6 +3728,208 @@ def list_events( return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all() +def list_event_occurrences( + session: Session, + *, + tenant_id: str, + start_at: datetime, + end_at: datetime, + calendar_id: str | None = None, +) -> list[dict[str, Any]]: + """Return range-bounded events with recurring series fully reconciled.""" + + range_start = normalize_datetime(start_at) + range_end = normalize_datetime(end_at) + if range_end <= range_start: + raise CalendarError("Event range end must be after start") + if range_end - range_start > timedelta(days=MAX_OCCURRENCE_RANGE_DAYS): + raise CalendarError( + f"Expanded event ranges are limited to {MAX_OCCURRENCE_RANGE_DAYS} days" + ) + + base_query = session.query(CalendarEvent).filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.deleted_at.is_(None), + ) + if calendar_id: + base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id) + + recurring_masters = ( + base_query.filter( + CalendarEvent.recurrence_id.is_(None), + or_( + CalendarEvent.rrule.is_not(None), + func.json_array_length(CalendarEvent.rdate) > 0, + ), + ) + .order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc()) + .all() + ) + direct_events = ( + base_query.filter( + or_( + CalendarEvent.end_at.is_(None), + CalendarEvent.end_at >= range_start, + ), + CalendarEvent.start_at <= range_end, + ) + .order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc()) + .all() + ) + + series_keys = { + (event.calendar_id, event.uid) for event in recurring_masters + } + overrides: list[CalendarEvent] = [] + if series_keys: + series_uids = {uid for _calendar_id, uid in series_keys} + overrides = [ + event + for event in base_query.filter( + CalendarEvent.recurrence_id.is_not(None), + CalendarEvent.uid.in_(series_uids), + ).all() + if (event.calendar_id, event.uid) in series_keys + ] + + overrides_by_series: dict[ + tuple[str, str], dict[str, CalendarEvent] + ] = {} + for override in overrides: + recurrence_key = normalized_recurrence_id(override.recurrence_id) + if recurrence_key: + overrides_by_series.setdefault( + (override.calendar_id, override.uid), {} + )[recurrence_key] = override + + results: list[dict[str, Any]] = [] + consumed_event_ids: set[str] = set() + recurring_master_ids = {event.id for event in recurring_masters} + recurring_master_by_series = { + (event.calendar_id, event.uid): event for event in recurring_masters + } + for master in recurring_masters: + series_key = (master.calendar_id, master.uid) + series_overrides = overrides_by_series.get(series_key, {}) + for occurrence in expand_event_occurrences( + master, + range_start, + range_end, + ): + occurrence_recurrence_id = str( + occurrence.get("recurrence_id") or "" + ) + recurrence_key = normalized_recurrence_id( + occurrence_recurrence_id + ) + override = ( + series_overrides.get(recurrence_key) + if recurrence_key + else None + ) + if override is not None: + consumed_event_ids.add(override.id) + if override.status.upper() == "CANCELLED": + continue + if event_overlaps_range( + override, + range_start=range_start, + range_end=range_end, + ): + results.append( + expanded_event_response( + override, + series_event_id=master.id, + recurrence_id=( + override.recurrence_id + or occurrence_recurrence_id + ), + is_override=True, + ) + ) + continue + results.append( + expanded_event_response( + master, + series_event_id=master.id, + recurrence_id=occurrence_recurrence_id, + occurrence=occurrence, + ) + ) + + for event in direct_events: + if event.id in recurring_master_ids or event.id in consumed_event_ids: + continue + series_event_id = None + is_override = False + if event.recurrence_id: + master = recurring_master_by_series.get( + (event.calendar_id, event.uid) + ) + if master is not None: + series_event_id = master.id + is_override = True + if event.status.upper() == "CANCELLED": + continue + results.append( + expanded_event_response( + event, + series_event_id=series_event_id, + recurrence_id=normalized_recurrence_id(event.recurrence_id), + is_override=is_override, + ) + ) + return sorted( + results, + key=lambda item: ( + item["start_at"], + item["calendar_id"], + item["summary"], + item["instance_id"], + ), + ) + + +def event_overlaps_range( + event: CalendarEvent, + *, + range_start: datetime, + range_end: datetime, +) -> bool: + event_start = normalize_datetime(event.start_at) + event_end = ( + normalize_datetime(event.end_at) + if event.end_at is not None + else event_start + ) + return event_end >= range_start and event_start <= range_end + + +def expanded_event_response( + event: CalendarEvent, + *, + series_event_id: str | None, + recurrence_id: str | None, + occurrence: dict[str, Any] | None = None, + is_override: bool = False, +) -> dict[str, Any]: + payload = event_response(event) + if occurrence is not None: + payload["start_at"] = response_datetime(occurrence["start_at"]) + payload["end_at"] = response_datetime(occurrence.get("end_at")) + payload["all_day"] = bool(occurrence.get("all_day")) + payload["recurrence_id"] = recurrence_id + payload["series_event_id"] = series_event_id + payload["is_occurrence"] = series_event_id is not None + payload["is_override"] = is_override + payload["instance_id"] = ( + f"{series_event_id}:{recurrence_id}" + if series_event_id and recurrence_id + else event.id + ) + return payload + + def list_freebusy( session: Session, *, @@ -3940,40 +3942,49 @@ def list_freebusy( 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", - ) + events: list[dict[str, Any]] = [] 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, - } + for calendar_id in dict.fromkeys(calendar_ids): + events.extend( + list_event_occurrences( + session, + tenant_id=tenant_id, + calendar_id=calendar_id, + start_at=range_start, + end_at=range_end, + ) + ) + else: + events = list_event_occurrences( + session, + tenant_id=tenant_id, + start_at=range_start, + end_at=range_end, + ) + busy = [ + { + "calendar_id": event["calendar_id"], + "event_id": event["id"], + "uid": event["uid"], + "recurrence_id": event["recurrence_id"], + "start_at": event["start_at"], + "end_at": event["end_at"], + "all_day": event["all_day"], + "transparency": event["transparency"], + "status": event["status"], + } + for event in events + if event["status"].upper() != "CANCELLED" + and event["transparency"].upper() != "TRANSPARENT" + ] + return sorted( + busy, + key=lambda item: ( + item["start_at"], + item["calendar_id"], + item["uid"], + ), + ) def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEvent: @@ -3987,6 +3998,78 @@ def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEve return event +def get_calendar_view_preferences( + session: Session, + *, + tenant_id: str, + user_id: str, +) -> dict[str, Any]: + preference = ( + session.query(CalendarViewPreference) + .filter( + CalendarViewPreference.tenant_id == tenant_id, + CalendarViewPreference.user_id == user_id, + ) + .first() + ) + effective = dict(DEFAULT_CALENDAR_VIEW_PREFERENCES) + overridden_fields: list[str] = [] + if preference is not None: + for field_name in DEFAULT_CALENDAR_VIEW_PREFERENCES: + value = getattr(preference, field_name) + if value is not None: + effective[field_name] = value + overridden_fields.append(field_name) + return { + **effective, + "overridden_fields": overridden_fields, + "defaults": dict(DEFAULT_CALENDAR_VIEW_PREFERENCES), + } + + +def update_calendar_view_preferences( + session: Session, + *, + tenant_id: str, + user_id: str, + payload: CalendarViewPreferencesUpdateRequest, +) -> dict[str, Any]: + preference = ( + session.query(CalendarViewPreference) + .filter( + CalendarViewPreference.tenant_id == tenant_id, + CalendarViewPreference.user_id == user_id, + ) + .first() + ) + if preference is None: + preference = CalendarViewPreference( + tenant_id=tenant_id, + user_id=user_id, + ) + session.add(preference) + for field_name in payload.model_fields_set: + setattr(preference, field_name, getattr(payload, field_name)) + + effective = dict(DEFAULT_CALENDAR_VIEW_PREFERENCES) + for field_name in DEFAULT_CALENDAR_VIEW_PREFERENCES: + value = getattr(preference, field_name) + if value is not None: + effective[field_name] = value + if int(effective["workday_end_hour"]) <= int( + effective["workday_start_hour"] + ): + raise CalendarError( + "Workday end hour must be after workday start hour" + ) + session.flush() + return get_calendar_view_preferences( + session, + tenant_id=tenant_id, + user_id=user_id, + ) + + def create_event(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarEventCreateRequest) -> CalendarEvent: 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) @@ -4239,6 +4322,197 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event return event +def update_event_occurrence( + session: Session, + *, + tenant_id: str, + user_id: str | None, + series_event_id: str, + payload: CalendarEventOccurrenceUpdateRequest, +) -> CalendarEvent: + master = get_event( + session, + tenant_id=tenant_id, + event_id=series_event_id, + ) + if master.recurrence_id is not None or not (master.rrule or master.rdate): + raise CalendarError("Calendar event is not a recurring series master") + occurrence = recurrence_occurrence(master, payload.recurrence_id) + recurrence_id = str(occurrence["recurrence_id"]) + existing = find_event_override( + session, + tenant_id=tenant_id, + master=master, + recurrence_id=recurrence_id, + ) + update_values = payload.model_dump( + exclude_unset=True, + exclude={"recurrence_id", "rrule", "rdate", "exdate"}, + ) + if ( + "calendar_id" in update_values + and update_values["calendar_id"] != master.calendar_id + ): + raise CalendarError( + "A recurring occurrence cannot be moved to another calendar" + ) + update_values.pop("calendar_id", None) + update_payload = CalendarEventUpdateRequest(**update_values) + if existing is not None: + return update_event( + session, + tenant_id=tenant_id, + user_id=user_id, + event_id=existing.id, + payload=update_payload, + ) + + source = active_sync_source_for_calendar( + session, + tenant_id=tenant_id, + calendar_id=master.calendar_id, + for_update=True, + ) + assert_sync_mutation_allowed(source) + event = CalendarEvent( + tenant_id=tenant_id, + calendar_id=master.calendar_id, + uid=master.uid, + recurrence_id=recurrence_id, + sequence=max(0, int(master.sequence or 0)), + summary=master.summary, + description=master.description, + location=master.location, + status=master.status, + transparency=master.transparency, + classification=master.classification, + start_at=normalize_datetime(occurrence["start_at"]), + end_at=( + normalize_datetime(occurrence["end_at"]) + if occurrence.get("end_at") is not None + else None + ), + duration_seconds=master.duration_seconds, + all_day=master.all_day, + timezone=master.timezone, + organizer=copy.deepcopy(master.organizer), + attendees=copy.deepcopy(master.attendees or []), + categories=copy.deepcopy(master.categories or []), + rrule=None, + rdate=[], + exdate=[], + reminders=copy.deepcopy(master.reminders or []), + attachments=copy.deepcopy(master.attachments or []), + related_to=copy.deepcopy(master.related_to or []), + source_kind=master.source_kind, + source_href=master.source_href, + etag=master.etag, + icalendar=copy.deepcopy(master.icalendar or {}), + raw_ics=None, + created_by_user_id=user_id, + updated_by_user_id=user_id, + metadata_=copy.deepcopy(master.metadata_ or {}), + ) + _apply_event_update_values( + event, + user_id=user_id, + payload=update_payload, + ) + validate_event_time(event) + session.add(event) + session.flush() + if should_push_event_to_caldav(source=source, event=event): + from govoplan_calendar.backend.outbox import enqueue_caldav_put + + enqueue_caldav_put(session, source=source, event_model=event) + record_calendar_event_change( + session, + event=event, + operation="created", + user_id=user_id, + ) + return event + + +def delete_event_occurrence( + session: Session, + *, + tenant_id: str, + user_id: str | None, + series_event_id: str, + recurrence_id: str, +) -> CalendarEvent: + return update_event_occurrence( + session, + tenant_id=tenant_id, + user_id=user_id, + series_event_id=series_event_id, + payload=CalendarEventOccurrenceUpdateRequest( + recurrence_id=recurrence_id, + status="CANCELLED", + ), + ) + + +def recurrence_occurrence( + master: CalendarEvent, + recurrence_id: str, +) -> dict[str, Any]: + recurrence_start = recurrence_id_datetime(recurrence_id) + if recurrence_start is None: + raise CalendarError("Invalid recurrence_id") + recurrence_key = normalized_recurrence_id(recurrence_id) + candidates = expand_event_occurrences( + master, + recurrence_start - timedelta(seconds=1), + recurrence_start + timedelta(seconds=1), + ) + occurrence = next( + ( + item + for item in candidates + if normalized_recurrence_id(item.get("recurrence_id")) + == recurrence_key + ), + None, + ) + if occurrence is None: + raise CalendarError( + "recurrence_id is not an occurrence in this event series" + ) + return occurrence + + +def find_event_override( + session: Session, + *, + tenant_id: str, + master: CalendarEvent, + recurrence_id: str, +) -> CalendarEvent | None: + recurrence_key = normalized_recurrence_id(recurrence_id) + candidates = ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.calendar_id == master.calendar_id, + CalendarEvent.uid == master.uid, + CalendarEvent.recurrence_id.is_not(None), + CalendarEvent.deleted_at.is_(None), + ) + .all() + ) + return next( + ( + event + for event in candidates + if normalized_recurrence_id(event.recurrence_id) + == recurrence_key + ), + None, + ) + + def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None: event_locator = ( session.query(CalendarEvent.calendar_id) @@ -4258,15 +4532,43 @@ def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: st for_update=True, ) event = get_event(session, tenant_id=tenant_id, event_id=event_id) - previous = calendar_event_change_payload(event, prefix="previous_") assert_sync_mutation_allowed(source) - if should_push_event_to_caldav(source=source, event=event): + series_events = [event] + if event.recurrence_id is None and (event.rrule or event.rdate): + series_events = ( + session.query(CalendarEvent) + .filter( + CalendarEvent.tenant_id == tenant_id, + CalendarEvent.calendar_id == event.calendar_id, + CalendarEvent.uid == event.uid, + CalendarEvent.deleted_at.is_(None), + ) + .all() + ) + previous_by_id = { + item.id: calendar_event_change_payload( + item, + prefix="previous_", + ) + for item in series_events + } + push_delete = should_push_event_to_caldav(source=source, event=event) + deleted_at = utcnow() + for item in series_events: + item.deleted_at = deleted_at + session.flush() + if push_delete: from govoplan_calendar.backend.outbox import enqueue_caldav_delete enqueue_caldav_delete(session, source=source, event_model=event) - event.deleted_at = utcnow() - session.flush() - record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous) + for item in series_events: + record_calendar_event_change( + session, + event=item, + operation="deleted", + user_id=user_id, + previous=previous_by_id[item.id], + ) def active_sync_source_for_calendar( @@ -4550,4 +4852,8 @@ def event_response(event: CalendarEvent) -> dict[str, Any]: "created_at": response_datetime(event.created_at), "updated_at": response_datetime(event.updated_at), "metadata": event.metadata_ or {}, + "instance_id": event.id, + "series_event_id": None, + "is_occurrence": False, + "is_override": False, } diff --git a/tests/test_provider_adapters.py b/tests/test_provider_adapters.py new file mode 100644 index 0000000..18e41eb --- /dev/null +++ b/tests/test_provider_adapters.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from govoplan_calendar.backend.ews import ( + EwsAdapterError, + ews_find_item_body, + parse_ews_calendar_items, +) +from govoplan_calendar.backend.graph import graph_event_payload + + +class ProviderAdapterTests(unittest.TestCase): + def test_graph_event_fixture_maps_provider_fields(self) -> None: + payload = graph_event_payload( + { + "id": "graph-event-1", + "iCalUId": "uid-1@example.test", + "@odata.etag": 'W/"etag-1"', + "type": "occurrence", + "subject": "Planning", + "body": {"content": "Agenda"}, + "start": { + "dateTime": "2026-07-08T09:00:00", + "timeZone": "Europe/Berlin", + }, + "end": { + "dateTime": "2026-07-08T10:30:00", + "timeZone": "Europe/Berlin", + }, + "organizer": { + "emailAddress": { + "name": "Ada", + "address": "ada@example.test", + } + }, + "attendees": [ + { + "type": "required", + "emailAddress": { + "name": "Lin", + "address": "lin@example.test", + }, + } + ], + "isReminderOn": True, + "reminderMinutesBeforeStart": 15, + } + ) + + self.assertEqual(payload["uid"], "uid-1@example.test") + self.assertEqual(payload["recurrence_id"], "graph-event-1") + self.assertEqual( + payload["start_at"], + datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc), + ) + self.assertEqual(payload["duration_seconds"], 5_400) + self.assertEqual( + payload["attendees"][0]["email"], + "lin@example.test", + ) + self.assertEqual( + payload["reminders"][0]["trigger_minutes_before"], + 15, + ) + + def test_ews_calendar_fixture_maps_item_and_escapes_mailbox(self) -> None: + response_xml = """ + + + + + + EWS item + 2026-07-08T09:00:00Z + 2026-07-08T10:00:00Z + false + ews-uid-1 + + Ada + ada@example.test + + + + +""" + + items = parse_ews_calendar_items(response_xml) + + self.assertEqual(len(items), 1) + self.assertEqual(items[0]["href"], "ews-event-1") + self.assertEqual(items[0]["etag"], "ews-etag-1") + self.assertEqual( + items[0]["attendees"][0]["email"], + "ada@example.test", + ) + request_body = ews_find_item_body( + start=datetime(2026, 7, 1, tzinfo=timezone.utc), + end=datetime(2026, 8, 1, tzinfo=timezone.utc), + mailbox='ops&"<@example.test', + ) + self.assertIn("ops&"<@example.test", request_body) + + def test_ews_parser_rejects_invalid_xml(self) -> None: + with self.assertRaisesRegex(EwsAdapterError, "Invalid EWS"): + parse_ews_calendar_items("") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_recurrence_preferences.py b/tests/test_recurrence_preferences.py new file mode 100644 index 0000000..d44c07a --- /dev/null +++ b/tests/test_recurrence_preferences.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_access.backend.db import models as access_models # noqa: F401 +from govoplan_calendar.backend.db.models import CalendarEvent +from govoplan_calendar.backend.schemas import ( + CalendarCollectionCreateRequest, + CalendarEventCreateRequest, + CalendarEventOccurrenceUpdateRequest, + CalendarViewPreferencesUpdateRequest, +) +from govoplan_calendar.backend.service import ( + CalendarError, + create_calendar, + create_event, + delete_event, + delete_event_occurrence, + get_calendar_view_preferences, + list_event_occurrences, + list_freebusy, + update_calendar_view_preferences, + update_event_occurrence, +) +from govoplan_core.db.base import Base +from govoplan_core.tenancy.scope import create_scope_tables +from govoplan_tenancy.backend.db.models import Tenant + + +class CalendarRecurrenceAndPreferenceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + create_scope_tables(self.engine) + Base.metadata.create_all(bind=self.engine) + self.Session = sessionmaker(bind=self.engine) + self.session = self.Session() + self.session.add( + Tenant(id="tenant-1", slug="tenant-1", name="Tenant") + ) + self.calendar = create_calendar( + self.session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarCollectionCreateRequest(name="Calendar"), + ) + self.session.flush() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all(bind=self.engine) + self.engine.dispose() + + def recurring_master(self) -> CalendarEvent: + return create_event( + self.session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarEventCreateRequest( + calendar_id=self.calendar.id, + uid="series@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 + ), + rrule={"FREQ": "WEEKLY", "COUNT": "3"}, + ), + ) + + def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy( + self, + ) -> None: + master = self.recurring_master() + override = update_event_occurrence( + self.session, + tenant_id="tenant-1", + user_id=None, + series_event_id=master.id, + payload=CalendarEventOccurrenceUpdateRequest( + recurrence_id="20260715T090000Z", + summary="Moved planning", + start_at=datetime( + 2026, 7, 15, 13, 0, tzinfo=timezone.utc + ), + end_at=datetime( + 2026, 7, 15, 14, 30, tzinfo=timezone.utc + ), + ), + ) + cancelled = delete_event_occurrence( + self.session, + tenant_id="tenant-1", + user_id=None, + series_event_id=master.id, + recurrence_id="20260722T090000Z", + ) + self.session.commit() + + events = list_event_occurrences( + self.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( + [event["summary"] for event in events], + ["Planning", "Moved planning"], + ) + self.assertEqual(events[1]["id"], override.id) + self.assertEqual(events[1]["series_event_id"], master.id) + self.assertTrue(events[1]["is_override"]) + self.assertEqual( + events[1]["start_at"], + datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc), + ) + self.assertEqual(cancelled.status, "CANCELLED") + + busy = list_freebusy( + self.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(len(busy), 2) + self.assertEqual( + busy[1]["start_at"], + datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc), + ) + + def test_deleting_series_removes_its_overrides(self) -> None: + master = self.recurring_master() + update_event_occurrence( + self.session, + tenant_id="tenant-1", + user_id=None, + series_event_id=master.id, + payload=CalendarEventOccurrenceUpdateRequest( + recurrence_id="20260715T090000Z", + summary="Override", + ), + ) + + delete_event( + self.session, + tenant_id="tenant-1", + event_id=master.id, + ) + + active = ( + self.session.query(CalendarEvent) + .filter(CalendarEvent.deleted_at.is_(None)) + .count() + ) + self.assertEqual(active, 0) + + def test_all_day_occurrence_retains_date_recurrence_id(self) -> None: + master = create_event( + self.session, + tenant_id="tenant-1", + user_id=None, + payload=CalendarEventCreateRequest( + calendar_id=self.calendar.id, + uid="all-day-series@example.test", + summary="All-day planning", + start_at=datetime( + 2026, 7, 8, tzinfo=timezone.utc + ), + end_at=datetime( + 2026, 7, 9, tzinfo=timezone.utc + ), + all_day=True, + rrule={"FREQ": "WEEKLY", "COUNT": "2"}, + ), + ) + + events = list_event_occurrences( + self.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( + [event["recurrence_id"] for event in events], + ["20260708", "20260715"], + ) + + override = update_event_occurrence( + self.session, + tenant_id="tenant-1", + user_id=None, + series_event_id=master.id, + payload=CalendarEventOccurrenceUpdateRequest( + recurrence_id=events[1]["recurrence_id"], + summary="Moved all-day planning", + ), + ) + self.assertEqual(override.recurrence_id, "20260715") + + def test_calendar_preferences_have_defaults_and_durable_user_overrides( + self, + ) -> None: + defaults = get_calendar_view_preferences( + self.session, + tenant_id="tenant-1", + user_id="user-1", + ) + self.assertTrue(defaults["dim_weekends"]) + self.assertEqual(defaults["overridden_fields"], []) + + updated = update_calendar_view_preferences( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=CalendarViewPreferencesUpdateRequest( + dim_weekends=False, + workday_start_hour=8, + workday_end_hour=18, + ), + ) + self.session.commit() + reloaded = get_calendar_view_preferences( + self.session, + tenant_id="tenant-1", + user_id="user-1", + ) + + self.assertEqual(updated, reloaded) + self.assertFalse(reloaded["dim_weekends"]) + self.assertEqual(reloaded["workday_start_hour"], 8) + self.assertEqual(reloaded["workday_end_hour"], 18) + self.assertEqual( + set(reloaded["overridden_fields"]), + {"dim_weekends", "workday_start_hour", "workday_end_hour"}, + ) + + def test_calendar_preferences_reject_inverted_workday(self) -> None: + with self.assertRaisesRegex(CalendarError, "end hour"): + update_calendar_view_preferences( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=CalendarViewPreferencesUpdateRequest( + workday_start_hour=18, + workday_end_hour=8, + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/api/calendar.ts b/webui/src/api/calendar.ts index 7d9d5b2..94bc92a 100644 --- a/webui/src/api/calendar.ts +++ b/webui/src/api/calendar.ts @@ -51,6 +51,10 @@ export type CalendarEvent = { created_at: string; updated_at: string; metadata?: Record | null; + instance_id?: string | null; + series_event_id?: string | null; + is_occurrence?: boolean; + is_override?: boolean; }; export type CalendarCollectionListResponse = { calendars: CalendarCollection[] }; @@ -243,6 +247,29 @@ export type CalendarEventCreatePayload = { }; export type CalendarEventUpdatePayload = Partial; +export type CalendarViewPreferences = { + dim_weekends: boolean; + dim_off_hours: boolean; + workday_start_hour: number; + workday_end_hour: number; + continuous_virtualization: boolean; + continuous_overscan_weeks: number; + alternate_continuous_months: boolean; + overridden_fields: string[]; + defaults: Record; +}; +export type CalendarViewPreferencesUpdatePayload = Partial< + Pick< + CalendarViewPreferences, + | "dim_weekends" + | "dim_off_hours" + | "workday_start_hour" + | "workday_end_hour" + | "continuous_virtualization" + | "continuous_overscan_weeks" + | "alternate_continuous_months" + > +>; export function listCalendars(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/calendar/calendars"); @@ -320,12 +347,13 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa export function listCalendarEvents( settings: ApiSettings, - params: { calendar_id?: string; start_at?: string; end_at?: string } = {} + params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {} ): Promise { const search = new URLSearchParams(); if (params.calendar_id) search.set("calendar_id", params.calendar_id); if (params.start_at) search.set("start_at", params.start_at); if (params.end_at) search.set("end_at", params.end_at); + if (params.expand_recurring) search.set("expand_recurring", "true"); const suffix = search.toString() ? `?${search.toString()}` : ""; return apiFetch(settings, `/api/v1/calendar/events${suffix}`); } @@ -344,6 +372,10 @@ export function listCalendarEventsDelta( return apiFetch(settings, `/api/v1/calendar/events/delta${suffix}`); } +export function getCalendarEvent(settings: ApiSettings, eventId: string): Promise { + return apiFetch(settings, `/api/v1/calendar/events/${eventId}`); +} + export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise { return apiFetch(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) }); } @@ -355,3 +387,49 @@ export function updateCalendarEvent(settings: ApiSettings, eventId: string, payl export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise { return apiFetch(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" }); } + +export function updateCalendarEventOccurrence( + settings: ApiSettings, + seriesEventId: string, + recurrenceId: string, + payload: CalendarEventUpdatePayload +): Promise { + return apiFetch( + settings, + `/api/v1/calendar/events/${seriesEventId}/occurrence`, + { + method: "PATCH", + body: JSON.stringify({ ...payload, recurrence_id: recurrenceId }) + } + ); +} + +export function deleteCalendarEventOccurrence( + settings: ApiSettings, + seriesEventId: string, + recurrenceId: string +): Promise { + return apiFetch( + settings, + `/api/v1/calendar/events/${seriesEventId}/occurrence`, + { + method: "DELETE", + body: JSON.stringify({ recurrence_id: recurrenceId }) + } + ); +} + +export function getCalendarViewPreferences(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/calendar/preferences/view"); +} + +export function updateCalendarViewPreferences( + settings: ApiSettings, + payload: CalendarViewPreferencesUpdatePayload +): Promise { + return apiFetch( + settings, + "/api/v1/calendar/preferences/view", + { method: "PATCH", body: JSON.stringify(payload) } + ); +} diff --git a/webui/src/features/calendar/CalendarEventDialog.tsx b/webui/src/features/calendar/CalendarEventDialog.tsx index c61e904..e56c882 100644 --- a/webui/src/features/calendar/CalendarEventDialog.tsx +++ b/webui/src/features/calendar/CalendarEventDialog.tsx @@ -8,6 +8,7 @@ import { Button, DateField, Dialog, + SegmentedControl, TimeField, ToggleSwitch, useUnsavedDraftGuard, @@ -30,6 +31,7 @@ import { } from "./calendarViewModel"; type CalendarEventEndMode = "end" | "duration"; +type CalendarEventEditScope = "occurrence" | "series"; type CalendarEventAdvancedPayload = Pick< CalendarEventCreatePayload, | "organizer" @@ -49,25 +51,40 @@ export function CalendarEventDialog({ calendars, defaultCalendarId, event, + editScope, + canChooseSeries, + seriesLoaded, focusDate, saving, canWrite, canDelete, + onEditScopeChange, onCancel, onSave, onDelete - - - - - - - - - - - -}: {calendars: CalendarCollection[];defaultCalendarId: string;event: CalendarEvent | null;focusDate: Date;saving: boolean;canWrite: boolean;canDelete: boolean;onCancel: () => void;onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise;onDelete: (event: CalendarEvent) => Promise;}) { +}: { + calendars: CalendarCollection[]; + defaultCalendarId: string; + event: CalendarEvent | null; + editScope: CalendarEventEditScope; + canChooseSeries: boolean; + seriesLoaded: boolean; + focusDate: Date; + saving: boolean; + canWrite: boolean; + canDelete: boolean; + onEditScopeChange: (scope: CalendarEventEditScope) => void; + onCancel: () => void; + onSave: ( + payload: CalendarEventCreatePayload, + event: CalendarEvent | null, + editScope: CalendarEventEditScope + ) => Promise; + onDelete: ( + event: CalendarEvent, + editScope: CalendarEventEditScope + ) => Promise; +}) { const initialStart = event ? new Date(event.start_at) : focusDate; const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1); const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd; @@ -231,7 +248,7 @@ export function CalendarEventDialog({ if (uid.trim()) payload.uid = uid.trim(); if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim(); } - return onSave(payload, event); + return onSave(payload, event, editScope); } function submit(formEvent: FormEvent) { @@ -245,7 +262,7 @@ export function CalendarEventDialog({ setConfirmingDelete(true); return; } - void onDelete(event); + void onDelete(event, editScope); } return ( @@ -273,6 +290,24 @@ export function CalendarEventDialog({ }>
+ {canChooseSeries && ( + + )} {formError &&

{formError}

}