Complete recurrence editing and calendar preferences
This commit is contained in:
@@ -65,10 +65,12 @@ and the user must sync before retrying. A calendar-owned task,
|
|||||||
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
|
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
|
||||||
cron-style scheduler.
|
cron-style scheduler.
|
||||||
|
|
||||||
This is not yet a full CalDAV network server. Scheduling inbox/outbox behavior,
|
Calendar exposes collection and credential management, sync status/actions,
|
||||||
CalDAV server endpoints, UI credential management, and full user-facing
|
durable per-user view preferences, recurrence expansion, detached occurrence
|
||||||
recurrence editing remain follow-up work. The backend now includes a free/busy
|
overrides, instance/series editing, and a free/busy API primitive for scheduling
|
||||||
API primitive for scheduling and appointment modules.
|
and appointment modules. It is not yet a CalDAV network server: external clients
|
||||||
|
cannot use GovOPlaN itself as their CalDAV endpoint, and scheduling inbox/outbox
|
||||||
|
delivery remains owned by the scheduling and mail integration work.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- calendar collections
|
- calendar collections
|
||||||
- VEVENT storage and iCalendar import/export
|
- VEVENT storage and iCalendar import/export
|
||||||
- event recurrence data, recurrence exceptions, and future recurrence expansion
|
- event recurrence data, recurrence expansion, and recurrence exceptions
|
||||||
- availability and free/busy semantics
|
- availability and free/busy semantics
|
||||||
- resources such as rooms, shared equipment, and service desks
|
- resources such as rooms, shared equipment, and service desks
|
||||||
- groupware calendar adapter boundaries, including CalDAV and Open-Xchange
|
- groupware calendar adapter boundaries, including CalDAV and Open-Xchange
|
||||||
@@ -22,7 +22,10 @@ The first standalone module provides:
|
|||||||
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
|
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
|
||||||
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
|
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
|
||||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
||||||
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
- bounded occurrence expansion with RRULE, RDATE, EXDATE, detached overrides,
|
||||||
|
instance/series editing, and free/busy reconciliation
|
||||||
|
- durable per-user calendar view preferences exposed through the platform
|
||||||
|
Settings surface
|
||||||
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
||||||
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
|
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
|
||||||
state and the exact external resource snapshot commit atomically; workers use
|
state and the exact external resource snapshot commit atomically; workers use
|
||||||
@@ -30,7 +33,10 @@ The first standalone module provides:
|
|||||||
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
||||||
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
||||||
|
|
||||||
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
|
The implementation is not yet a full CalDAV network server. It is the internal
|
||||||
|
calendar storage, recurrence, sync, availability, and UI foundation on which
|
||||||
|
Open-Xchange integration, scheduling inbox/outbox behavior, and CalDAV server
|
||||||
|
endpoints can be built.
|
||||||
|
|
||||||
## Outbound delivery operations
|
## Outbound delivery operations
|
||||||
|
|
||||||
@@ -197,9 +203,6 @@ The connector boundary should hand calendar a configured profile and credentials
|
|||||||
|
|
||||||
## Follow-Up Work
|
## Follow-Up Work
|
||||||
|
|
||||||
- Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
|
||||||
- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
|
||||||
- UI management for CalDAV credentials, sync status, sync direction, and conflict resolution.
|
|
||||||
- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections.
|
- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections.
|
||||||
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
|
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
|
||||||
- Attendee RSVP workflow and mail/notification bridge.
|
- Attendee RSVP workflow and mail/notification bridge.
|
||||||
|
|||||||
@@ -94,6 +94,36 @@ class CalendarEvent(Base, TimestampMixin):
|
|||||||
calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
|
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):
|
class CalendarSyncSource(Base, TimestampMixin):
|
||||||
__tablename__ = "calendar_sync_sources"
|
__tablename__ = "calendar_sync_sources"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -215,5 +245,6 @@ __all__ = [
|
|||||||
"CalendarOutboxOperation",
|
"CalendarOutboxOperation",
|
||||||
"CalendarSyncCredential",
|
"CalendarSyncCredential",
|
||||||
"CalendarSyncSource",
|
"CalendarSyncSource",
|
||||||
|
"CalendarViewPreference",
|
||||||
"new_uuid",
|
"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
|
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:
|
def recurrence_rule(value: Any | None) -> dict[str, Any] | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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]:
|
def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"uid": event.uid,
|
"uid": event.uid,
|
||||||
"recurrence_id": format_ical_datetime(start_at),
|
"recurrence_id": occurrence_recurrence_id(start_at, event),
|
||||||
"start_at": start_at,
|
"start_at": start_at,
|
||||||
"end_at": start_at + duration if duration else None,
|
"end_at": start_at + duration if duration else None,
|
||||||
"all_day": event.all_day,
|
"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:
|
def int_or_zero(value: str | None) -> int:
|
||||||
try:
|
try:
|
||||||
return int(value or "0")
|
return int(value or "0")
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ _calendar_table_retirement_provider = drop_table_retirement_provider(
|
|||||||
calendar_models.CalendarOutboxOperation,
|
calendar_models.CalendarOutboxOperation,
|
||||||
calendar_models.CalendarSyncCredential,
|
calendar_models.CalendarSyncCredential,
|
||||||
calendar_models.CalendarSyncSource,
|
calendar_models.CalendarSyncSource,
|
||||||
|
calendar_models.CalendarViewPreference,
|
||||||
label="Calendar",
|
label="Calendar",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,6 +128,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
|||||||
CalendarOutboxOperation,
|
CalendarOutboxOperation,
|
||||||
CalendarSyncCredential,
|
CalendarSyncCredential,
|
||||||
CalendarSyncSource,
|
CalendarSyncSource,
|
||||||
|
CalendarViewPreference,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
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_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_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_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)
|
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
|
||||||
.filter(
|
.filter(
|
||||||
CalendarOutboxOperation.tenant_id == tenant_id,
|
CalendarOutboxOperation.tenant_id == tenant_id,
|
||||||
@@ -213,6 +216,13 @@ manifest = ModuleManifest(
|
|||||||
label="Upcoming events widget",
|
label="Upcoming events widget",
|
||||||
order=40,
|
order=40,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="calendar.settings.preferences",
|
||||||
|
module_id="calendar",
|
||||||
|
kind="section",
|
||||||
|
label="Calendar preferences",
|
||||||
|
order=45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -230,6 +240,7 @@ manifest = ModuleManifest(
|
|||||||
calendar_models.CalendarOutboxOperation,
|
calendar_models.CalendarOutboxOperation,
|
||||||
calendar_models.CalendarSyncCredential,
|
calendar_models.CalendarSyncCredential,
|
||||||
calendar_models.CalendarSyncSource,
|
calendar_models.CalendarSyncSource,
|
||||||
|
calendar_models.CalendarViewPreference,
|
||||||
label="Calendar",
|
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,
|
CalendarEventCreateRequest,
|
||||||
CalendarEventDeltaResponse,
|
CalendarEventDeltaResponse,
|
||||||
CalendarEventListResponse,
|
CalendarEventListResponse,
|
||||||
|
CalendarEventOccurrenceDeleteRequest,
|
||||||
|
CalendarEventOccurrenceUpdateRequest,
|
||||||
CalendarEventResponse,
|
CalendarEventResponse,
|
||||||
CalendarEventUpdateRequest,
|
CalendarEventUpdateRequest,
|
||||||
CalendarFreeBusyRequest,
|
CalendarFreeBusyRequest,
|
||||||
@@ -49,6 +51,8 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
CalendarSyncSourceSyncRequest,
|
CalendarSyncSourceSyncRequest,
|
||||||
CalendarSyncSourceSyncResponse,
|
CalendarSyncSourceSyncResponse,
|
||||||
CalendarSyncSourceUpdateRequest,
|
CalendarSyncSourceUpdateRequest,
|
||||||
|
CalendarViewPreferencesResponse,
|
||||||
|
CalendarViewPreferencesUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_calendar.backend.outbox import (
|
from govoplan_calendar.backend.outbox import (
|
||||||
calendar_outbox_operation_response,
|
calendar_outbox_operation_response,
|
||||||
@@ -75,14 +79,17 @@ from govoplan_calendar.backend.service import (
|
|||||||
delete_caldav_source,
|
delete_caldav_source,
|
||||||
delete_sync_source,
|
delete_sync_source,
|
||||||
delete_event,
|
delete_event,
|
||||||
|
delete_event_occurrence,
|
||||||
discover_caldav_calendars,
|
discover_caldav_calendars,
|
||||||
event_response,
|
event_response,
|
||||||
|
get_calendar_view_preferences,
|
||||||
get_event,
|
get_event,
|
||||||
import_ics_event,
|
import_ics_event,
|
||||||
list_freebusy,
|
list_freebusy,
|
||||||
list_caldav_sources,
|
list_caldav_sources,
|
||||||
list_calendars,
|
list_calendars,
|
||||||
list_events,
|
list_events,
|
||||||
|
list_event_occurrences,
|
||||||
list_sync_sources,
|
list_sync_sources,
|
||||||
normalize_datetime,
|
normalize_datetime,
|
||||||
sync_due_sources,
|
sync_due_sources,
|
||||||
@@ -93,6 +100,8 @@ from govoplan_calendar.backend.service import (
|
|||||||
update_caldav_source,
|
update_caldav_source,
|
||||||
update_sync_source,
|
update_sync_source,
|
||||||
update_event,
|
update_event,
|
||||||
|
update_event_occurrence,
|
||||||
|
update_calendar_view_preferences,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
|
|
||||||
@@ -743,10 +752,39 @@ def api_list_events(
|
|||||||
calendar_id: str | None = None,
|
calendar_id: str | None = None,
|
||||||
start_at: datetime | None = Query(default=None),
|
start_at: datetime | None = Query(default=None),
|
||||||
end_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),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:event:read")
|
_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)
|
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])
|
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
|
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)
|
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def api_delete_event(
|
def api_delete_event(
|
||||||
event_id: str,
|
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
|
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)
|
@router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def api_import_ics_event(
|
def api_import_ics_event(
|
||||||
payload: CalendarIcsImportRequest,
|
payload: CalendarIcsImportRequest,
|
||||||
|
|||||||
@@ -375,6 +375,16 @@ class CalendarEventUpdateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] | None = None
|
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):
|
class CalendarEventResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
@@ -409,6 +419,10 @@ class CalendarEventResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class CalendarEventListResponse(BaseModel):
|
||||||
@@ -423,6 +437,30 @@ class CalendarEventDeltaResponse(BaseModel):
|
|||||||
full: bool = False
|
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):
|
class CalendarFreeBusyRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
import re
|
||||||
@@ -11,10 +12,8 @@ import urllib.request
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Callable, Iterable
|
from typing import Any, Callable, Iterable
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
from defusedxml import ElementTree as SafeElementTree
|
from sqlalchemy import func, or_
|
||||||
from sqlalchemy import or_
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.security.outbound_http import (
|
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_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.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.db.models import (
|
||||||
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
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.runtime import get_registry
|
||||||
from govoplan_calendar.backend.schemas import (
|
from govoplan_calendar.backend.schemas import (
|
||||||
CalendarCalDavDiscoveryRequest,
|
CalendarCalDavDiscoveryRequest,
|
||||||
@@ -46,9 +63,11 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
CalendarCollectionDeleteRequest,
|
CalendarCollectionDeleteRequest,
|
||||||
CalendarCollectionUpdateRequest,
|
CalendarCollectionUpdateRequest,
|
||||||
CalendarEventCreateRequest,
|
CalendarEventCreateRequest,
|
||||||
|
CalendarEventOccurrenceUpdateRequest,
|
||||||
CalendarEventUpdateRequest,
|
CalendarEventUpdateRequest,
|
||||||
CalendarSyncSourceCreateRequest,
|
CalendarSyncSourceCreateRequest,
|
||||||
CalendarSyncSourceUpdateRequest,
|
CalendarSyncSourceUpdateRequest,
|
||||||
|
CalendarViewPreferencesUpdateRequest,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||||
from govoplan_core.db.base import utcnow
|
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"}
|
SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"}
|
||||||
READ_ONLY_SYNC_SOURCE_KINDS = {"ics", "webcal", "graph", "ews"}
|
READ_ONLY_SYNC_SOURCE_KINDS = {"ics", "webcal", "graph", "ews"}
|
||||||
GRAPH_DEFAULT_BASE_URL = "https://graph.microsoft.com/v1.0/"
|
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_MODULE_ID = "calendar"
|
||||||
CALENDAR_EVENTS_COLLECTION = "calendar.events"
|
CALENDAR_EVENTS_COLLECTION = "calendar.events"
|
||||||
CALENDAR_EVENT_RESOURCE = "calendar_event"
|
CALENDAR_EVENT_RESOURCE = "calendar_event"
|
||||||
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||||
REMOTE_SYNC_MAX_ITEMS = 10_000
|
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(
|
def calendar_credential_context(
|
||||||
@@ -3027,89 +3053,6 @@ def sync_graph_source(
|
|||||||
raise
|
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(
|
def sync_ews_source(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -3153,7 +3096,10 @@ def sync_ews_source(
|
|||||||
mailbox=metadata.get("mailbox"),
|
mailbox=metadata.get("mailbox"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
items = parse_ews_calendar_items(body)
|
items = parse_ews_calendar_items(body)
|
||||||
|
except EwsAdapterError as exc:
|
||||||
|
raise CalendarError(str(exc)) from exc
|
||||||
if len(items) > REMOTE_SYNC_MAX_ITEMS:
|
if len(items) > REMOTE_SYNC_MAX_ITEMS:
|
||||||
raise CalendarError(
|
raise CalendarError(
|
||||||
"Exchange Web Services sync exceeded the configured item limit."
|
"Exchange Web Services sync exceeded the configured item limit."
|
||||||
@@ -3349,152 +3295,6 @@ def soft_delete_unseen_source_events(
|
|||||||
return count
|
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(
|
def sync_due_sources(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -3928,6 +3728,208 @@ def list_events(
|
|||||||
return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all()
|
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(
|
def list_freebusy(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -3940,40 +3942,49 @@ def list_freebusy(
|
|||||||
range_end = normalize_datetime(end_at)
|
range_end = normalize_datetime(end_at)
|
||||||
if range_end < range_start:
|
if range_end < range_start:
|
||||||
raise CalendarError("Free/busy end must be after start")
|
raise CalendarError("Free/busy end must be after start")
|
||||||
query = session.query(CalendarEvent).filter(
|
events: list[dict[str, Any]] = []
|
||||||
CalendarEvent.tenant_id == tenant_id,
|
|
||||||
CalendarEvent.deleted_at.is_(None),
|
|
||||||
CalendarEvent.status != "CANCELLED",
|
|
||||||
CalendarEvent.transparency != "TRANSPARENT",
|
|
||||||
)
|
|
||||||
if calendar_ids:
|
if calendar_ids:
|
||||||
query = query.filter(CalendarEvent.calendar_id.in_(calendar_ids))
|
for calendar_id in dict.fromkeys(calendar_ids):
|
||||||
events = query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all()
|
events.extend(
|
||||||
busy: list[dict[str, Any]] = []
|
list_event_occurrences(
|
||||||
for event in events:
|
session,
|
||||||
if event.rrule or event.rdate:
|
tenant_id=tenant_id,
|
||||||
for occurrence in expand_event_occurrences(event, range_start, range_end):
|
calendar_id=calendar_id,
|
||||||
busy.append(freebusy_block(event, occurrence["start_at"], occurrence["end_at"], occurrence.get("recurrence_id")))
|
start_at=range_start,
|
||||||
continue
|
end_at=range_end,
|
||||||
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:
|
else:
|
||||||
busy.append(freebusy_block(event, event_start, event_end, event.recurrence_id))
|
events = list_event_occurrences(
|
||||||
return sorted(busy, key=lambda item: (item["start_at"], item["calendar_id"], item["uid"]))
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
start_at=range_start,
|
||||||
def freebusy_block(event: CalendarEvent, start_at: datetime, end_at: datetime | None, recurrence_id: str | None) -> dict[str, Any]:
|
end_at=range_end,
|
||||||
return {
|
)
|
||||||
"calendar_id": event.calendar_id,
|
busy = [
|
||||||
"event_id": event.id,
|
{
|
||||||
"uid": event.uid,
|
"calendar_id": event["calendar_id"],
|
||||||
"recurrence_id": recurrence_id,
|
"event_id": event["id"],
|
||||||
"start_at": response_datetime(start_at),
|
"uid": event["uid"],
|
||||||
"end_at": response_datetime(end_at),
|
"recurrence_id": event["recurrence_id"],
|
||||||
"all_day": event.all_day,
|
"start_at": event["start_at"],
|
||||||
"transparency": event.transparency,
|
"end_at": event["end_at"],
|
||||||
"status": event.status,
|
"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:
|
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
|
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:
|
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)
|
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)
|
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
|
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:
|
def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None:
|
||||||
event_locator = (
|
event_locator = (
|
||||||
session.query(CalendarEvent.calendar_id)
|
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,
|
for_update=True,
|
||||||
)
|
)
|
||||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
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)
|
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
|
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
|
||||||
|
|
||||||
enqueue_caldav_delete(session, source=source, event_model=event)
|
enqueue_caldav_delete(session, source=source, event_model=event)
|
||||||
event.deleted_at = utcnow()
|
for item in series_events:
|
||||||
session.flush()
|
record_calendar_event_change(
|
||||||
record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous)
|
session,
|
||||||
|
event=item,
|
||||||
|
operation="deleted",
|
||||||
|
user_id=user_id,
|
||||||
|
previous=previous_by_id[item.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def active_sync_source_for_calendar(
|
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),
|
"created_at": response_datetime(event.created_at),
|
||||||
"updated_at": response_datetime(event.updated_at),
|
"updated_at": response_datetime(event.updated_at),
|
||||||
"metadata": event.metadata_ or {},
|
"metadata": event.metadata_ or {},
|
||||||
|
"instance_id": event.id,
|
||||||
|
"series_event_id": None,
|
||||||
|
"is_occurrence": False,
|
||||||
|
"is_override": False,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from govoplan_calendar.backend.ews import (
|
||||||
|
EwsAdapterError,
|
||||||
|
ews_find_item_body,
|
||||||
|
parse_ews_calendar_items,
|
||||||
|
)
|
||||||
|
from govoplan_calendar.backend.graph import graph_event_payload
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderAdapterTests(unittest.TestCase):
|
||||||
|
def test_graph_event_fixture_maps_provider_fields(self) -> None:
|
||||||
|
payload = graph_event_payload(
|
||||||
|
{
|
||||||
|
"id": "graph-event-1",
|
||||||
|
"iCalUId": "uid-1@example.test",
|
||||||
|
"@odata.etag": 'W/"etag-1"',
|
||||||
|
"type": "occurrence",
|
||||||
|
"subject": "Planning",
|
||||||
|
"body": {"content": "Agenda"},
|
||||||
|
"start": {
|
||||||
|
"dateTime": "2026-07-08T09:00:00",
|
||||||
|
"timeZone": "Europe/Berlin",
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"dateTime": "2026-07-08T10:30:00",
|
||||||
|
"timeZone": "Europe/Berlin",
|
||||||
|
},
|
||||||
|
"organizer": {
|
||||||
|
"emailAddress": {
|
||||||
|
"name": "Ada",
|
||||||
|
"address": "ada@example.test",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"attendees": [
|
||||||
|
{
|
||||||
|
"type": "required",
|
||||||
|
"emailAddress": {
|
||||||
|
"name": "Lin",
|
||||||
|
"address": "lin@example.test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isReminderOn": True,
|
||||||
|
"reminderMinutesBeforeStart": 15,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["uid"], "uid-1@example.test")
|
||||||
|
self.assertEqual(payload["recurrence_id"], "graph-event-1")
|
||||||
|
self.assertEqual(
|
||||||
|
payload["start_at"],
|
||||||
|
datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["duration_seconds"], 5_400)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["attendees"][0]["email"],
|
||||||
|
"lin@example.test",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
payload["reminders"][0]["trigger_minutes_before"],
|
||||||
|
15,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ews_calendar_fixture_maps_item_and_escapes_mailbox(self) -> None:
|
||||||
|
response_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||||
|
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
||||||
|
<s:Body><m:FindItemResponse><m:ResponseMessages>
|
||||||
|
<m:FindItemResponseMessage ResponseClass="Success"><m:RootFolder>
|
||||||
|
<t:Items><t:CalendarItem>
|
||||||
|
<t:ItemId Id="ews-event-1" ChangeKey="ews-etag-1" />
|
||||||
|
<t:Subject>EWS item</t:Subject>
|
||||||
|
<t:Start>2026-07-08T09:00:00Z</t:Start>
|
||||||
|
<t:End>2026-07-08T10:00:00Z</t:End>
|
||||||
|
<t:IsAllDayEvent>false</t:IsAllDayEvent>
|
||||||
|
<t:UID>ews-uid-1</t:UID>
|
||||||
|
<t:RequiredAttendees><t:Attendee><t:Mailbox>
|
||||||
|
<t:Name>Ada</t:Name>
|
||||||
|
<t:EmailAddress>ada@example.test</t:EmailAddress>
|
||||||
|
</t:Mailbox></t:Attendee></t:RequiredAttendees>
|
||||||
|
</t:CalendarItem></t:Items>
|
||||||
|
</m:RootFolder></m:FindItemResponseMessage>
|
||||||
|
</m:ResponseMessages></m:FindItemResponse></s:Body>
|
||||||
|
</s:Envelope>"""
|
||||||
|
|
||||||
|
items = parse_ews_calendar_items(response_xml)
|
||||||
|
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
self.assertEqual(items[0]["href"], "ews-event-1")
|
||||||
|
self.assertEqual(items[0]["etag"], "ews-etag-1")
|
||||||
|
self.assertEqual(
|
||||||
|
items[0]["attendees"][0]["email"],
|
||||||
|
"ada@example.test",
|
||||||
|
)
|
||||||
|
request_body = ews_find_item_body(
|
||||||
|
start=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||||
|
end=datetime(2026, 8, 1, tzinfo=timezone.utc),
|
||||||
|
mailbox='ops&"<@example.test',
|
||||||
|
)
|
||||||
|
self.assertIn("ops&"<@example.test", request_body)
|
||||||
|
|
||||||
|
def test_ews_parser_rejects_invalid_xml(self) -> None:
|
||||||
|
with self.assertRaisesRegex(EwsAdapterError, "Invalid EWS"):
|
||||||
|
parse_ews_calendar_items("<not-closed>")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||||
|
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||||
|
from govoplan_calendar.backend.schemas import (
|
||||||
|
CalendarCollectionCreateRequest,
|
||||||
|
CalendarEventCreateRequest,
|
||||||
|
CalendarEventOccurrenceUpdateRequest,
|
||||||
|
CalendarViewPreferencesUpdateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_calendar.backend.service import (
|
||||||
|
CalendarError,
|
||||||
|
create_calendar,
|
||||||
|
create_event,
|
||||||
|
delete_event,
|
||||||
|
delete_event_occurrence,
|
||||||
|
get_calendar_view_preferences,
|
||||||
|
list_event_occurrences,
|
||||||
|
list_freebusy,
|
||||||
|
update_calendar_view_preferences,
|
||||||
|
update_event_occurrence,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.tenancy.scope import create_scope_tables
|
||||||
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarRecurrenceAndPreferenceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.session.add(
|
||||||
|
Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
|
||||||
|
)
|
||||||
|
self.calendar = create_calendar(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Calendar"),
|
||||||
|
)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def recurring_master(self) -> CalendarEvent:
|
||||||
|
return create_event(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarEventCreateRequest(
|
||||||
|
calendar_id=self.calendar.id,
|
||||||
|
uid="series@example.test",
|
||||||
|
summary="Planning",
|
||||||
|
start_at=datetime(
|
||||||
|
2026, 7, 8, 9, 0, tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
end_at=datetime(
|
||||||
|
2026, 7, 8, 10, 0, tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
rrule={"FREQ": "WEEKLY", "COUNT": "3"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
master = self.recurring_master()
|
||||||
|
override = update_event_occurrence(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
series_event_id=master.id,
|
||||||
|
payload=CalendarEventOccurrenceUpdateRequest(
|
||||||
|
recurrence_id="20260715T090000Z",
|
||||||
|
summary="Moved planning",
|
||||||
|
start_at=datetime(
|
||||||
|
2026, 7, 15, 13, 0, tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
end_at=datetime(
|
||||||
|
2026, 7, 15, 14, 30, tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cancelled = delete_event_occurrence(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
series_event_id=master.id,
|
||||||
|
recurrence_id="20260722T090000Z",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
events = list_event_occurrences(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[event["summary"] for event in events],
|
||||||
|
["Planning", "Moved planning"],
|
||||||
|
)
|
||||||
|
self.assertEqual(events[1]["id"], override.id)
|
||||||
|
self.assertEqual(events[1]["series_event_id"], master.id)
|
||||||
|
self.assertTrue(events[1]["is_override"])
|
||||||
|
self.assertEqual(
|
||||||
|
events[1]["start_at"],
|
||||||
|
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(cancelled.status, "CANCELLED")
|
||||||
|
|
||||||
|
busy = list_freebusy(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(len(busy), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
busy[1]["start_at"],
|
||||||
|
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_deleting_series_removes_its_overrides(self) -> None:
|
||||||
|
master = self.recurring_master()
|
||||||
|
update_event_occurrence(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
series_event_id=master.id,
|
||||||
|
payload=CalendarEventOccurrenceUpdateRequest(
|
||||||
|
recurrence_id="20260715T090000Z",
|
||||||
|
summary="Override",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_event(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
event_id=master.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
active = (
|
||||||
|
self.session.query(CalendarEvent)
|
||||||
|
.filter(CalendarEvent.deleted_at.is_(None))
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
self.assertEqual(active, 0)
|
||||||
|
|
||||||
|
def test_all_day_occurrence_retains_date_recurrence_id(self) -> None:
|
||||||
|
master = create_event(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarEventCreateRequest(
|
||||||
|
calendar_id=self.calendar.id,
|
||||||
|
uid="all-day-series@example.test",
|
||||||
|
summary="All-day planning",
|
||||||
|
start_at=datetime(
|
||||||
|
2026, 7, 8, tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
end_at=datetime(
|
||||||
|
2026, 7, 9, tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
all_day=True,
|
||||||
|
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
events = list_event_occurrences(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[event["recurrence_id"] for event in events],
|
||||||
|
["20260708", "20260715"],
|
||||||
|
)
|
||||||
|
|
||||||
|
override = update_event_occurrence(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
series_event_id=master.id,
|
||||||
|
payload=CalendarEventOccurrenceUpdateRequest(
|
||||||
|
recurrence_id=events[1]["recurrence_id"],
|
||||||
|
summary="Moved all-day planning",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(override.recurrence_id, "20260715")
|
||||||
|
|
||||||
|
def test_calendar_preferences_have_defaults_and_durable_user_overrides(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
defaults = get_calendar_view_preferences(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
self.assertTrue(defaults["dim_weekends"])
|
||||||
|
self.assertEqual(defaults["overridden_fields"], [])
|
||||||
|
|
||||||
|
updated = update_calendar_view_preferences(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=CalendarViewPreferencesUpdateRequest(
|
||||||
|
dim_weekends=False,
|
||||||
|
workday_start_hour=8,
|
||||||
|
workday_end_hour=18,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
reloaded = get_calendar_view_preferences(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(updated, reloaded)
|
||||||
|
self.assertFalse(reloaded["dim_weekends"])
|
||||||
|
self.assertEqual(reloaded["workday_start_hour"], 8)
|
||||||
|
self.assertEqual(reloaded["workday_end_hour"], 18)
|
||||||
|
self.assertEqual(
|
||||||
|
set(reloaded["overridden_fields"]),
|
||||||
|
{"dim_weekends", "workday_start_hour", "workday_end_hour"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_calendar_preferences_reject_inverted_workday(self) -> None:
|
||||||
|
with self.assertRaisesRegex(CalendarError, "end hour"):
|
||||||
|
update_calendar_view_preferences(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
payload=CalendarViewPreferencesUpdateRequest(
|
||||||
|
workday_start_hour=18,
|
||||||
|
workday_end_hour=8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -51,6 +51,10 @@ export type CalendarEvent = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
metadata?: Record<string, unknown> | null;
|
metadata?: Record<string, unknown> | null;
|
||||||
|
instance_id?: string | null;
|
||||||
|
series_event_id?: string | null;
|
||||||
|
is_occurrence?: boolean;
|
||||||
|
is_override?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
||||||
@@ -243,6 +247,29 @@ export type CalendarEventCreatePayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
|
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
|
||||||
|
export type CalendarViewPreferences = {
|
||||||
|
dim_weekends: boolean;
|
||||||
|
dim_off_hours: boolean;
|
||||||
|
workday_start_hour: number;
|
||||||
|
workday_end_hour: number;
|
||||||
|
continuous_virtualization: boolean;
|
||||||
|
continuous_overscan_weeks: number;
|
||||||
|
alternate_continuous_months: boolean;
|
||||||
|
overridden_fields: string[];
|
||||||
|
defaults: Record<string, boolean | number>;
|
||||||
|
};
|
||||||
|
export type CalendarViewPreferencesUpdatePayload = Partial<
|
||||||
|
Pick<
|
||||||
|
CalendarViewPreferences,
|
||||||
|
| "dim_weekends"
|
||||||
|
| "dim_off_hours"
|
||||||
|
| "workday_start_hour"
|
||||||
|
| "workday_end_hour"
|
||||||
|
| "continuous_virtualization"
|
||||||
|
| "continuous_overscan_weeks"
|
||||||
|
| "alternate_continuous_months"
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
|
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
|
||||||
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
||||||
@@ -320,12 +347,13 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
|
|||||||
|
|
||||||
export function listCalendarEvents(
|
export function listCalendarEvents(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
|
||||||
): Promise<CalendarEventListResponse> {
|
): Promise<CalendarEventListResponse> {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||||
if (params.start_at) search.set("start_at", params.start_at);
|
if (params.start_at) search.set("start_at", params.start_at);
|
||||||
if (params.end_at) search.set("end_at", params.end_at);
|
if (params.end_at) search.set("end_at", params.end_at);
|
||||||
|
if (params.expand_recurring) search.set("expand_recurring", "true");
|
||||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||||
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
||||||
}
|
}
|
||||||
@@ -344,6 +372,10 @@ export function listCalendarEventsDelta(
|
|||||||
return apiFetch<CalendarEventDeltaResponse>(settings, `/api/v1/calendar/events/delta${suffix}`);
|
return apiFetch<CalendarEventDeltaResponse>(settings, `/api/v1/calendar/events/delta${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCalendarEvent(settings: ApiSettings, eventId: string): Promise<CalendarEvent> {
|
||||||
|
return apiFetch<CalendarEvent>(settings, `/api/v1/calendar/events/${eventId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
||||||
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
@@ -355,3 +387,49 @@ export function updateCalendarEvent(settings: ApiSettings, eventId: string, payl
|
|||||||
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
|
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
|
||||||
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
|
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateCalendarEventOccurrence(
|
||||||
|
settings: ApiSettings,
|
||||||
|
seriesEventId: string,
|
||||||
|
recurrenceId: string,
|
||||||
|
payload: CalendarEventUpdatePayload
|
||||||
|
): Promise<CalendarEvent> {
|
||||||
|
return apiFetch<CalendarEvent>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ ...payload, recurrence_id: recurrenceId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCalendarEventOccurrence(
|
||||||
|
settings: ApiSettings,
|
||||||
|
seriesEventId: string,
|
||||||
|
recurrenceId: string
|
||||||
|
): Promise<CalendarEvent> {
|
||||||
|
return apiFetch<CalendarEvent>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ recurrence_id: recurrenceId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalendarViewPreferences(settings: ApiSettings): Promise<CalendarViewPreferences> {
|
||||||
|
return apiFetch<CalendarViewPreferences>(settings, "/api/v1/calendar/preferences/view");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCalendarViewPreferences(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: CalendarViewPreferencesUpdatePayload
|
||||||
|
): Promise<CalendarViewPreferences> {
|
||||||
|
return apiFetch<CalendarViewPreferences>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/calendar/preferences/view",
|
||||||
|
{ method: "PATCH", body: JSON.stringify(payload) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
DateField,
|
DateField,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
SegmentedControl,
|
||||||
TimeField,
|
TimeField,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
} from "./calendarViewModel";
|
} from "./calendarViewModel";
|
||||||
|
|
||||||
type CalendarEventEndMode = "end" | "duration";
|
type CalendarEventEndMode = "end" | "duration";
|
||||||
|
type CalendarEventEditScope = "occurrence" | "series";
|
||||||
type CalendarEventAdvancedPayload = Pick<
|
type CalendarEventAdvancedPayload = Pick<
|
||||||
CalendarEventCreatePayload,
|
CalendarEventCreatePayload,
|
||||||
| "organizer"
|
| "organizer"
|
||||||
@@ -49,25 +51,40 @@ export function CalendarEventDialog({
|
|||||||
calendars,
|
calendars,
|
||||||
defaultCalendarId,
|
defaultCalendarId,
|
||||||
event,
|
event,
|
||||||
|
editScope,
|
||||||
|
canChooseSeries,
|
||||||
|
seriesLoaded,
|
||||||
focusDate,
|
focusDate,
|
||||||
saving,
|
saving,
|
||||||
canWrite,
|
canWrite,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
onEditScopeChange,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete
|
onDelete
|
||||||
|
}: {
|
||||||
|
calendars: CalendarCollection[];
|
||||||
|
defaultCalendarId: string;
|
||||||
|
event: CalendarEvent | null;
|
||||||
|
editScope: CalendarEventEditScope;
|
||||||
|
canChooseSeries: boolean;
|
||||||
|
seriesLoaded: boolean;
|
||||||
|
focusDate: Date;
|
||||||
|
saving: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
}: {calendars: CalendarCollection[];defaultCalendarId: string;event: CalendarEvent | null;focusDate: Date;saving: boolean;canWrite: boolean;canDelete: boolean;onCancel: () => void;onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise<boolean>;onDelete: (event: CalendarEvent) => Promise<void>;}) {
|
onEditScopeChange: (scope: CalendarEventEditScope) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSave: (
|
||||||
|
payload: CalendarEventCreatePayload,
|
||||||
|
event: CalendarEvent | null,
|
||||||
|
editScope: CalendarEventEditScope
|
||||||
|
) => Promise<boolean>;
|
||||||
|
onDelete: (
|
||||||
|
event: CalendarEvent,
|
||||||
|
editScope: CalendarEventEditScope
|
||||||
|
) => Promise<void>;
|
||||||
|
}) {
|
||||||
const initialStart = event ? new Date(event.start_at) : focusDate;
|
const initialStart = event ? new Date(event.start_at) : focusDate;
|
||||||
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
|
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
|
||||||
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
|
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
|
||||||
@@ -231,7 +248,7 @@ export function CalendarEventDialog({
|
|||||||
if (uid.trim()) payload.uid = uid.trim();
|
if (uid.trim()) payload.uid = uid.trim();
|
||||||
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
|
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
|
||||||
}
|
}
|
||||||
return onSave(payload, event);
|
return onSave(payload, event, editScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit(formEvent: FormEvent<HTMLFormElement>) {
|
function submit(formEvent: FormEvent<HTMLFormElement>) {
|
||||||
@@ -245,7 +262,7 @@ export function CalendarEventDialog({
|
|||||||
setConfirmingDelete(true);
|
setConfirmingDelete(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void onDelete(event);
|
void onDelete(event, editScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +290,24 @@ export function CalendarEventDialog({
|
|||||||
}>
|
}>
|
||||||
|
|
||||||
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||||
|
{canChooseSeries && (
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="Recurring event edit scope"
|
||||||
|
value={editScope}
|
||||||
|
onChange={onEditScopeChange}
|
||||||
|
width="fill"
|
||||||
|
size="equal"
|
||||||
|
disabled={saving}
|
||||||
|
options={[
|
||||||
|
{ id: "occurrence", label: "This occurrence" },
|
||||||
|
{
|
||||||
|
id: "series",
|
||||||
|
label: seriesLoaded ? "Whole series" : "Loading series",
|
||||||
|
disabled: !seriesLoaded
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{formError && <p className="calendar-form-error">{formError}</p>}
|
{formError && <p className="calendar-form-error">{formError}</p>}
|
||||||
<label>
|
<label>
|
||||||
<span>i18n:govoplan-calendar.title.768e0c1c</span>
|
<span>i18n:govoplan-calendar.title.768e0c1c</span>
|
||||||
@@ -559,4 +594,3 @@ function eventUsesDuration(event: CalendarEvent): boolean {
|
|||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,17 @@ import {
|
|||||||
createSyncSource,
|
createSyncSource,
|
||||||
deleteCalendar,
|
deleteCalendar,
|
||||||
deleteCalendarEvent,
|
deleteCalendarEvent,
|
||||||
|
deleteCalendarEventOccurrence,
|
||||||
discoverCalDavCalendars,
|
discoverCalDavCalendars,
|
||||||
|
getCalendarEvent,
|
||||||
|
getCalendarViewPreferences,
|
||||||
listCalendarEvents,
|
listCalendarEvents,
|
||||||
listCalendarEventsDelta,
|
|
||||||
listCalendars,
|
listCalendars,
|
||||||
listSyncSources,
|
listSyncSources,
|
||||||
syncSyncSource,
|
syncSyncSource,
|
||||||
updateCalendar,
|
updateCalendar,
|
||||||
updateCalendarEvent,
|
updateCalendarEvent,
|
||||||
|
updateCalendarEventOccurrence,
|
||||||
updateSyncSource,
|
updateSyncSource,
|
||||||
type CalendarCalDavDiscoveryPayload,
|
type CalendarCalDavDiscoveryPayload,
|
||||||
type CalendarCollection,
|
type CalendarCollection,
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
type CalendarEvent,
|
type CalendarEvent,
|
||||||
type CalendarEventCreatePayload,
|
type CalendarEventCreatePayload,
|
||||||
type CalendarSyncSource,
|
type CalendarSyncSource,
|
||||||
|
type CalendarViewPreferences as CalendarViewPreferencesResponse,
|
||||||
} from
|
} from
|
||||||
"../../api/calendar";
|
"../../api/calendar";
|
||||||
import {
|
import {
|
||||||
@@ -66,6 +70,7 @@ import {
|
|||||||
agendaDateLabel,
|
agendaDateLabel,
|
||||||
agendaGroupsForDays,
|
agendaGroupsForDays,
|
||||||
calendarEventColorStyle,
|
calendarEventColorStyle,
|
||||||
|
calendarEventInstanceId,
|
||||||
continuousEventWindow,
|
continuousEventWindow,
|
||||||
daysBetweenCount,
|
daysBetweenCount,
|
||||||
daysForMode,
|
daysForMode,
|
||||||
@@ -76,7 +81,6 @@ import {
|
|||||||
headingForMode,
|
headingForMode,
|
||||||
loadCalendarMode,
|
loadCalendarMode,
|
||||||
loadCalendarViewPreferences,
|
loadCalendarViewPreferences,
|
||||||
mergeCalendarEventDelta,
|
|
||||||
moveEventToDay,
|
moveEventToDay,
|
||||||
moveEventToTime,
|
moveEventToTime,
|
||||||
normalizeHexColor,
|
normalizeHexColor,
|
||||||
@@ -89,10 +93,19 @@ import {
|
|||||||
type CalendarDropTarget,
|
type CalendarDropTarget,
|
||||||
type CalendarMode,
|
type CalendarMode,
|
||||||
type CalendarResizeEdge,
|
type CalendarResizeEdge,
|
||||||
|
type CalendarViewPreferences,
|
||||||
type ContinuousViewport,
|
type ContinuousViewport,
|
||||||
} from "./calendarViewModel";
|
} from "./calendarViewModel";
|
||||||
|
|
||||||
type EventDialogState = {kind: "create";} | {kind: "edit";event: CalendarEvent;};
|
type EventEditScope = "occurrence" | "series";
|
||||||
|
type EventDialogState =
|
||||||
|
| { kind: "create" }
|
||||||
|
| {
|
||||||
|
kind: "edit";
|
||||||
|
occurrence: CalendarEvent;
|
||||||
|
seriesEvent: CalendarEvent | null;
|
||||||
|
editScope: EventEditScope;
|
||||||
|
};
|
||||||
const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 };
|
const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 };
|
||||||
|
|
||||||
const modeOptions: {id: CalendarMode;label: string;}[] = [
|
const modeOptions: {id: CalendarMode;label: string;}[] = [
|
||||||
@@ -109,8 +122,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
|
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
|
||||||
const [eventCalendarId, setEventCalendarId] = useState("");
|
const [eventCalendarId, setEventCalendarId] = useState("");
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
const eventsRef = useRef<CalendarEvent[]>([]);
|
|
||||||
const eventDeltaRef = useRef<{key: string;watermark: string | null;}>({ key: "", watermark: null });
|
|
||||||
const eventRequestRef = useRef(0);
|
const eventRequestRef = useRef(0);
|
||||||
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
|
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
|
||||||
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
|
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
|
||||||
@@ -132,7 +143,9 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
|
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
|
||||||
const dragActionRef = useRef<CalendarDragAction | null>(null);
|
const dragActionRef = useRef<CalendarDragAction | null>(null);
|
||||||
|
|
||||||
const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []);
|
const [viewPreferences, setViewPreferences] = useState<CalendarViewPreferences>(
|
||||||
|
() => loadCalendarViewPreferences()
|
||||||
|
);
|
||||||
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
|
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
|
||||||
const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]);
|
const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]);
|
||||||
const syncSourceByCalendarId = useMemo(() => new Map(syncSources.map((source) => [source.calendar_id, source])), [syncSources]);
|
const syncSourceByCalendarId = useMemo(() => new Map(syncSources.map((source) => [source.calendar_id, source])), [syncSources]);
|
||||||
@@ -152,6 +165,12 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
|
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
|
||||||
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
|
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
|
||||||
const heading = headingForMode(mode, focusDate, visibleRange);
|
const heading = headingForMode(mode, focusDate, visibleRange);
|
||||||
|
const dialogEvent =
|
||||||
|
eventDialog?.kind === "edit"
|
||||||
|
? eventDialog.editScope === "series"
|
||||||
|
? eventDialog.seriesEvent
|
||||||
|
: eventDialog.occurrence
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadCalendars();
|
void loadCalendars();
|
||||||
@@ -161,11 +180,35 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
saveCalendarMode(mode);
|
saveCalendarMode(mode);
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const preferences = await getCalendarViewPreferences(settings);
|
||||||
|
if (active) setViewPreferences(calendarViewPreferences(preferences));
|
||||||
|
} catch (err) {
|
||||||
|
if (active) setError(errorText(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handlePreferenceChange = () => void load();
|
||||||
|
void load();
|
||||||
|
window.addEventListener(
|
||||||
|
"govoplan:calendar-preferences-changed",
|
||||||
|
handlePreferenceChange
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.removeEventListener(
|
||||||
|
"govoplan:calendar-preferences-changed",
|
||||||
|
handlePreferenceChange
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (calendars.length) {
|
if (calendars.length) {
|
||||||
void loadEvents();
|
void loadEvents();
|
||||||
} else {
|
} else {
|
||||||
eventsRef.current = [];
|
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
}
|
}
|
||||||
}, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
|
}, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
|
||||||
@@ -222,29 +265,17 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
setError("");
|
setError("");
|
||||||
const start = eventWindow.start.toISOString();
|
const start = eventWindow.start.toISOString();
|
||||||
const end = eventWindow.end.toISOString();
|
const end = eventWindow.end.toISOString();
|
||||||
const deltaKey = `${start}|${end}`;
|
|
||||||
try {
|
try {
|
||||||
let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null;
|
const response = await listCalendarEvents(settings, {
|
||||||
let nextEvents = eventDeltaRef.current.key === deltaKey ? eventsRef.current : [];
|
|
||||||
let response = null;
|
|
||||||
do {
|
|
||||||
response = await listCalendarEventsDelta(settings, {
|
|
||||||
start_at: start,
|
start_at: start,
|
||||||
end_at: end,
|
end_at: end,
|
||||||
since
|
expand_recurring: true
|
||||||
});
|
});
|
||||||
nextEvents = mergeCalendarEventDelta(nextEvents, response);
|
|
||||||
since = response.watermark || null;
|
|
||||||
} while (response.has_more && response.watermark);
|
|
||||||
if (requestId !== eventRequestRef.current) return;
|
if (requestId !== eventRequestRef.current) return;
|
||||||
eventsRef.current = nextEvents;
|
setEvents(response.events);
|
||||||
eventDeltaRef.current = { key: deltaKey, watermark: since };
|
|
||||||
setEvents(nextEvents);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== eventRequestRef.current) return;
|
if (requestId !== eventRequestRef.current) return;
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
eventsRef.current = [];
|
|
||||||
eventDeltaRef.current = { key: "", watermark: null };
|
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === eventRequestRef.current) setLoading(false);
|
if (requestId === eventRequestRef.current) setLoading(false);
|
||||||
@@ -352,7 +383,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
if (calendars.length > 1 || payload.event_action === "move") {
|
if (calendars.length > 1 || payload.event_action === "move") {
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
} else {
|
} else {
|
||||||
eventsRef.current = [];
|
|
||||||
setEvents([]);
|
setEvents([]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -469,15 +499,50 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
window.requestAnimationFrame(() => updateContinuousViewport(node));
|
window.requestAnimationFrame(() => updateContinuousViewport(node));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEventEditor(event: CalendarEvent) {
|
||||||
|
const recurringOccurrence = Boolean(
|
||||||
|
event.is_occurrence &&
|
||||||
|
event.series_event_id &&
|
||||||
|
event.recurrence_id
|
||||||
|
);
|
||||||
|
setEventDialog({
|
||||||
|
kind: "edit",
|
||||||
|
occurrence: event,
|
||||||
|
seriesEvent: recurringOccurrence ? null : event,
|
||||||
|
editScope: recurringOccurrence ? "occurrence" : "series"
|
||||||
|
});
|
||||||
|
if (!recurringOccurrence || !event.series_event_id) return;
|
||||||
|
const instanceId = calendarEventInstanceId(event);
|
||||||
|
void getCalendarEvent(settings, event.series_event_id)
|
||||||
|
.then((seriesEvent) => {
|
||||||
|
setEventDialog((current) =>
|
||||||
|
current?.kind === "edit" &&
|
||||||
|
calendarEventInstanceId(current.occurrence) === instanceId
|
||||||
|
? { ...current, seriesEvent }
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(errorText(err)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEventEditScope(scope: EventEditScope) {
|
||||||
|
setEventDialog((current) =>
|
||||||
|
current?.kind === "edit" &&
|
||||||
|
(scope === "occurrence" || current.seriesEvent)
|
||||||
|
? { ...current, editScope: scope }
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
|
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
|
||||||
if (!canWrite) {
|
if (!canWrite) {
|
||||||
dragEvent.preventDefault();
|
dragEvent.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dragActionRef.current = action;
|
dragActionRef.current = action;
|
||||||
setDraggingEventId(action.event.id);
|
setDraggingEventId(calendarEventInstanceId(action.event));
|
||||||
dragEvent.dataTransfer.effectAllowed = "move";
|
dragEvent.dataTransfer.effectAllowed = "move";
|
||||||
dragEvent.dataTransfer.setData("text/plain", action.event.id);
|
dragEvent.dataTransfer.setData("text/plain", calendarEventInstanceId(action.event));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
|
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
|
||||||
@@ -553,11 +618,25 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await updateCalendarEvent(settings, calendarEvent.id, {
|
const payload = {
|
||||||
start_at: next.startAt.toISOString(),
|
start_at: next.startAt.toISOString(),
|
||||||
end_at: next.endAt ? next.endAt.toISOString() : null,
|
end_at: next.endAt ? next.endAt.toISOString() : null,
|
||||||
all_day: next.allDay
|
all_day: next.allDay
|
||||||
});
|
};
|
||||||
|
if (
|
||||||
|
calendarEvent.is_occurrence &&
|
||||||
|
calendarEvent.series_event_id &&
|
||||||
|
calendarEvent.recurrence_id
|
||||||
|
) {
|
||||||
|
await updateCalendarEventOccurrence(
|
||||||
|
settings,
|
||||||
|
calendarEvent.series_event_id,
|
||||||
|
calendarEvent.recurrence_id,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await updateCalendarEvent(settings, calendarEvent.id, payload);
|
||||||
|
}
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
@@ -576,11 +655,29 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(payload: CalendarEventCreatePayload, event: CalendarEvent | null): Promise<boolean> {
|
async function handleSave(
|
||||||
|
payload: CalendarEventCreatePayload,
|
||||||
|
event: CalendarEvent | null,
|
||||||
|
editScope: EventEditScope
|
||||||
|
): Promise<boolean> {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
if (event) {
|
const currentDialog = eventDialog;
|
||||||
|
if (
|
||||||
|
event &&
|
||||||
|
currentDialog?.kind === "edit" &&
|
||||||
|
editScope === "occurrence" &&
|
||||||
|
currentDialog.occurrence.series_event_id &&
|
||||||
|
currentDialog.occurrence.recurrence_id
|
||||||
|
) {
|
||||||
|
await updateCalendarEventOccurrence(
|
||||||
|
settings,
|
||||||
|
currentDialog.occurrence.series_event_id,
|
||||||
|
currentDialog.occurrence.recurrence_id,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
} else if (event) {
|
||||||
await updateCalendarEvent(settings, event.id, payload);
|
await updateCalendarEvent(settings, event.id, payload);
|
||||||
} else {
|
} else {
|
||||||
await createCalendarEvent(settings, payload);
|
await createCalendarEvent(settings, payload);
|
||||||
@@ -600,11 +697,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(event: CalendarEvent) {
|
async function handleDelete(
|
||||||
|
event: CalendarEvent,
|
||||||
|
editScope: EventEditScope
|
||||||
|
) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
const currentDialog = eventDialog;
|
||||||
|
if (
|
||||||
|
currentDialog?.kind === "edit" &&
|
||||||
|
editScope === "occurrence" &&
|
||||||
|
currentDialog.occurrence.series_event_id &&
|
||||||
|
currentDialog.occurrence.recurrence_id
|
||||||
|
) {
|
||||||
|
await deleteCalendarEventOccurrence(
|
||||||
|
settings,
|
||||||
|
currentDialog.occurrence.series_event_id,
|
||||||
|
currentDialog.occurrence.recurrence_id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await deleteCalendarEvent(settings, event.id);
|
await deleteCalendarEvent(settings, event.id);
|
||||||
|
}
|
||||||
setEventDialog(null);
|
setEventDialog(null);
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -690,15 +804,15 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
<h3>{agendaDateLabel(group.date)}</h3>
|
<h3>{agendaDateLabel(group.date)}</h3>
|
||||||
{group.events.map((event) =>
|
{group.events.map((event) =>
|
||||||
<button
|
<button
|
||||||
key={`${group.key}-${event.id}`}
|
key={`${group.key}-${calendarEventInstanceId(event)}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={["calendar-agenda-item", hoveredEventId === event.id ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
|
className={["calendar-agenda-item", hoveredEventId === calendarEventInstanceId(event) ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
|
||||||
style={calendarEventColorStyle(calendarColorById.get(event.calendar_id))}
|
style={calendarEventColorStyle(calendarColorById.get(event.calendar_id))}
|
||||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
onMouseEnter={() => setHoveredEventId(calendarEventInstanceId(event))}
|
||||||
onMouseLeave={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
onMouseLeave={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
|
||||||
onFocus={() => setHoveredEventId(event.id)}
|
onFocus={() => setHoveredEventId(calendarEventInstanceId(event))}
|
||||||
onBlur={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
onBlur={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
|
||||||
onClick={() => setEventDialog({ kind: "edit", event })}>
|
onClick={() => openEventEditor(event)}>
|
||||||
|
|
||||||
<EventInlineLabel event={event} />
|
<EventInlineLabel event={event} />
|
||||||
</button>
|
</button>
|
||||||
@@ -779,7 +893,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
hoveredEventId={hoveredEventId}
|
hoveredEventId={hoveredEventId}
|
||||||
dropTarget={dropTarget}
|
dropTarget={dropTarget}
|
||||||
viewport={continuousViewport}
|
viewport={continuousViewport}
|
||||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
onEventSelect={openEventEditor}
|
||||||
onEventHover={setHoveredEventId}
|
onEventHover={setHoveredEventId}
|
||||||
onEventDragStart={handleEventDragStart}
|
onEventDragStart={handleEventDragStart}
|
||||||
onEventDragEnd={handleEventDragEnd}
|
onEventDragEnd={handleEventDragEnd}
|
||||||
@@ -800,7 +914,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
draggingEventId={draggingEventId}
|
draggingEventId={draggingEventId}
|
||||||
hoveredEventId={hoveredEventId}
|
hoveredEventId={hoveredEventId}
|
||||||
dropTarget={dropTarget}
|
dropTarget={dropTarget}
|
||||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
onEventSelect={openEventEditor}
|
||||||
onEventHover={setHoveredEventId}
|
onEventHover={setHoveredEventId}
|
||||||
onEventDragStart={handleEventDragStart}
|
onEventDragStart={handleEventDragStart}
|
||||||
onEventDragEnd={handleEventDragEnd}
|
onEventDragEnd={handleEventDragEnd}
|
||||||
@@ -819,7 +933,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
draggingEventId={draggingEventId}
|
draggingEventId={draggingEventId}
|
||||||
hoveredEventId={hoveredEventId}
|
hoveredEventId={hoveredEventId}
|
||||||
dropTarget={dropTarget}
|
dropTarget={dropTarget}
|
||||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
onEventSelect={openEventEditor}
|
||||||
onEventHover={setHoveredEventId}
|
onEventHover={setHoveredEventId}
|
||||||
onEventDragStart={handleEventDragStart}
|
onEventDragStart={handleEventDragStart}
|
||||||
onEventResizeDragStart={handleEventResizeDragStart}
|
onEventResizeDragStart={handleEventResizeDragStart}
|
||||||
@@ -838,13 +952,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
|
|
||||||
{eventDialog && calendars.length > 0 &&
|
{eventDialog && calendars.length > 0 &&
|
||||||
<CalendarEventDialog
|
<CalendarEventDialog
|
||||||
|
key={
|
||||||
|
eventDialog.kind === "edit"
|
||||||
|
? `${calendarEventInstanceId(eventDialog.occurrence)}:${eventDialog.editScope}`
|
||||||
|
: "new-event"
|
||||||
|
}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultCalendarId={targetCalendarId}
|
defaultCalendarId={targetCalendarId}
|
||||||
event={eventDialog.kind === "edit" ? eventDialog.event : null}
|
event={dialogEvent}
|
||||||
|
editScope={eventDialog.kind === "edit" ? eventDialog.editScope : "series"}
|
||||||
|
canChooseSeries={
|
||||||
|
eventDialog.kind === "edit" &&
|
||||||
|
Boolean(eventDialog.occurrence.is_occurrence)
|
||||||
|
}
|
||||||
|
seriesLoaded={
|
||||||
|
eventDialog.kind !== "edit" ||
|
||||||
|
eventDialog.seriesEvent !== null
|
||||||
|
}
|
||||||
focusDate={focusDate}
|
focusDate={focusDate}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
canWrite={canWrite}
|
canWrite={canWrite}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
|
onEditScopeChange={setEventEditScope}
|
||||||
onCancel={() => setEventDialog(null)}
|
onCancel={() => setEventDialog(null)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onDelete={handleDelete} />
|
onDelete={handleDelete} />
|
||||||
@@ -887,3 +1016,17 @@ function compareCalendars(left: CalendarCollection, right: CalendarCollection):
|
|||||||
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
|
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
|
||||||
return left.name.localeCompare(right.name);
|
return left.name.localeCompare(right.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calendarViewPreferences(
|
||||||
|
response: CalendarViewPreferencesResponse
|
||||||
|
): CalendarViewPreferences {
|
||||||
|
return {
|
||||||
|
dimWeekends: response.dim_weekends,
|
||||||
|
dimOffHours: response.dim_off_hours,
|
||||||
|
workdayStartHour: response.workday_start_hour,
|
||||||
|
workdayEndHour: response.workday_end_hour,
|
||||||
|
continuousVirtualization: response.continuous_virtualization,
|
||||||
|
continuousOverscanWeeks: response.continuous_overscan_weeks,
|
||||||
|
alternateContinuousMonths: response.alternate_continuous_months
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
ToggleSwitch,
|
||||||
|
type ApiSettings,
|
||||||
|
type AuthInfo
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
getCalendarViewPreferences,
|
||||||
|
updateCalendarViewPreferences,
|
||||||
|
type CalendarViewPreferences
|
||||||
|
} from "../../api/calendar";
|
||||||
|
|
||||||
|
type CalendarPreferenceDraft = Pick<
|
||||||
|
CalendarViewPreferences,
|
||||||
|
| "dim_weekends"
|
||||||
|
| "dim_off_hours"
|
||||||
|
| "workday_start_hour"
|
||||||
|
| "workday_end_hour"
|
||||||
|
| "continuous_virtualization"
|
||||||
|
| "continuous_overscan_weeks"
|
||||||
|
| "alternate_continuous_months"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const FALLBACK_DRAFT: CalendarPreferenceDraft = {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CalendarSettingsPanel({
|
||||||
|
settings,
|
||||||
|
auth
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
}) {
|
||||||
|
const [loaded, setLoaded] = useState<CalendarPreferenceDraft | null>(null);
|
||||||
|
const [draft, setDraft] = useState<CalendarPreferenceDraft>(FALLBACK_DRAFT);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [tone, setTone] = useState<"success" | "warning">("success");
|
||||||
|
const dirty = useMemo(
|
||||||
|
() => loaded !== null && JSON.stringify(loaded) !== JSON.stringify(draft),
|
||||||
|
[draft, loaded]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPreferences();
|
||||||
|
}, [auth.user.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
async function loadPreferences() {
|
||||||
|
setLoading(true);
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await getCalendarViewPreferences(settings);
|
||||||
|
const next = preferenceDraft(response);
|
||||||
|
setLoaded(next);
|
||||||
|
setDraft(next);
|
||||||
|
} catch (error) {
|
||||||
|
setTone("warning");
|
||||||
|
setMessage(errorText(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePreferences() {
|
||||||
|
if (draft.workday_end_hour <= draft.workday_start_hour) {
|
||||||
|
setTone("warning");
|
||||||
|
setMessage("Workday end must be after workday start.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
const response = await updateCalendarViewPreferences(settings, draft);
|
||||||
|
const next = preferenceDraft(response);
|
||||||
|
setLoaded(next);
|
||||||
|
setDraft(next);
|
||||||
|
setTone("success");
|
||||||
|
setMessage("Calendar preferences saved.");
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("govoplan:calendar-preferences-changed")
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setTone("warning");
|
||||||
|
setMessage(errorText(error));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabled = loading || saving;
|
||||||
|
return (
|
||||||
|
<div className="dashboard-grid settings-dashboard-grid calendar-settings-panel">
|
||||||
|
<Card title="Calendar display">
|
||||||
|
<div className="form-grid">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Dim weekends"
|
||||||
|
help="Use a quieter background for Saturday and Sunday in week views."
|
||||||
|
checked={draft.dim_weekends}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(dim_weekends) =>
|
||||||
|
setDraft((current) => ({ ...current, dim_weekends }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Dim hours outside the workday"
|
||||||
|
checked={draft.dim_off_hours}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(dim_off_hours) =>
|
||||||
|
setDraft((current) => ({ ...current, dim_off_hours }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="calendar-dialog-grid-two">
|
||||||
|
<FormField label="Workday starts">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={23}
|
||||||
|
value={draft.workday_start_hour}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
workday_start_hour: Number(event.target.value)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Workday ends">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
value={draft.workday_end_hour}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
workday_end_hour: Number(event.target.value)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title="Continuous view">
|
||||||
|
<div className="form-grid">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Virtualize distant weeks"
|
||||||
|
help="Keep the continuous calendar responsive by rendering only nearby weeks."
|
||||||
|
checked={draft.continuous_virtualization}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(continuous_virtualization) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
continuous_virtualization
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Overscan weeks"
|
||||||
|
help="Additional weeks rendered above and below the viewport."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={20}
|
||||||
|
value={draft.continuous_overscan_weeks}
|
||||||
|
disabled={disabled || !draft.continuous_virtualization}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
continuous_overscan_weeks: Number(event.target.value)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Alternate month backgrounds"
|
||||||
|
checked={draft.alternate_continuous_months}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(alternate_continuous_months) =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
alternate_continuous_months
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
disabled={disabled || !dirty}
|
||||||
|
onClick={() => void savePreferences()}
|
||||||
|
>
|
||||||
|
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{message && (
|
||||||
|
<DismissibleAlert tone={tone} resetKey={message} floating>
|
||||||
|
{message}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferenceDraft(
|
||||||
|
preferences: CalendarViewPreferences
|
||||||
|
): CalendarPreferenceDraft {
|
||||||
|
return {
|
||||||
|
dim_weekends: preferences.dim_weekends,
|
||||||
|
dim_off_hours: preferences.dim_off_hours,
|
||||||
|
workday_start_hour: preferences.workday_start_hour,
|
||||||
|
workday_end_hour: preferences.workday_end_hour,
|
||||||
|
continuous_virtualization: preferences.continuous_virtualization,
|
||||||
|
continuous_overscan_weeks: preferences.continuous_overscan_weeks,
|
||||||
|
alternate_continuous_months: preferences.alternate_continuous_months
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Calendar preferences could not be loaded.";
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
HOUR_ROW_HEIGHT,
|
HOUR_ROW_HEIGHT,
|
||||||
INITIAL_SCROLL_HOUR,
|
INITIAL_SCROLL_HOUR,
|
||||||
calendarEventColorStyle,
|
calendarEventColorStyle,
|
||||||
|
calendarEventInstanceId,
|
||||||
chunk,
|
chunk,
|
||||||
continuousVirtualWindow,
|
continuousVirtualWindow,
|
||||||
dayKey,
|
dayKey,
|
||||||
@@ -185,12 +186,12 @@ export function CalendarWeekRows({
|
|||||||
<div className="calendar-event-stack">
|
<div className="calendar-event-stack">
|
||||||
{dayEvents.slice(0, eventLimit).map((event) => (
|
{dayEvents.slice(0, eventLimit).map((event) => (
|
||||||
<CalendarEventChip
|
<CalendarEventChip
|
||||||
key={event.id}
|
key={calendarEventInstanceId(event)}
|
||||||
event={event}
|
event={event}
|
||||||
color={calendarColorById.get(event.calendar_id)}
|
color={calendarColorById.get(event.calendar_id)}
|
||||||
canDrag={canWrite}
|
canDrag={canWrite}
|
||||||
dragging={draggingEventId === event.id}
|
dragging={draggingEventId === calendarEventInstanceId(event)}
|
||||||
linkedHover={hoveredEventId === event.id}
|
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
|
||||||
onSelect={onEventSelect}
|
onSelect={onEventSelect}
|
||||||
onHover={onEventHover}
|
onHover={onEventHover}
|
||||||
onDragStart={onEventDragStart}
|
onDragStart={onEventDragStart}
|
||||||
@@ -350,13 +351,13 @@ export function CalendarTimeGrid({
|
|||||||
>
|
>
|
||||||
{day.events.map((event) => (
|
{day.events.map((event) => (
|
||||||
<CalendarEventChip
|
<CalendarEventChip
|
||||||
key={event.id}
|
key={calendarEventInstanceId(event)}
|
||||||
event={event}
|
event={event}
|
||||||
color={calendarColorById.get(event.calendar_id)}
|
color={calendarColorById.get(event.calendar_id)}
|
||||||
compact
|
compact
|
||||||
canDrag={canWrite}
|
canDrag={canWrite}
|
||||||
dragging={draggingEventId === event.id}
|
dragging={draggingEventId === calendarEventInstanceId(event)}
|
||||||
linkedHover={hoveredEventId === event.id}
|
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
|
||||||
onSelect={onEventSelect}
|
onSelect={onEventSelect}
|
||||||
onHover={onEventHover}
|
onHover={onEventHover}
|
||||||
onDragStart={onEventDragStart}
|
onDragStart={onEventDragStart}
|
||||||
@@ -503,11 +504,11 @@ function TimedDayColumn({
|
|||||||
)}
|
)}
|
||||||
{layout.items.map((item) => (
|
{layout.items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.event.id}
|
key={calendarEventInstanceId(item.event)}
|
||||||
className={[
|
className={[
|
||||||
"calendar-timed-event",
|
"calendar-timed-event",
|
||||||
draggingEventId === item.event.id ? "is-dragging" : "",
|
draggingEventId === calendarEventInstanceId(item.event) ? "is-dragging" : "",
|
||||||
hoveredEventId === item.event.id
|
hoveredEventId === calendarEventInstanceId(item.event)
|
||||||
? "is-linked-hover"
|
? "is-linked-hover"
|
||||||
: "",
|
: "",
|
||||||
]
|
]
|
||||||
@@ -518,7 +519,7 @@ function TimedDayColumn({
|
|||||||
calendarColorById.get(item.event.calendar_id),
|
calendarColorById.get(item.event.calendar_id),
|
||||||
)}
|
)}
|
||||||
draggable={canWrite}
|
draggable={canWrite}
|
||||||
onMouseEnter={() => onEventHover(item.event.id)}
|
onMouseEnter={() => onEventHover(calendarEventInstanceId(item.event))}
|
||||||
onMouseLeave={() => onEventHover("")}
|
onMouseLeave={() => onEventHover("")}
|
||||||
onDragStart={
|
onDragStart={
|
||||||
canWrite
|
canWrite
|
||||||
@@ -546,7 +547,7 @@ function TimedDayColumn({
|
|||||||
type="button"
|
type="button"
|
||||||
className="calendar-timed-event-content"
|
className="calendar-timed-event-content"
|
||||||
onClick={() => onEventSelect(item.event)}
|
onClick={() => onEventSelect(item.event)}
|
||||||
onFocus={() => onEventHover(item.event.id)}
|
onFocus={() => onEventHover(calendarEventInstanceId(item.event))}
|
||||||
onBlur={() => onEventHover("")}
|
onBlur={() => onEventHover("")}
|
||||||
title={i18nMessage(
|
title={i18nMessage(
|
||||||
"i18n:govoplan-calendar.edit_value.fad75899",
|
"i18n:govoplan-calendar.edit_value.fad75899",
|
||||||
@@ -629,9 +630,9 @@ function CalendarEventChip({
|
|||||||
style={calendarEventColorStyle(color)}
|
style={calendarEventColorStyle(color)}
|
||||||
draggable={canDrag}
|
draggable={canDrag}
|
||||||
onClick={() => onSelect(event)}
|
onClick={() => onSelect(event)}
|
||||||
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
|
onMouseEnter={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
|
||||||
onMouseLeave={onHover ? () => onHover("") : undefined}
|
onMouseLeave={onHover ? () => onHover("") : undefined}
|
||||||
onFocus={onHover ? () => onHover(event.id) : undefined}
|
onFocus={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
|
||||||
onBlur={onHover ? () => onHover("") : undefined}
|
onBlur={onHover ? () => onHover("") : undefined}
|
||||||
onDragStart={
|
onDragStart={
|
||||||
canDrag && onDragStart
|
canDrag && onDragStart
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ export default function UpcomingEventsWidget({
|
|||||||
end.setDate(end.getDate() + daysAhead);
|
end.setDate(end.getDate() + daysAhead);
|
||||||
const response = await listCalendarEvents(settings, {
|
const response = await listCalendarEvents(settings, {
|
||||||
start_at: start.toISOString(),
|
start_at: start.toISOString(),
|
||||||
end_at: end.toISOString()
|
end_at: end.toISOString(),
|
||||||
|
expand_recurring: true
|
||||||
});
|
});
|
||||||
return [...response.events]
|
return [...response.events]
|
||||||
.filter((event) => event.status !== "cancelled")
|
.filter((event) => event.status.toUpperCase() !== "CANCELLED")
|
||||||
.sort(
|
.sort(
|
||||||
(left, right) =>
|
(left, right) =>
|
||||||
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
|
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
|
||||||
@@ -57,7 +58,7 @@ export default function UpcomingEventsWidget({
|
|||||||
<DashboardWidgetList
|
<DashboardWidgetList
|
||||||
emptyText={`No events in the next ${daysAhead} days.`}
|
emptyText={`No events in the next ${daysAhead} days.`}
|
||||||
items={(events ?? []).map((event) => ({
|
items={(events ?? []).map((event) => ({
|
||||||
id: event.id,
|
id: event.instance_id || event.id,
|
||||||
title: event.summary,
|
title: event.summary,
|
||||||
detail:
|
detail:
|
||||||
showLocation && event.location ? (
|
showLocation && event.location ? (
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const MAX_TIMED_EVENT_COLUMNS = 3;
|
|||||||
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
|
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
|
||||||
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
|
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
|
||||||
"govoplan.calendar.viewPreferences";
|
"govoplan.calendar.viewPreferences";
|
||||||
const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
||||||
dimWeekends: true,
|
dimWeekends: true,
|
||||||
dimOffHours: true,
|
dimOffHours: true,
|
||||||
workdayStartHour: 6,
|
workdayStartHour: 6,
|
||||||
@@ -84,6 +84,10 @@ const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
|||||||
alternateContinuousMonths: true,
|
alternateContinuousMonths: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function calendarEventInstanceId(event: CalendarEvent): string {
|
||||||
|
return event.instance_id || event.id;
|
||||||
|
}
|
||||||
|
|
||||||
export function rangeForMode(
|
export function rangeForMode(
|
||||||
mode: CalendarMode,
|
mode: CalendarMode,
|
||||||
focusDate: Date,
|
focusDate: Date,
|
||||||
@@ -677,7 +681,7 @@ export function layoutTimedEventsForDay(
|
|||||||
}
|
}
|
||||||
if (hidden.length > 0) {
|
if (hidden.length > 0) {
|
||||||
overflows.push({
|
overflows.push({
|
||||||
key: `${dayKey(day)}-${hidden[0].event.id}-overflow`,
|
key: `${dayKey(day)}-${calendarEventInstanceId(hidden[0].event)}-overflow`,
|
||||||
top: Math.min(...hidden.map((item) => item.top)),
|
top: Math.min(...hidden.map((item) => item.top)),
|
||||||
column: overflowColumn,
|
column: overflowColumn,
|
||||||
columns: MAX_TIMED_EVENT_COLUMNS,
|
columns: MAX_TIMED_EVENT_COLUMNS,
|
||||||
|
|||||||
+28
-2
@@ -2,7 +2,8 @@ import { createElement, lazy } from "react";
|
|||||||
import type {
|
import type {
|
||||||
CalendarPickerUiCapability,
|
CalendarPickerUiCapability,
|
||||||
DashboardWidgetsUiCapability,
|
DashboardWidgetsUiCapability,
|
||||||
PlatformWebModule
|
PlatformWebModule,
|
||||||
|
SettingsSectionsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import "./styles/calendar.css";
|
import "./styles/calendar.css";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
@@ -10,6 +11,9 @@ import CalendarPicker from "./features/calendar/CalendarPicker";
|
|||||||
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
|
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
|
||||||
|
|
||||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||||
|
const CalendarSettingsPanel = lazy(
|
||||||
|
() => import("./features/calendar/CalendarSettingsPanel")
|
||||||
|
);
|
||||||
|
|
||||||
const eventRead = ["calendar:event:read"];
|
const eventRead = ["calendar:event:read"];
|
||||||
const translations = {
|
const translations = {
|
||||||
@@ -18,6 +22,20 @@ const translations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
||||||
|
const calendarSettingsSections: SettingsSectionsUiCapability = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
id: "calendar",
|
||||||
|
surfaceId: "calendar.settings.preferences",
|
||||||
|
label: "Calendar",
|
||||||
|
group: "ui",
|
||||||
|
order: 45,
|
||||||
|
anyOf: eventRead,
|
||||||
|
render: ({ settings, auth }) =>
|
||||||
|
createElement(CalendarSettingsPanel, { settings, auth })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
|
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||||
widgets: [
|
widgets: [
|
||||||
{
|
{
|
||||||
@@ -87,6 +105,13 @@ export const calendarModule: PlatformWebModule = {
|
|||||||
kind: "section",
|
kind: "section",
|
||||||
label: "Upcoming events widget",
|
label: "Upcoming events widget",
|
||||||
order: 40
|
order: 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calendar.settings.preferences",
|
||||||
|
moduleId: "calendar",
|
||||||
|
kind: "section",
|
||||||
|
label: "Calendar preferences",
|
||||||
|
order: 45
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
||||||
@@ -94,7 +119,8 @@ export const calendarModule: PlatformWebModule = {
|
|||||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
|
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"calendar.picker": calendarPicker,
|
"calendar.picker": calendarPicker,
|
||||||
"dashboard.widgets": calendarDashboardWidgets
|
"dashboard.widgets": calendarDashboardWidgets,
|
||||||
|
"settings.sections": calendarSettingsSections
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,13 +23,19 @@
|
|||||||
],
|
],
|
||||||
"react/jsx-runtime": [
|
"react/jsx-runtime": [
|
||||||
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
|
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
|
||||||
|
],
|
||||||
|
"react-router": [
|
||||||
|
"../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"../../govoplan-core/webui/src/vite-env.d.ts",
|
"../../govoplan-core/webui/src/vite-env.d.ts",
|
||||||
|
"src/module.ts",
|
||||||
"src/api/calendar.ts",
|
"src/api/calendar.ts",
|
||||||
"src/features/calendar/CalendarPage.tsx",
|
"src/features/calendar/CalendarPage.tsx",
|
||||||
|
"src/features/calendar/CalendarSettingsPanel.tsx",
|
||||||
|
"src/features/calendar/UpcomingEventsWidget.tsx",
|
||||||
"src/features/calendar/CalendarViews.tsx",
|
"src/features/calendar/CalendarViews.tsx",
|
||||||
"src/features/calendar/CalendarCollectionDialogs.tsx",
|
"src/features/calendar/CalendarCollectionDialogs.tsx",
|
||||||
"src/features/calendar/CalendarEventDialog.tsx",
|
"src/features/calendar/CalendarEventDialog.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user