Complete recurrence editing and calendar preferences
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 = (
|
||||
"<t:Mailbox><t:EmailAddress>"
|
||||
f"{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 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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
|
||||
+28
@@ -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()
|
||||
+83
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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