Complete recurrence editing and calendar preferences
This commit is contained in:
@@ -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"<t:Mailbox><t:EmailAddress>{xml_escape(str(mailbox))}</t:EmailAddress></t:Mailbox>"
|
||||
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="{EWS_SOAP_NS}" xmlns:m="{EWS_MESSAGES_NS}" xmlns:t="{EWS_TYPES_NS}">
|
||||
<s:Header>
|
||||
<t:RequestServerVersion Version="Exchange2013_SP1" />
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<m:FindItem Traversal="Shallow">
|
||||
<m:ItemShape>
|
||||
<t:BaseShape>AllProperties</t:BaseShape>
|
||||
</m:ItemShape>
|
||||
<m:CalendarView StartDate="{start_text}" EndDate="{end_text}" />
|
||||
<m:ParentFolderIds>
|
||||
<t:DistinguishedFolderId Id="calendar">{mailbox_xml}</t:DistinguishedFolderId>
|
||||
</m:ParentFolderIds>
|
||||
</m:FindItem>
|
||||
</s:Body>
|
||||
</s:Envelope>"""
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user