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
|
||||
cron-style scheduler.
|
||||
|
||||
This is not yet a full CalDAV network server. Scheduling inbox/outbox behavior,
|
||||
CalDAV server endpoints, UI credential management, and full user-facing
|
||||
recurrence editing remain follow-up work. The backend now includes a free/busy
|
||||
API primitive for scheduling and appointment modules.
|
||||
Calendar exposes collection and credential management, sync status/actions,
|
||||
durable per-user view preferences, recurrence expansion, detached occurrence
|
||||
overrides, instance/series editing, and a free/busy API primitive for scheduling
|
||||
and appointment modules. It is not yet a CalDAV network server: external clients
|
||||
cannot use GovOPlaN itself as their CalDAV endpoint, and scheduling inbox/outbox
|
||||
delivery remains owned by the scheduling and mail integration work.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- calendar collections
|
||||
- VEVENT storage and iCalendar import/export
|
||||
- event recurrence data, recurrence exceptions, and future recurrence expansion
|
||||
- event recurrence data, recurrence expansion, and recurrence exceptions
|
||||
- availability and free/busy semantics
|
||||
- resources such as rooms, shared equipment, and service desks
|
||||
- groupware calendar adapter boundaries, including CalDAV and Open-Xchange
|
||||
@@ -22,7 +22,10 @@ The first standalone module provides:
|
||||
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
|
||||
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
|
||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
||||
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
||||
- bounded occurrence expansion with RRULE, RDATE, EXDATE, detached overrides,
|
||||
instance/series editing, and free/busy reconciliation
|
||||
- durable per-user calendar view preferences exposed through the platform
|
||||
Settings surface
|
||||
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
||||
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
|
||||
state and the exact external resource snapshot commit atomically; workers use
|
||||
@@ -30,7 +33,10 @@ The first standalone module provides:
|
||||
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
||||
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
||||
|
||||
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
|
||||
The implementation is not yet a full CalDAV network server. It is the internal
|
||||
calendar storage, recurrence, sync, availability, and UI foundation on which
|
||||
Open-Xchange integration, scheduling inbox/outbox behavior, and CalDAV server
|
||||
endpoints can be built.
|
||||
|
||||
## Outbound delivery operations
|
||||
|
||||
@@ -197,9 +203,6 @@ The connector boundary should hand calendar a configured profile and credentials
|
||||
|
||||
## Follow-Up Work
|
||||
|
||||
- Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
||||
- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
||||
- UI management for CalDAV credentials, sync status, sync direction, and conflict resolution.
|
||||
- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections.
|
||||
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
|
||||
- Attendee RSVP workflow and mail/notification bridge.
|
||||
|
||||
@@ -94,6 +94,36 @@ class CalendarEvent(Base, TimestampMixin):
|
||||
calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
|
||||
|
||||
|
||||
class CalendarViewPreference(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_view_preferences"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
name="uq_calendar_view_preferences_tenant_user",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
dim_weekends: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
dim_off_hours: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
workday_start_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
workday_end_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
continuous_virtualization: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
continuous_overscan_weeks: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
alternate_continuous_months: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||
|
||||
|
||||
class CalendarSyncSource(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_sync_sources"
|
||||
__table_args__ = (
|
||||
@@ -215,5 +245,6 @@ __all__ = [
|
||||
"CalendarOutboxOperation",
|
||||
"CalendarSyncCredential",
|
||||
"CalendarSyncSource",
|
||||
"CalendarViewPreference",
|
||||
"new_uuid",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from defusedxml import ElementTree as SafeElementTree
|
||||
|
||||
|
||||
EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
|
||||
EWS_MESSAGES_NS = (
|
||||
"http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
)
|
||||
EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types"
|
||||
|
||||
|
||||
class EwsAdapterError(ValueError):
|
||||
"""Raised when an EWS response cannot be translated safely."""
|
||||
|
||||
|
||||
def ews_find_item_body(
|
||||
*,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
mailbox: Any | None = None,
|
||||
) -> str:
|
||||
start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z")
|
||||
end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z")
|
||||
mailbox_xml = ""
|
||||
if mailbox:
|
||||
mailbox_xml = (
|
||||
"<t:Mailbox><t:EmailAddress>"
|
||||
f"{xml_escape(str(mailbox))}"
|
||||
"</t:EmailAddress></t:Mailbox>"
|
||||
)
|
||||
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="{EWS_SOAP_NS}" xmlns:m="{EWS_MESSAGES_NS}" xmlns:t="{EWS_TYPES_NS}">
|
||||
<s:Header>
|
||||
<t:RequestServerVersion Version="Exchange2013_SP1" />
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<m:FindItem Traversal="Shallow">
|
||||
<m:ItemShape>
|
||||
<t:BaseShape>AllProperties</t:BaseShape>
|
||||
</m:ItemShape>
|
||||
<m:CalendarView StartDate="{start_text}" EndDate="{end_text}" />
|
||||
<m:ParentFolderIds>
|
||||
<t:DistinguishedFolderId Id="calendar">{mailbox_xml}</t:DistinguishedFolderId>
|
||||
</m:ParentFolderIds>
|
||||
</m:FindItem>
|
||||
</s:Body>
|
||||
</s:Envelope>"""
|
||||
|
||||
|
||||
def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
root = SafeElementTree.fromstring(xml_text)
|
||||
except SafeElementTree.ParseError as exc:
|
||||
raise EwsAdapterError(f"Invalid EWS response XML: {exc}") from exc
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"):
|
||||
item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId")
|
||||
href = item_id.get("Id") if item_id is not None else None
|
||||
if not href:
|
||||
continue
|
||||
change_key = (
|
||||
item_id.get("ChangeKey") if item_id is not None else None
|
||||
)
|
||||
start = parse_ews_datetime(text_of(item, "Start"))
|
||||
end_text = text_of(item, "End")
|
||||
end = parse_ews_datetime(end_text) if end_text else start
|
||||
uid = text_of(item, "UID") or href
|
||||
subject = text_of(item, "Subject") or "(Untitled event)"
|
||||
all_day = text_of(item, "IsAllDayEvent") == "true"
|
||||
free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy"
|
||||
sensitivity = text_of(item, "Sensitivity") or "Normal"
|
||||
body = item.find(f"./{{{EWS_TYPES_NS}}}Body")
|
||||
provider_payload = element_to_dict(item)
|
||||
items.append(
|
||||
{
|
||||
"href": href,
|
||||
"uid": uid,
|
||||
"recurrence_id": (
|
||||
href
|
||||
if text_of(item, "CalendarItemType")
|
||||
in {"Occurrence", "Exception"}
|
||||
else None
|
||||
),
|
||||
"sequence": int_or_default(
|
||||
text_of(item, "AppointmentSequenceNumber"),
|
||||
0,
|
||||
),
|
||||
"summary": subject,
|
||||
"description": body.text if body is not None else None,
|
||||
"location": text_of(item, "Location"),
|
||||
"status": (
|
||||
"CANCELLED"
|
||||
if text_of(item, "IsCancelled") == "true"
|
||||
else "CONFIRMED"
|
||||
),
|
||||
"transparency": (
|
||||
"TRANSPARENT"
|
||||
if free_busy.lower() == "free"
|
||||
else "OPAQUE"
|
||||
),
|
||||
"classification": (
|
||||
"PRIVATE"
|
||||
if sensitivity.lower() == "private"
|
||||
else "PUBLIC"
|
||||
),
|
||||
"start_at": start,
|
||||
"end_at": end,
|
||||
"duration_seconds": int((end - start).total_seconds()),
|
||||
"all_day": all_day,
|
||||
"timezone": "UTC",
|
||||
"organizer": ews_mailbox_record(
|
||||
item.find(
|
||||
f"./{{{EWS_TYPES_NS}}}Organizer/"
|
||||
f"{{{EWS_TYPES_NS}}}Mailbox"
|
||||
)
|
||||
),
|
||||
"attendees": ews_attendees(item),
|
||||
"categories": [
|
||||
category.text
|
||||
for category in item.findall(
|
||||
f"./{{{EWS_TYPES_NS}}}Categories/"
|
||||
f"{{{EWS_TYPES_NS}}}String"
|
||||
)
|
||||
if category.text
|
||||
],
|
||||
"rrule": None,
|
||||
"rdate": [],
|
||||
"exdate": [],
|
||||
"reminders": ews_reminders(item),
|
||||
"attachments": [],
|
||||
"related_to": [],
|
||||
"etag": change_key,
|
||||
"icalendar": {
|
||||
"component": "VEVENT",
|
||||
"schema_version": 1,
|
||||
"provider": "ews",
|
||||
"ews": provider_payload,
|
||||
},
|
||||
"metadata": {"ews": provider_payload},
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def parse_ews_datetime(value: str | None) -> datetime:
|
||||
if not value:
|
||||
return datetime.now(timezone.utc)
|
||||
return normalize_datetime(
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
)
|
||||
|
||||
|
||||
def text_of(item: Any, name: str) -> str | None:
|
||||
child = item.find(f"./{{{EWS_TYPES_NS}}}{name}")
|
||||
return child.text if child is not None else None
|
||||
|
||||
|
||||
def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None:
|
||||
if mailbox is None:
|
||||
return None
|
||||
return {
|
||||
"name": text_of(mailbox, "Name") or "",
|
||||
"email": text_of(mailbox, "EmailAddress") or "",
|
||||
"routing_type": text_of(mailbox, "RoutingType") or "",
|
||||
}
|
||||
|
||||
|
||||
def ews_attendees(item: Any) -> list[dict[str, Any]]:
|
||||
attendees: list[dict[str, Any]] = []
|
||||
for role, path in (
|
||||
("required", "RequiredAttendees"),
|
||||
("optional", "OptionalAttendees"),
|
||||
):
|
||||
for attendee in item.findall(
|
||||
f"./{{{EWS_TYPES_NS}}}{path}/"
|
||||
f"{{{EWS_TYPES_NS}}}Attendee"
|
||||
):
|
||||
mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox")
|
||||
record = ews_mailbox_record(mailbox) or {}
|
||||
record["role"] = role
|
||||
response = text_of(attendee, "ResponseType")
|
||||
if response:
|
||||
record["status"] = response
|
||||
attendees.append(record)
|
||||
return attendees
|
||||
|
||||
|
||||
def ews_reminders(item: Any) -> list[dict[str, Any]]:
|
||||
if text_of(item, "ReminderIsSet") == "true":
|
||||
minutes = int_or_default(
|
||||
text_of(item, "ReminderMinutesBeforeStart"),
|
||||
15,
|
||||
)
|
||||
return [{"action": "DISPLAY", "trigger_minutes_before": minutes}]
|
||||
return []
|
||||
|
||||
|
||||
def element_to_dict(element: Any) -> dict[str, Any]:
|
||||
tag = element.tag.rsplit("}", 1)[-1]
|
||||
children = list(element)
|
||||
result: dict[str, Any] = {"tag": tag}
|
||||
if element.attrib:
|
||||
result["attributes"] = dict(element.attrib)
|
||||
if element.text and element.text.strip():
|
||||
result["text"] = element.text.strip()
|
||||
if children:
|
||||
result["children"] = [
|
||||
element_to_dict(child) for child in children
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def int_or_default(value: str | None, default: int) -> int:
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def xml_escape(value: str) -> str:
|
||||
return (
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
def normalize_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Translate one Microsoft Graph event into the calendar event contract."""
|
||||
|
||||
start = graph_datetime(item.get("start"))
|
||||
end = graph_datetime(item.get("end"))
|
||||
all_day = bool(item.get("isAllDay"))
|
||||
uid = str(
|
||||
item.get("iCalUId")
|
||||
or item.get("uid")
|
||||
or item.get("id")
|
||||
or uuid.uuid4()
|
||||
)
|
||||
return {
|
||||
"uid": uid,
|
||||
"recurrence_id": (
|
||||
str(item.get("id"))
|
||||
if item.get("type") == "occurrence"
|
||||
else None
|
||||
),
|
||||
"sequence": int(item.get("sequence") or 0),
|
||||
"summary": str(item.get("subject") or "(Untitled event)"),
|
||||
"description": graph_body_text(item),
|
||||
"location": graph_location_text(item.get("location")),
|
||||
"status": (
|
||||
"CANCELLED" if item.get("isCancelled") else "CONFIRMED"
|
||||
),
|
||||
"transparency": (
|
||||
"TRANSPARENT"
|
||||
if item.get("showAs") in {"free", "workingElsewhere"}
|
||||
else "OPAQUE"
|
||||
),
|
||||
"classification": (
|
||||
"PRIVATE"
|
||||
if item.get("sensitivity") == "private"
|
||||
else "PUBLIC"
|
||||
),
|
||||
"start_at": start,
|
||||
"end_at": end,
|
||||
"duration_seconds": int((end - start).total_seconds()),
|
||||
"all_day": all_day,
|
||||
"timezone": (
|
||||
(item.get("start") or {}).get("timeZone")
|
||||
if isinstance(item.get("start"), dict)
|
||||
else None
|
||||
)
|
||||
or "UTC",
|
||||
"organizer": graph_party(item.get("organizer")),
|
||||
"attendees": [
|
||||
graph_party(attendee)
|
||||
for attendee in item.get("attendees") or []
|
||||
],
|
||||
"categories": [
|
||||
str(category) for category in item.get("categories") or []
|
||||
],
|
||||
"rrule": (
|
||||
item.get("recurrence")
|
||||
if isinstance(item.get("recurrence"), dict)
|
||||
else None
|
||||
),
|
||||
"rdate": [],
|
||||
"exdate": [],
|
||||
"reminders": graph_reminders(item),
|
||||
"attachments": [],
|
||||
"related_to": [],
|
||||
"etag": item.get("@odata.etag"),
|
||||
"icalendar": {
|
||||
"component": "VEVENT",
|
||||
"schema_version": 1,
|
||||
"provider": "graph",
|
||||
"graph": item,
|
||||
},
|
||||
"metadata": {"graph": item},
|
||||
}
|
||||
|
||||
|
||||
def graph_datetime(value: Any) -> datetime:
|
||||
if not isinstance(value, dict) or not value.get("dateTime"):
|
||||
return datetime.now(timezone.utc)
|
||||
raw = str(value["dateTime"]).replace("Z", "+00:00")
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
tz_name = str(value.get("timeZone") or "UTC")
|
||||
if parsed.tzinfo is None:
|
||||
try:
|
||||
parsed = parsed.replace(tzinfo=ZoneInfo(tz_name))
|
||||
except ZoneInfoNotFoundError:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return normalize_datetime(parsed)
|
||||
|
||||
|
||||
def graph_body_text(item: dict[str, Any]) -> str | None:
|
||||
body = item.get("body")
|
||||
if isinstance(body, dict) and body.get("content"):
|
||||
return str(body["content"])
|
||||
return str(item.get("bodyPreview")) if item.get("bodyPreview") else None
|
||||
|
||||
|
||||
def graph_location_text(value: Any) -> str | None:
|
||||
if isinstance(value, dict) and value.get("displayName"):
|
||||
return str(value["displayName"])
|
||||
return None
|
||||
|
||||
|
||||
def graph_party(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
email = (
|
||||
value.get("emailAddress")
|
||||
if isinstance(value.get("emailAddress"), dict)
|
||||
else value
|
||||
)
|
||||
result = {
|
||||
"name": (
|
||||
str(email.get("name") or "")
|
||||
if isinstance(email, dict)
|
||||
else ""
|
||||
),
|
||||
"email": (
|
||||
str(email.get("address") or "")
|
||||
if isinstance(email, dict)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
if value.get("type"):
|
||||
result["role"] = value["type"]
|
||||
if value.get("status"):
|
||||
result["status"] = value["status"]
|
||||
return result
|
||||
|
||||
|
||||
def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if (
|
||||
item.get("isReminderOn")
|
||||
and item.get("reminderMinutesBeforeStart") is not None
|
||||
):
|
||||
return [
|
||||
{
|
||||
"action": "DISPLAY",
|
||||
"trigger_minutes_before": int(
|
||||
item["reminderMinutesBeforeStart"]
|
||||
),
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def normalize_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
@@ -302,6 +302,30 @@ def recurrence_id_value(value: Any | None) -> str | None:
|
||||
return f"TZID={tzid}:{raw_value}" if tzid else raw_value
|
||||
|
||||
|
||||
def recurrence_id_datetime(value: str | None) -> datetime | None:
|
||||
"""Parse the canonical/raw RECURRENCE-ID forms stored by the module."""
|
||||
|
||||
if not value:
|
||||
return None
|
||||
raw_value = value
|
||||
params: dict[str, Any] = {}
|
||||
if value.startswith("TZID=") and ":" in value:
|
||||
tzid, raw_value = value.removeprefix("TZID=").split(":", 1)
|
||||
params["TZID"] = [tzid]
|
||||
parsed = parse_ical_temporal(raw_value, params)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
try:
|
||||
return normalize_datetime(datetime.fromisoformat(raw_value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def normalized_recurrence_id(value: str | None) -> str | None:
|
||||
parsed = recurrence_id_datetime(value)
|
||||
return format_ical_datetime(parsed) if parsed is not None else value
|
||||
|
||||
|
||||
def recurrence_rule(value: Any | None) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -653,13 +677,30 @@ def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: da
|
||||
def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"uid": event.uid,
|
||||
"recurrence_id": format_ical_datetime(start_at),
|
||||
"recurrence_id": occurrence_recurrence_id(start_at, event),
|
||||
"start_at": start_at,
|
||||
"end_at": start_at + duration if duration else None,
|
||||
"all_day": event.all_day,
|
||||
}
|
||||
|
||||
|
||||
def occurrence_recurrence_id(start_at: datetime, event: Any) -> str:
|
||||
start_at = normalize_datetime(start_at)
|
||||
if event.all_day:
|
||||
return start_at.strftime("%Y%m%d")
|
||||
timezone_id = getattr(event, "timezone", None)
|
||||
if timezone_id:
|
||||
try:
|
||||
local_start = start_at.astimezone(ZoneInfo(timezone_id))
|
||||
return (
|
||||
f"TZID={timezone_id}:"
|
||||
f"{local_start.strftime('%Y%m%dT%H%M%S')}"
|
||||
)
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
return format_ical_datetime(start_at)
|
||||
|
||||
|
||||
def int_or_zero(value: str | None) -> int:
|
||||
try:
|
||||
return int(value or "0")
|
||||
|
||||
@@ -35,6 +35,7 @@ _calendar_table_retirement_provider = drop_table_retirement_provider(
|
||||
calendar_models.CalendarOutboxOperation,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
calendar_models.CalendarViewPreference,
|
||||
label="Calendar",
|
||||
)
|
||||
|
||||
@@ -127,6 +128,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
CalendarOutboxOperation,
|
||||
CalendarSyncCredential,
|
||||
CalendarSyncSource,
|
||||
CalendarViewPreference,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -134,6 +136,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
|
||||
"calendar_view_preferences": session.query(CalendarViewPreference).filter(CalendarViewPreference.tenant_id == tenant_id).count(),
|
||||
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
|
||||
.filter(
|
||||
CalendarOutboxOperation.tenant_id == tenant_id,
|
||||
@@ -213,6 +216,13 @@ manifest = ModuleManifest(
|
||||
label="Upcoming events widget",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
id="calendar.settings.preferences",
|
||||
module_id="calendar",
|
||||
kind="section",
|
||||
label="Calendar preferences",
|
||||
order=45,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
@@ -230,6 +240,7 @@ manifest = ModuleManifest(
|
||||
calendar_models.CalendarOutboxOperation,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
calendar_models.CalendarViewPreference,
|
||||
label="Calendar",
|
||||
),
|
||||
),
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""Add durable per-user calendar view preferences on the development track.
|
||||
|
||||
Revision ID: b02c3d4e5f60
|
||||
Revises: af1b2c3d4e5f
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
|
||||
downgrade as _downgrade,
|
||||
)
|
||||
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
|
||||
upgrade as _upgrade,
|
||||
)
|
||||
|
||||
|
||||
revision = "b02c3d4e5f60"
|
||||
down_revision = "af1b2c3d4e5f"
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_downgrade()
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
"""Add durable per-user calendar view preferences.
|
||||
|
||||
Revision ID: b02c3d4e5f60
|
||||
Revises: af1b2c3d4e5f
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b02c3d4e5f60"
|
||||
down_revision = "af1b2c3d4e5f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"calendar_view_preferences",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("dim_weekends", sa.Boolean(), nullable=True),
|
||||
sa.Column("dim_off_hours", sa.Boolean(), nullable=True),
|
||||
sa.Column("workday_start_hour", sa.Integer(), nullable=True),
|
||||
sa.Column("workday_end_hour", sa.Integer(), nullable=True),
|
||||
sa.Column("continuous_virtualization", sa.Boolean(), nullable=True),
|
||||
sa.Column("continuous_overscan_weeks", sa.Integer(), nullable=True),
|
||||
sa.Column("alternate_continuous_months", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f(
|
||||
"fk_calendar_view_preferences_tenant_id_core_scopes"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_calendar_view_preferences_user_id_access_users"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_calendar_view_preferences"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
name="uq_calendar_view_preferences_tenant_user",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_calendar_view_preferences_tenant_id"),
|
||||
"calendar_view_preferences",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_calendar_view_preferences_user_id"),
|
||||
"calendar_view_preferences",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f("ix_calendar_view_preferences_user_id"),
|
||||
table_name="calendar_view_preferences",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_calendar_view_preferences_tenant_id"),
|
||||
table_name="calendar_view_preferences",
|
||||
)
|
||||
op.drop_table("calendar_view_preferences")
|
||||
@@ -33,6 +33,8 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarEventCreateRequest,
|
||||
CalendarEventDeltaResponse,
|
||||
CalendarEventListResponse,
|
||||
CalendarEventOccurrenceDeleteRequest,
|
||||
CalendarEventOccurrenceUpdateRequest,
|
||||
CalendarEventResponse,
|
||||
CalendarEventUpdateRequest,
|
||||
CalendarFreeBusyRequest,
|
||||
@@ -49,6 +51,8 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarSyncSourceSyncRequest,
|
||||
CalendarSyncSourceSyncResponse,
|
||||
CalendarSyncSourceUpdateRequest,
|
||||
CalendarViewPreferencesResponse,
|
||||
CalendarViewPreferencesUpdateRequest,
|
||||
)
|
||||
from govoplan_calendar.backend.outbox import (
|
||||
calendar_outbox_operation_response,
|
||||
@@ -75,14 +79,17 @@ from govoplan_calendar.backend.service import (
|
||||
delete_caldav_source,
|
||||
delete_sync_source,
|
||||
delete_event,
|
||||
delete_event_occurrence,
|
||||
discover_caldav_calendars,
|
||||
event_response,
|
||||
get_calendar_view_preferences,
|
||||
get_event,
|
||||
import_ics_event,
|
||||
list_freebusy,
|
||||
list_caldav_sources,
|
||||
list_calendars,
|
||||
list_events,
|
||||
list_event_occurrences,
|
||||
list_sync_sources,
|
||||
normalize_datetime,
|
||||
sync_due_sources,
|
||||
@@ -93,6 +100,8 @@ from govoplan_calendar.backend.service import (
|
||||
update_caldav_source,
|
||||
update_sync_source,
|
||||
update_event,
|
||||
update_event_occurrence,
|
||||
update_calendar_view_preferences,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
@@ -743,10 +752,39 @@ def api_list_events(
|
||||
calendar_id: str | None = None,
|
||||
start_at: datetime | None = Query(default=None),
|
||||
end_at: datetime | None = Query(default=None),
|
||||
expand_recurring: bool = Query(default=False),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
if expand_recurring:
|
||||
if start_at is None or end_at is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(
|
||||
"start_at and end_at are required when expanding "
|
||||
"recurring events"
|
||||
),
|
||||
)
|
||||
try:
|
||||
occurrences = list_event_occurrences(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
)
|
||||
return CalendarEventListResponse(
|
||||
events=[
|
||||
CalendarEventResponse.model_validate(item)
|
||||
for item in occurrences
|
||||
]
|
||||
)
|
||||
except CalendarError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
|
||||
return CalendarEventListResponse(events=[_event_response(event) for event in events])
|
||||
|
||||
@@ -850,6 +888,70 @@ def api_update_event(
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/events/{series_event_id}/occurrence",
|
||||
response_model=CalendarEventResponse,
|
||||
)
|
||||
def api_update_event_occurrence(
|
||||
series_event_id: str,
|
||||
payload: CalendarEventOccurrenceUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||
try:
|
||||
event = update_event_occurrence(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
series_event_id=series_event_id,
|
||||
payload=payload,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(event)
|
||||
return _event_response(event)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/events/{series_event_id}/occurrence",
|
||||
response_model=CalendarEventResponse,
|
||||
)
|
||||
def api_delete_event_occurrence(
|
||||
series_event_id: str,
|
||||
payload: CalendarEventOccurrenceDeleteRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_any_scope(
|
||||
principal,
|
||||
"calendar:event:delete",
|
||||
CALENDAR_EVENT_WRITE_SCOPE,
|
||||
)
|
||||
try:
|
||||
event = delete_event_occurrence(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
series_event_id=series_event_id,
|
||||
recurrence_id=payload.recurrence_id,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(event)
|
||||
return _event_response(event)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_event(
|
||||
event_id: str,
|
||||
@@ -866,6 +968,51 @@ def api_delete_event(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/preferences/view",
|
||||
response_model=CalendarViewPreferencesResponse,
|
||||
)
|
||||
def api_get_calendar_view_preferences(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
return CalendarViewPreferencesResponse.model_validate(
|
||||
get_calendar_view_preferences(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/preferences/view",
|
||||
response_model=CalendarViewPreferencesResponse,
|
||||
)
|
||||
def api_update_calendar_view_preferences(
|
||||
payload: CalendarViewPreferencesUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:read")
|
||||
try:
|
||||
response = update_calendar_view_preferences(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
payload=payload,
|
||||
)
|
||||
session.commit()
|
||||
return CalendarViewPreferencesResponse.model_validate(response)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
||||
def api_import_ics_event(
|
||||
payload: CalendarIcsImportRequest,
|
||||
|
||||
@@ -375,6 +375,16 @@ class CalendarEventUpdateRequest(BaseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CalendarEventOccurrenceUpdateRequest(CalendarEventUpdateRequest):
|
||||
recurrence_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CalendarEventOccurrenceDeleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
recurrence_id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CalendarEventResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
@@ -409,6 +419,10 @@ class CalendarEventResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
instance_id: str | None = None
|
||||
series_event_id: str | None = None
|
||||
is_occurrence: bool = False
|
||||
is_override: bool = False
|
||||
|
||||
|
||||
class CalendarEventListResponse(BaseModel):
|
||||
@@ -423,6 +437,30 @@ class CalendarEventDeltaResponse(BaseModel):
|
||||
full: bool = False
|
||||
|
||||
|
||||
class CalendarViewPreferencesUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
dim_weekends: bool | None = None
|
||||
dim_off_hours: bool | None = None
|
||||
workday_start_hour: int | None = Field(default=None, ge=0, le=23)
|
||||
workday_end_hour: int | None = Field(default=None, ge=1, le=24)
|
||||
continuous_virtualization: bool | None = None
|
||||
continuous_overscan_weeks: int | None = Field(default=None, ge=2, le=20)
|
||||
alternate_continuous_months: bool | None = None
|
||||
|
||||
|
||||
class CalendarViewPreferencesResponse(BaseModel):
|
||||
dim_weekends: bool
|
||||
dim_off_hours: bool
|
||||
workday_start_hour: int
|
||||
workday_end_hour: int
|
||||
continuous_virtualization: bool
|
||||
continuous_overscan_weeks: int
|
||||
alternate_continuous_months: bool
|
||||
overridden_fields: list[str] = Field(default_factory=list)
|
||||
defaults: dict[str, bool | int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarFreeBusyRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
@@ -11,10 +12,8 @@ import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable, Iterable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from defusedxml import ElementTree as SafeElementTree
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.security.outbound_http import (
|
||||
@@ -35,8 +34,26 @@ from govoplan_core.security.credential_envelopes import (
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
|
||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
||||
from govoplan_calendar.backend.db.models import (
|
||||
CalendarCollection,
|
||||
CalendarEvent,
|
||||
CalendarSyncCredential,
|
||||
CalendarSyncSource,
|
||||
CalendarViewPreference,
|
||||
)
|
||||
from govoplan_calendar.backend.ews import (
|
||||
EwsAdapterError,
|
||||
ews_find_item_body,
|
||||
parse_ews_calendar_items,
|
||||
)
|
||||
from govoplan_calendar.backend.graph import graph_event_payload
|
||||
from govoplan_calendar.backend.ical import (
|
||||
expand_event_occurrences,
|
||||
normalized_recurrence_id,
|
||||
parse_vevent,
|
||||
parse_vevents,
|
||||
recurrence_id_datetime,
|
||||
)
|
||||
from govoplan_calendar.backend.runtime import get_registry
|
||||
from govoplan_calendar.backend.schemas import (
|
||||
CalendarCalDavDiscoveryRequest,
|
||||
@@ -46,9 +63,11 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarCollectionDeleteRequest,
|
||||
CalendarCollectionUpdateRequest,
|
||||
CalendarEventCreateRequest,
|
||||
CalendarEventOccurrenceUpdateRequest,
|
||||
CalendarEventUpdateRequest,
|
||||
CalendarSyncSourceCreateRequest,
|
||||
CalendarSyncSourceUpdateRequest,
|
||||
CalendarViewPreferencesUpdateRequest,
|
||||
)
|
||||
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||
from govoplan_core.db.base import utcnow
|
||||
@@ -67,14 +86,21 @@ CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS = 900
|
||||
SYNC_SOURCE_KINDS = {"caldav", "ics", "webcal", "graph", "ews"}
|
||||
READ_ONLY_SYNC_SOURCE_KINDS = {"ics", "webcal", "graph", "ews"}
|
||||
GRAPH_DEFAULT_BASE_URL = "https://graph.microsoft.com/v1.0/"
|
||||
EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
|
||||
EWS_MESSAGES_NS = "http://schemas.microsoft.com/exchange/services/2006/messages"
|
||||
EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types"
|
||||
CALENDAR_MODULE_ID = "calendar"
|
||||
CALENDAR_EVENTS_COLLECTION = "calendar.events"
|
||||
CALENDAR_EVENT_RESOURCE = "calendar_event"
|
||||
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||
REMOTE_SYNC_MAX_ITEMS = 10_000
|
||||
MAX_OCCURRENCE_RANGE_DAYS = 400
|
||||
DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = {
|
||||
"dim_weekends": True,
|
||||
"dim_off_hours": True,
|
||||
"workday_start_hour": 6,
|
||||
"workday_end_hour": 20,
|
||||
"continuous_virtualization": True,
|
||||
"continuous_overscan_weeks": 6,
|
||||
"alternate_continuous_months": True,
|
||||
}
|
||||
|
||||
|
||||
def calendar_credential_context(
|
||||
@@ -3027,89 +3053,6 @@ def sync_graph_source(
|
||||
raise
|
||||
|
||||
|
||||
def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]:
|
||||
start = graph_datetime(item.get("start"))
|
||||
end = graph_datetime(item.get("end"))
|
||||
all_day = bool(item.get("isAllDay"))
|
||||
uid = str(item.get("iCalUId") or item.get("uid") or item.get("id") or uuid.uuid4())
|
||||
return {
|
||||
"uid": uid,
|
||||
"recurrence_id": str(item.get("id")) if item.get("type") == "occurrence" else None,
|
||||
"sequence": int(item.get("sequence") or 0),
|
||||
"summary": str(item.get("subject") or "(Untitled event)"),
|
||||
"description": graph_body_text(item),
|
||||
"location": graph_location_text(item.get("location")),
|
||||
"status": "CANCELLED" if item.get("isCancelled") else "CONFIRMED",
|
||||
"transparency": "TRANSPARENT" if item.get("showAs") in {"free", "workingElsewhere"} else "OPAQUE",
|
||||
"classification": "PRIVATE" if item.get("sensitivity") == "private" else "PUBLIC",
|
||||
"start_at": start,
|
||||
"end_at": end,
|
||||
"duration_seconds": int((end - start).total_seconds()) if start and end else None,
|
||||
"all_day": all_day,
|
||||
"timezone": ((item.get("start") or {}).get("timeZone") if isinstance(item.get("start"), dict) else None) or "UTC",
|
||||
"organizer": graph_party(item.get("organizer")),
|
||||
"attendees": [graph_party(attendee) for attendee in item.get("attendees") or []],
|
||||
"categories": [str(category) for category in item.get("categories") or []],
|
||||
"rrule": item.get("recurrence") if isinstance(item.get("recurrence"), dict) else None,
|
||||
"rdate": [],
|
||||
"exdate": [],
|
||||
"reminders": graph_reminders(item),
|
||||
"attachments": [],
|
||||
"related_to": [],
|
||||
"etag": item.get("@odata.etag"),
|
||||
"icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "graph", "graph": item},
|
||||
"metadata": {"graph": item},
|
||||
}
|
||||
|
||||
|
||||
def graph_datetime(value: Any) -> datetime:
|
||||
if not isinstance(value, dict) or not value.get("dateTime"):
|
||||
return utcnow()
|
||||
raw = str(value["dateTime"]).replace("Z", "+00:00")
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
tz_name = str(value.get("timeZone") or "UTC")
|
||||
if parsed.tzinfo is None:
|
||||
try:
|
||||
parsed = parsed.replace(tzinfo=ZoneInfo(tz_name))
|
||||
except ZoneInfoNotFoundError:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return normalize_datetime(parsed)
|
||||
|
||||
|
||||
def graph_body_text(item: dict[str, Any]) -> str | None:
|
||||
body = item.get("body")
|
||||
if isinstance(body, dict) and body.get("content"):
|
||||
return str(body["content"])
|
||||
return str(item.get("bodyPreview")) if item.get("bodyPreview") else None
|
||||
|
||||
|
||||
def graph_location_text(value: Any) -> str | None:
|
||||
if isinstance(value, dict):
|
||||
return str(value.get("displayName")) if value.get("displayName") else None
|
||||
return None
|
||||
|
||||
|
||||
def graph_party(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
email = value.get("emailAddress") if isinstance(value.get("emailAddress"), dict) else value
|
||||
result = {
|
||||
"name": str(email.get("name") or "") if isinstance(email, dict) else "",
|
||||
"email": str(email.get("address") or "") if isinstance(email, dict) else "",
|
||||
}
|
||||
if value.get("type"):
|
||||
result["role"] = value["type"]
|
||||
if value.get("status"):
|
||||
result["status"] = value["status"]
|
||||
return result
|
||||
|
||||
|
||||
def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if item.get("isReminderOn") and item.get("reminderMinutesBeforeStart") is not None:
|
||||
return [{"action": "DISPLAY", "trigger_minutes_before": int(item["reminderMinutesBeforeStart"])}]
|
||||
return []
|
||||
|
||||
|
||||
def sync_ews_source(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -3153,7 +3096,10 @@ def sync_ews_source(
|
||||
mailbox=metadata.get("mailbox"),
|
||||
),
|
||||
)
|
||||
items = parse_ews_calendar_items(body)
|
||||
try:
|
||||
items = parse_ews_calendar_items(body)
|
||||
except EwsAdapterError as exc:
|
||||
raise CalendarError(str(exc)) from exc
|
||||
if len(items) > REMOTE_SYNC_MAX_ITEMS:
|
||||
raise CalendarError(
|
||||
"Exchange Web Services sync exceeded the configured item limit."
|
||||
@@ -3349,152 +3295,6 @@ def soft_delete_unseen_source_events(
|
||||
return count
|
||||
|
||||
|
||||
def ews_find_item_body(*, start: datetime, end: datetime, mailbox: Any | None = None) -> str:
|
||||
start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z")
|
||||
end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z")
|
||||
mailbox_xml = ""
|
||||
if mailbox:
|
||||
mailbox_xml = f"<t:Mailbox><t:EmailAddress>{xml_escape(str(mailbox))}</t:EmailAddress></t:Mailbox>"
|
||||
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<s:Envelope xmlns:s="{EWS_SOAP_NS}" xmlns:m="{EWS_MESSAGES_NS}" xmlns:t="{EWS_TYPES_NS}">
|
||||
<s:Header>
|
||||
<t:RequestServerVersion Version="Exchange2013_SP1" />
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<m:FindItem Traversal="Shallow">
|
||||
<m:ItemShape>
|
||||
<t:BaseShape>AllProperties</t:BaseShape>
|
||||
</m:ItemShape>
|
||||
<m:CalendarView StartDate="{start_text}" EndDate="{end_text}" />
|
||||
<m:ParentFolderIds>
|
||||
<t:DistinguishedFolderId Id="calendar">{mailbox_xml}</t:DistinguishedFolderId>
|
||||
</m:ParentFolderIds>
|
||||
</m:FindItem>
|
||||
</s:Body>
|
||||
</s:Envelope>"""
|
||||
|
||||
|
||||
def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
root = SafeElementTree.fromstring(xml_text)
|
||||
except SafeElementTree.ParseError as exc:
|
||||
raise CalendarError(f"Invalid EWS response XML: {exc}") from exc
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"):
|
||||
item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId")
|
||||
href = item_id.get("Id") if item_id is not None else None
|
||||
if not href:
|
||||
continue
|
||||
change_key = item_id.get("ChangeKey") if item_id is not None else None
|
||||
start = parse_ews_datetime(text_of(item, "Start"))
|
||||
end = parse_ews_datetime(text_of(item, "End")) if text_of(item, "End") else start
|
||||
uid = text_of(item, "UID") or href
|
||||
subject = text_of(item, "Subject") or "(Untitled event)"
|
||||
all_day = text_of(item, "IsAllDayEvent") == "true"
|
||||
free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy"
|
||||
sensitivity = text_of(item, "Sensitivity") or "Normal"
|
||||
body = item.find(f"./{{{EWS_TYPES_NS}}}Body")
|
||||
provider_payload = element_to_dict(item)
|
||||
items.append(
|
||||
{
|
||||
"href": href,
|
||||
"uid": uid,
|
||||
"recurrence_id": href if text_of(item, "CalendarItemType") in {"Occurrence", "Exception"} else None,
|
||||
"sequence": int_or_default(text_of(item, "AppointmentSequenceNumber"), 0),
|
||||
"summary": subject,
|
||||
"description": body.text if body is not None else None,
|
||||
"location": text_of(item, "Location"),
|
||||
"status": "CANCELLED" if text_of(item, "IsCancelled") == "true" else "CONFIRMED",
|
||||
"transparency": "TRANSPARENT" if free_busy.lower() == "free" else "OPAQUE",
|
||||
"classification": "PRIVATE" if sensitivity.lower() == "private" else "PUBLIC",
|
||||
"start_at": start,
|
||||
"end_at": end,
|
||||
"duration_seconds": int((end - start).total_seconds()) if start and end else None,
|
||||
"all_day": all_day,
|
||||
"timezone": "UTC",
|
||||
"organizer": ews_mailbox_record(item.find(f"./{{{EWS_TYPES_NS}}}Organizer/{{{EWS_TYPES_NS}}}Mailbox")),
|
||||
"attendees": ews_attendees(item),
|
||||
"categories": [category.text for category in item.findall(f"./{{{EWS_TYPES_NS}}}Categories/{{{EWS_TYPES_NS}}}String") if category.text],
|
||||
"rrule": None,
|
||||
"rdate": [],
|
||||
"exdate": [],
|
||||
"reminders": ews_reminders(item),
|
||||
"attachments": [],
|
||||
"related_to": [],
|
||||
"etag": change_key,
|
||||
"icalendar": {"component": "VEVENT", "schema_version": 1, "provider": "ews", "ews": provider_payload},
|
||||
"metadata": {"ews": provider_payload},
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def parse_ews_datetime(value: str | None) -> datetime:
|
||||
if not value:
|
||||
return utcnow()
|
||||
return normalize_datetime(datetime.fromisoformat(value.replace("Z", "+00:00")))
|
||||
|
||||
|
||||
def text_of(item: Any, name: str) -> str | None:
|
||||
child = item.find(f"./{{{EWS_TYPES_NS}}}{name}")
|
||||
return child.text if child is not None else None
|
||||
|
||||
|
||||
def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None:
|
||||
if mailbox is None:
|
||||
return None
|
||||
return {
|
||||
"name": text_of(mailbox, "Name") or "",
|
||||
"email": text_of(mailbox, "EmailAddress") or "",
|
||||
"routing_type": text_of(mailbox, "RoutingType") or "",
|
||||
}
|
||||
|
||||
|
||||
def ews_attendees(item: Any) -> list[dict[str, Any]]:
|
||||
attendees: list[dict[str, Any]] = []
|
||||
for role, path in (("required", "RequiredAttendees"), ("optional", "OptionalAttendees")):
|
||||
for attendee in item.findall(f"./{{{EWS_TYPES_NS}}}{path}/{{{EWS_TYPES_NS}}}Attendee"):
|
||||
mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox")
|
||||
record = ews_mailbox_record(mailbox) or {}
|
||||
record["role"] = role
|
||||
response = text_of(attendee, "ResponseType")
|
||||
if response:
|
||||
record["status"] = response
|
||||
attendees.append(record)
|
||||
return attendees
|
||||
|
||||
|
||||
def ews_reminders(item: Any) -> list[dict[str, Any]]:
|
||||
if text_of(item, "ReminderIsSet") == "true":
|
||||
minutes = int_or_default(text_of(item, "ReminderMinutesBeforeStart"), 15)
|
||||
return [{"action": "DISPLAY", "trigger_minutes_before": minutes}]
|
||||
return []
|
||||
|
||||
|
||||
def element_to_dict(element: Any) -> dict[str, Any]:
|
||||
tag = element.tag.rsplit("}", 1)[-1]
|
||||
children = list(element)
|
||||
result: dict[str, Any] = {"tag": tag}
|
||||
if element.attrib:
|
||||
result["attributes"] = dict(element.attrib)
|
||||
if element.text and element.text.strip():
|
||||
result["text"] = element.text.strip()
|
||||
if children:
|
||||
result["children"] = [element_to_dict(child) for child in children]
|
||||
return result
|
||||
|
||||
|
||||
def int_or_default(value: str | None, default: int) -> int:
|
||||
try:
|
||||
return int(value) if value is not None else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def xml_escape(value: str) -> str:
|
||||
return value.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def sync_due_sources(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -3928,6 +3728,208 @@ def list_events(
|
||||
return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all()
|
||||
|
||||
|
||||
def list_event_occurrences(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
calendar_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return range-bounded events with recurring series fully reconciled."""
|
||||
|
||||
range_start = normalize_datetime(start_at)
|
||||
range_end = normalize_datetime(end_at)
|
||||
if range_end <= range_start:
|
||||
raise CalendarError("Event range end must be after start")
|
||||
if range_end - range_start > timedelta(days=MAX_OCCURRENCE_RANGE_DAYS):
|
||||
raise CalendarError(
|
||||
f"Expanded event ranges are limited to {MAX_OCCURRENCE_RANGE_DAYS} days"
|
||||
)
|
||||
|
||||
base_query = session.query(CalendarEvent).filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
if calendar_id:
|
||||
base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id)
|
||||
|
||||
recurring_masters = (
|
||||
base_query.filter(
|
||||
CalendarEvent.recurrence_id.is_(None),
|
||||
or_(
|
||||
CalendarEvent.rrule.is_not(None),
|
||||
func.json_array_length(CalendarEvent.rdate) > 0,
|
||||
),
|
||||
)
|
||||
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
||||
.all()
|
||||
)
|
||||
direct_events = (
|
||||
base_query.filter(
|
||||
or_(
|
||||
CalendarEvent.end_at.is_(None),
|
||||
CalendarEvent.end_at >= range_start,
|
||||
),
|
||||
CalendarEvent.start_at <= range_end,
|
||||
)
|
||||
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
series_keys = {
|
||||
(event.calendar_id, event.uid) for event in recurring_masters
|
||||
}
|
||||
overrides: list[CalendarEvent] = []
|
||||
if series_keys:
|
||||
series_uids = {uid for _calendar_id, uid in series_keys}
|
||||
overrides = [
|
||||
event
|
||||
for event in base_query.filter(
|
||||
CalendarEvent.recurrence_id.is_not(None),
|
||||
CalendarEvent.uid.in_(series_uids),
|
||||
).all()
|
||||
if (event.calendar_id, event.uid) in series_keys
|
||||
]
|
||||
|
||||
overrides_by_series: dict[
|
||||
tuple[str, str], dict[str, CalendarEvent]
|
||||
] = {}
|
||||
for override in overrides:
|
||||
recurrence_key = normalized_recurrence_id(override.recurrence_id)
|
||||
if recurrence_key:
|
||||
overrides_by_series.setdefault(
|
||||
(override.calendar_id, override.uid), {}
|
||||
)[recurrence_key] = override
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
consumed_event_ids: set[str] = set()
|
||||
recurring_master_ids = {event.id for event in recurring_masters}
|
||||
recurring_master_by_series = {
|
||||
(event.calendar_id, event.uid): event for event in recurring_masters
|
||||
}
|
||||
for master in recurring_masters:
|
||||
series_key = (master.calendar_id, master.uid)
|
||||
series_overrides = overrides_by_series.get(series_key, {})
|
||||
for occurrence in expand_event_occurrences(
|
||||
master,
|
||||
range_start,
|
||||
range_end,
|
||||
):
|
||||
occurrence_recurrence_id = str(
|
||||
occurrence.get("recurrence_id") or ""
|
||||
)
|
||||
recurrence_key = normalized_recurrence_id(
|
||||
occurrence_recurrence_id
|
||||
)
|
||||
override = (
|
||||
series_overrides.get(recurrence_key)
|
||||
if recurrence_key
|
||||
else None
|
||||
)
|
||||
if override is not None:
|
||||
consumed_event_ids.add(override.id)
|
||||
if override.status.upper() == "CANCELLED":
|
||||
continue
|
||||
if event_overlaps_range(
|
||||
override,
|
||||
range_start=range_start,
|
||||
range_end=range_end,
|
||||
):
|
||||
results.append(
|
||||
expanded_event_response(
|
||||
override,
|
||||
series_event_id=master.id,
|
||||
recurrence_id=(
|
||||
override.recurrence_id
|
||||
or occurrence_recurrence_id
|
||||
),
|
||||
is_override=True,
|
||||
)
|
||||
)
|
||||
continue
|
||||
results.append(
|
||||
expanded_event_response(
|
||||
master,
|
||||
series_event_id=master.id,
|
||||
recurrence_id=occurrence_recurrence_id,
|
||||
occurrence=occurrence,
|
||||
)
|
||||
)
|
||||
|
||||
for event in direct_events:
|
||||
if event.id in recurring_master_ids or event.id in consumed_event_ids:
|
||||
continue
|
||||
series_event_id = None
|
||||
is_override = False
|
||||
if event.recurrence_id:
|
||||
master = recurring_master_by_series.get(
|
||||
(event.calendar_id, event.uid)
|
||||
)
|
||||
if master is not None:
|
||||
series_event_id = master.id
|
||||
is_override = True
|
||||
if event.status.upper() == "CANCELLED":
|
||||
continue
|
||||
results.append(
|
||||
expanded_event_response(
|
||||
event,
|
||||
series_event_id=series_event_id,
|
||||
recurrence_id=normalized_recurrence_id(event.recurrence_id),
|
||||
is_override=is_override,
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
results,
|
||||
key=lambda item: (
|
||||
item["start_at"],
|
||||
item["calendar_id"],
|
||||
item["summary"],
|
||||
item["instance_id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def event_overlaps_range(
|
||||
event: CalendarEvent,
|
||||
*,
|
||||
range_start: datetime,
|
||||
range_end: datetime,
|
||||
) -> bool:
|
||||
event_start = normalize_datetime(event.start_at)
|
||||
event_end = (
|
||||
normalize_datetime(event.end_at)
|
||||
if event.end_at is not None
|
||||
else event_start
|
||||
)
|
||||
return event_end >= range_start and event_start <= range_end
|
||||
|
||||
|
||||
def expanded_event_response(
|
||||
event: CalendarEvent,
|
||||
*,
|
||||
series_event_id: str | None,
|
||||
recurrence_id: str | None,
|
||||
occurrence: dict[str, Any] | None = None,
|
||||
is_override: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload = event_response(event)
|
||||
if occurrence is not None:
|
||||
payload["start_at"] = response_datetime(occurrence["start_at"])
|
||||
payload["end_at"] = response_datetime(occurrence.get("end_at"))
|
||||
payload["all_day"] = bool(occurrence.get("all_day"))
|
||||
payload["recurrence_id"] = recurrence_id
|
||||
payload["series_event_id"] = series_event_id
|
||||
payload["is_occurrence"] = series_event_id is not None
|
||||
payload["is_override"] = is_override
|
||||
payload["instance_id"] = (
|
||||
f"{series_event_id}:{recurrence_id}"
|
||||
if series_event_id and recurrence_id
|
||||
else event.id
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def list_freebusy(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -3940,40 +3942,49 @@ def list_freebusy(
|
||||
range_end = normalize_datetime(end_at)
|
||||
if range_end < range_start:
|
||||
raise CalendarError("Free/busy end must be after start")
|
||||
query = session.query(CalendarEvent).filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
CalendarEvent.status != "CANCELLED",
|
||||
CalendarEvent.transparency != "TRANSPARENT",
|
||||
)
|
||||
events: list[dict[str, Any]] = []
|
||||
if calendar_ids:
|
||||
query = query.filter(CalendarEvent.calendar_id.in_(calendar_ids))
|
||||
events = query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc()).all()
|
||||
busy: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
if event.rrule or event.rdate:
|
||||
for occurrence in expand_event_occurrences(event, range_start, range_end):
|
||||
busy.append(freebusy_block(event, occurrence["start_at"], occurrence["end_at"], occurrence.get("recurrence_id")))
|
||||
continue
|
||||
event_start = normalize_datetime(event.start_at)
|
||||
event_end = normalize_datetime(event.end_at) if event.end_at else event_start
|
||||
if event_end >= range_start and event_start <= range_end:
|
||||
busy.append(freebusy_block(event, event_start, event_end, event.recurrence_id))
|
||||
return sorted(busy, key=lambda item: (item["start_at"], item["calendar_id"], item["uid"]))
|
||||
|
||||
|
||||
def freebusy_block(event: CalendarEvent, start_at: datetime, end_at: datetime | None, recurrence_id: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"calendar_id": event.calendar_id,
|
||||
"event_id": event.id,
|
||||
"uid": event.uid,
|
||||
"recurrence_id": recurrence_id,
|
||||
"start_at": response_datetime(start_at),
|
||||
"end_at": response_datetime(end_at),
|
||||
"all_day": event.all_day,
|
||||
"transparency": event.transparency,
|
||||
"status": event.status,
|
||||
}
|
||||
for calendar_id in dict.fromkeys(calendar_ids):
|
||||
events.extend(
|
||||
list_event_occurrences(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=calendar_id,
|
||||
start_at=range_start,
|
||||
end_at=range_end,
|
||||
)
|
||||
)
|
||||
else:
|
||||
events = list_event_occurrences(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
start_at=range_start,
|
||||
end_at=range_end,
|
||||
)
|
||||
busy = [
|
||||
{
|
||||
"calendar_id": event["calendar_id"],
|
||||
"event_id": event["id"],
|
||||
"uid": event["uid"],
|
||||
"recurrence_id": event["recurrence_id"],
|
||||
"start_at": event["start_at"],
|
||||
"end_at": event["end_at"],
|
||||
"all_day": event["all_day"],
|
||||
"transparency": event["transparency"],
|
||||
"status": event["status"],
|
||||
}
|
||||
for event in events
|
||||
if event["status"].upper() != "CANCELLED"
|
||||
and event["transparency"].upper() != "TRANSPARENT"
|
||||
]
|
||||
return sorted(
|
||||
busy,
|
||||
key=lambda item: (
|
||||
item["start_at"],
|
||||
item["calendar_id"],
|
||||
item["uid"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEvent:
|
||||
@@ -3987,6 +3998,78 @@ def get_event(session: Session, *, tenant_id: str, event_id: str) -> CalendarEve
|
||||
return event
|
||||
|
||||
|
||||
def get_calendar_view_preferences(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
) -> dict[str, Any]:
|
||||
preference = (
|
||||
session.query(CalendarViewPreference)
|
||||
.filter(
|
||||
CalendarViewPreference.tenant_id == tenant_id,
|
||||
CalendarViewPreference.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
effective = dict(DEFAULT_CALENDAR_VIEW_PREFERENCES)
|
||||
overridden_fields: list[str] = []
|
||||
if preference is not None:
|
||||
for field_name in DEFAULT_CALENDAR_VIEW_PREFERENCES:
|
||||
value = getattr(preference, field_name)
|
||||
if value is not None:
|
||||
effective[field_name] = value
|
||||
overridden_fields.append(field_name)
|
||||
return {
|
||||
**effective,
|
||||
"overridden_fields": overridden_fields,
|
||||
"defaults": dict(DEFAULT_CALENDAR_VIEW_PREFERENCES),
|
||||
}
|
||||
|
||||
|
||||
def update_calendar_view_preferences(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
payload: CalendarViewPreferencesUpdateRequest,
|
||||
) -> dict[str, Any]:
|
||||
preference = (
|
||||
session.query(CalendarViewPreference)
|
||||
.filter(
|
||||
CalendarViewPreference.tenant_id == tenant_id,
|
||||
CalendarViewPreference.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if preference is None:
|
||||
preference = CalendarViewPreference(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
session.add(preference)
|
||||
for field_name in payload.model_fields_set:
|
||||
setattr(preference, field_name, getattr(payload, field_name))
|
||||
|
||||
effective = dict(DEFAULT_CALENDAR_VIEW_PREFERENCES)
|
||||
for field_name in DEFAULT_CALENDAR_VIEW_PREFERENCES:
|
||||
value = getattr(preference, field_name)
|
||||
if value is not None:
|
||||
effective[field_name] = value
|
||||
if int(effective["workday_end_hour"]) <= int(
|
||||
effective["workday_start_hour"]
|
||||
):
|
||||
raise CalendarError(
|
||||
"Workday end hour must be after workday start hour"
|
||||
)
|
||||
session.flush()
|
||||
return get_calendar_view_preferences(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def create_event(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarEventCreateRequest) -> CalendarEvent:
|
||||
default_calendar = None if payload.calendar_id else get_default_calendar(session, tenant_id=tenant_id)
|
||||
calendar_id = payload.calendar_id or (default_calendar.id if default_calendar else None)
|
||||
@@ -4239,6 +4322,197 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
||||
return event
|
||||
|
||||
|
||||
def update_event_occurrence(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
series_event_id: str,
|
||||
payload: CalendarEventOccurrenceUpdateRequest,
|
||||
) -> CalendarEvent:
|
||||
master = get_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
event_id=series_event_id,
|
||||
)
|
||||
if master.recurrence_id is not None or not (master.rrule or master.rdate):
|
||||
raise CalendarError("Calendar event is not a recurring series master")
|
||||
occurrence = recurrence_occurrence(master, payload.recurrence_id)
|
||||
recurrence_id = str(occurrence["recurrence_id"])
|
||||
existing = find_event_override(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
master=master,
|
||||
recurrence_id=recurrence_id,
|
||||
)
|
||||
update_values = payload.model_dump(
|
||||
exclude_unset=True,
|
||||
exclude={"recurrence_id", "rrule", "rdate", "exdate"},
|
||||
)
|
||||
if (
|
||||
"calendar_id" in update_values
|
||||
and update_values["calendar_id"] != master.calendar_id
|
||||
):
|
||||
raise CalendarError(
|
||||
"A recurring occurrence cannot be moved to another calendar"
|
||||
)
|
||||
update_values.pop("calendar_id", None)
|
||||
update_payload = CalendarEventUpdateRequest(**update_values)
|
||||
if existing is not None:
|
||||
return update_event(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
event_id=existing.id,
|
||||
payload=update_payload,
|
||||
)
|
||||
|
||||
source = active_sync_source_for_calendar(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=master.calendar_id,
|
||||
for_update=True,
|
||||
)
|
||||
assert_sync_mutation_allowed(source)
|
||||
event = CalendarEvent(
|
||||
tenant_id=tenant_id,
|
||||
calendar_id=master.calendar_id,
|
||||
uid=master.uid,
|
||||
recurrence_id=recurrence_id,
|
||||
sequence=max(0, int(master.sequence or 0)),
|
||||
summary=master.summary,
|
||||
description=master.description,
|
||||
location=master.location,
|
||||
status=master.status,
|
||||
transparency=master.transparency,
|
||||
classification=master.classification,
|
||||
start_at=normalize_datetime(occurrence["start_at"]),
|
||||
end_at=(
|
||||
normalize_datetime(occurrence["end_at"])
|
||||
if occurrence.get("end_at") is not None
|
||||
else None
|
||||
),
|
||||
duration_seconds=master.duration_seconds,
|
||||
all_day=master.all_day,
|
||||
timezone=master.timezone,
|
||||
organizer=copy.deepcopy(master.organizer),
|
||||
attendees=copy.deepcopy(master.attendees or []),
|
||||
categories=copy.deepcopy(master.categories or []),
|
||||
rrule=None,
|
||||
rdate=[],
|
||||
exdate=[],
|
||||
reminders=copy.deepcopy(master.reminders or []),
|
||||
attachments=copy.deepcopy(master.attachments or []),
|
||||
related_to=copy.deepcopy(master.related_to or []),
|
||||
source_kind=master.source_kind,
|
||||
source_href=master.source_href,
|
||||
etag=master.etag,
|
||||
icalendar=copy.deepcopy(master.icalendar or {}),
|
||||
raw_ics=None,
|
||||
created_by_user_id=user_id,
|
||||
updated_by_user_id=user_id,
|
||||
metadata_=copy.deepcopy(master.metadata_ or {}),
|
||||
)
|
||||
_apply_event_update_values(
|
||||
event,
|
||||
user_id=user_id,
|
||||
payload=update_payload,
|
||||
)
|
||||
validate_event_time(event)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
if should_push_event_to_caldav(source=source, event=event):
|
||||
from govoplan_calendar.backend.outbox import enqueue_caldav_put
|
||||
|
||||
enqueue_caldav_put(session, source=source, event_model=event)
|
||||
record_calendar_event_change(
|
||||
session,
|
||||
event=event,
|
||||
operation="created",
|
||||
user_id=user_id,
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
def delete_event_occurrence(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
series_event_id: str,
|
||||
recurrence_id: str,
|
||||
) -> CalendarEvent:
|
||||
return update_event_occurrence(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
series_event_id=series_event_id,
|
||||
payload=CalendarEventOccurrenceUpdateRequest(
|
||||
recurrence_id=recurrence_id,
|
||||
status="CANCELLED",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def recurrence_occurrence(
|
||||
master: CalendarEvent,
|
||||
recurrence_id: str,
|
||||
) -> dict[str, Any]:
|
||||
recurrence_start = recurrence_id_datetime(recurrence_id)
|
||||
if recurrence_start is None:
|
||||
raise CalendarError("Invalid recurrence_id")
|
||||
recurrence_key = normalized_recurrence_id(recurrence_id)
|
||||
candidates = expand_event_occurrences(
|
||||
master,
|
||||
recurrence_start - timedelta(seconds=1),
|
||||
recurrence_start + timedelta(seconds=1),
|
||||
)
|
||||
occurrence = next(
|
||||
(
|
||||
item
|
||||
for item in candidates
|
||||
if normalized_recurrence_id(item.get("recurrence_id"))
|
||||
== recurrence_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if occurrence is None:
|
||||
raise CalendarError(
|
||||
"recurrence_id is not an occurrence in this event series"
|
||||
)
|
||||
return occurrence
|
||||
|
||||
|
||||
def find_event_override(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
master: CalendarEvent,
|
||||
recurrence_id: str,
|
||||
) -> CalendarEvent | None:
|
||||
recurrence_key = normalized_recurrence_id(recurrence_id)
|
||||
candidates = (
|
||||
session.query(CalendarEvent)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.calendar_id == master.calendar_id,
|
||||
CalendarEvent.uid == master.uid,
|
||||
CalendarEvent.recurrence_id.is_not(None),
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return next(
|
||||
(
|
||||
event
|
||||
for event in candidates
|
||||
if normalized_recurrence_id(event.recurrence_id)
|
||||
== recurrence_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: str | None = None) -> None:
|
||||
event_locator = (
|
||||
session.query(CalendarEvent.calendar_id)
|
||||
@@ -4258,15 +4532,43 @@ def delete_event(session: Session, *, tenant_id: str, event_id: str, user_id: st
|
||||
for_update=True,
|
||||
)
|
||||
event = get_event(session, tenant_id=tenant_id, event_id=event_id)
|
||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||
assert_sync_mutation_allowed(source)
|
||||
if should_push_event_to_caldav(source=source, event=event):
|
||||
series_events = [event]
|
||||
if event.recurrence_id is None and (event.rrule or event.rdate):
|
||||
series_events = (
|
||||
session.query(CalendarEvent)
|
||||
.filter(
|
||||
CalendarEvent.tenant_id == tenant_id,
|
||||
CalendarEvent.calendar_id == event.calendar_id,
|
||||
CalendarEvent.uid == event.uid,
|
||||
CalendarEvent.deleted_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
previous_by_id = {
|
||||
item.id: calendar_event_change_payload(
|
||||
item,
|
||||
prefix="previous_",
|
||||
)
|
||||
for item in series_events
|
||||
}
|
||||
push_delete = should_push_event_to_caldav(source=source, event=event)
|
||||
deleted_at = utcnow()
|
||||
for item in series_events:
|
||||
item.deleted_at = deleted_at
|
||||
session.flush()
|
||||
if push_delete:
|
||||
from govoplan_calendar.backend.outbox import enqueue_caldav_delete
|
||||
|
||||
enqueue_caldav_delete(session, source=source, event_model=event)
|
||||
event.deleted_at = utcnow()
|
||||
session.flush()
|
||||
record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous)
|
||||
for item in series_events:
|
||||
record_calendar_event_change(
|
||||
session,
|
||||
event=item,
|
||||
operation="deleted",
|
||||
user_id=user_id,
|
||||
previous=previous_by_id[item.id],
|
||||
)
|
||||
|
||||
|
||||
def active_sync_source_for_calendar(
|
||||
@@ -4550,4 +4852,8 @@ def event_response(event: CalendarEvent) -> dict[str, Any]:
|
||||
"created_at": response_datetime(event.created_at),
|
||||
"updated_at": response_datetime(event.updated_at),
|
||||
"metadata": event.metadata_ or {},
|
||||
"instance_id": event.id,
|
||||
"series_event_id": None,
|
||||
"is_occurrence": False,
|
||||
"is_override": False,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
updated_at: string;
|
||||
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[] };
|
||||
@@ -243,6 +247,29 @@ export type 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> {
|
||||
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
||||
@@ -320,12 +347,13 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
|
||||
|
||||
export function listCalendarEvents(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
|
||||
): Promise<CalendarEventListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||
if (params.start_at) search.set("start_at", params.start_at);
|
||||
if (params.end_at) search.set("end_at", params.end_at);
|
||||
if (params.expand_recurring) search.set("expand_recurring", "true");
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
||||
}
|
||||
@@ -344,6 +372,10 @@ export function listCalendarEventsDelta(
|
||||
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> {
|
||||
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> {
|
||||
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,
|
||||
DateField,
|
||||
Dialog,
|
||||
SegmentedControl,
|
||||
TimeField,
|
||||
ToggleSwitch,
|
||||
useUnsavedDraftGuard,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from "./calendarViewModel";
|
||||
|
||||
type CalendarEventEndMode = "end" | "duration";
|
||||
type CalendarEventEditScope = "occurrence" | "series";
|
||||
type CalendarEventAdvancedPayload = Pick<
|
||||
CalendarEventCreatePayload,
|
||||
| "organizer"
|
||||
@@ -49,25 +51,40 @@ export function CalendarEventDialog({
|
||||
calendars,
|
||||
defaultCalendarId,
|
||||
event,
|
||||
editScope,
|
||||
canChooseSeries,
|
||||
seriesLoaded,
|
||||
focusDate,
|
||||
saving,
|
||||
canWrite,
|
||||
canDelete,
|
||||
onEditScopeChange,
|
||||
onCancel,
|
||||
onSave,
|
||||
onDelete
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {calendars: CalendarCollection[];defaultCalendarId: string;event: CalendarEvent | null;focusDate: Date;saving: boolean;canWrite: boolean;canDelete: boolean;onCancel: () => void;onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise<boolean>;onDelete: (event: CalendarEvent) => Promise<void>;}) {
|
||||
}: {
|
||||
calendars: CalendarCollection[];
|
||||
defaultCalendarId: string;
|
||||
event: CalendarEvent | null;
|
||||
editScope: CalendarEventEditScope;
|
||||
canChooseSeries: boolean;
|
||||
seriesLoaded: boolean;
|
||||
focusDate: Date;
|
||||
saving: boolean;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
onEditScopeChange: (scope: CalendarEventEditScope) => void;
|
||||
onCancel: () => void;
|
||||
onSave: (
|
||||
payload: CalendarEventCreatePayload,
|
||||
event: CalendarEvent | null,
|
||||
editScope: CalendarEventEditScope
|
||||
) => Promise<boolean>;
|
||||
onDelete: (
|
||||
event: CalendarEvent,
|
||||
editScope: CalendarEventEditScope
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const initialStart = event ? new Date(event.start_at) : focusDate;
|
||||
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
|
||||
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
|
||||
@@ -231,7 +248,7 @@ export function CalendarEventDialog({
|
||||
if (uid.trim()) payload.uid = uid.trim();
|
||||
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
|
||||
}
|
||||
return onSave(payload, event);
|
||||
return onSave(payload, event, editScope);
|
||||
}
|
||||
|
||||
function submit(formEvent: FormEvent<HTMLFormElement>) {
|
||||
@@ -245,7 +262,7 @@ export function CalendarEventDialog({
|
||||
setConfirmingDelete(true);
|
||||
return;
|
||||
}
|
||||
void onDelete(event);
|
||||
void onDelete(event, editScope);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -273,6 +290,24 @@ export function CalendarEventDialog({
|
||||
}>
|
||||
|
||||
<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>}
|
||||
<label>
|
||||
<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> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,14 +26,17 @@ import {
|
||||
createSyncSource,
|
||||
deleteCalendar,
|
||||
deleteCalendarEvent,
|
||||
deleteCalendarEventOccurrence,
|
||||
discoverCalDavCalendars,
|
||||
getCalendarEvent,
|
||||
getCalendarViewPreferences,
|
||||
listCalendarEvents,
|
||||
listCalendarEventsDelta,
|
||||
listCalendars,
|
||||
listSyncSources,
|
||||
syncSyncSource,
|
||||
updateCalendar,
|
||||
updateCalendarEvent,
|
||||
updateCalendarEventOccurrence,
|
||||
updateSyncSource,
|
||||
type CalendarCalDavDiscoveryPayload,
|
||||
type CalendarCollection,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
type CalendarEvent,
|
||||
type CalendarEventCreatePayload,
|
||||
type CalendarSyncSource,
|
||||
type CalendarViewPreferences as CalendarViewPreferencesResponse,
|
||||
} from
|
||||
"../../api/calendar";
|
||||
import {
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
agendaDateLabel,
|
||||
agendaGroupsForDays,
|
||||
calendarEventColorStyle,
|
||||
calendarEventInstanceId,
|
||||
continuousEventWindow,
|
||||
daysBetweenCount,
|
||||
daysForMode,
|
||||
@@ -76,7 +81,6 @@ import {
|
||||
headingForMode,
|
||||
loadCalendarMode,
|
||||
loadCalendarViewPreferences,
|
||||
mergeCalendarEventDelta,
|
||||
moveEventToDay,
|
||||
moveEventToTime,
|
||||
normalizeHexColor,
|
||||
@@ -89,10 +93,19 @@ import {
|
||||
type CalendarDropTarget,
|
||||
type CalendarMode,
|
||||
type CalendarResizeEdge,
|
||||
type CalendarViewPreferences,
|
||||
type ContinuousViewport,
|
||||
} 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 modeOptions: {id: CalendarMode;label: string;}[] = [
|
||||
@@ -109,8 +122,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
|
||||
const [eventCalendarId, setEventCalendarId] = useState("");
|
||||
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 [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
|
||||
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 dragActionRef = useRef<CalendarDragAction | null>(null);
|
||||
|
||||
const viewPreferences = useMemo(() => loadCalendarViewPreferences(), []);
|
||||
const [viewPreferences, setViewPreferences] = useState<CalendarViewPreferences>(
|
||||
() => loadCalendarViewPreferences()
|
||||
);
|
||||
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
|
||||
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]);
|
||||
@@ -152,6 +165,12 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
|
||||
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
|
||||
const heading = headingForMode(mode, focusDate, visibleRange);
|
||||
const dialogEvent =
|
||||
eventDialog?.kind === "edit"
|
||||
? eventDialog.editScope === "series"
|
||||
? eventDialog.seriesEvent
|
||||
: eventDialog.occurrence
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
void loadCalendars();
|
||||
@@ -161,11 +180,35 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
saveCalendarMode(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(() => {
|
||||
if (calendars.length) {
|
||||
void loadEvents();
|
||||
} else {
|
||||
eventsRef.current = [];
|
||||
setEvents([]);
|
||||
}
|
||||
}, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
|
||||
@@ -222,29 +265,17 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
setError("");
|
||||
const start = eventWindow.start.toISOString();
|
||||
const end = eventWindow.end.toISOString();
|
||||
const deltaKey = `${start}|${end}`;
|
||||
try {
|
||||
let since = eventDeltaRef.current.key === deltaKey ? eventDeltaRef.current.watermark : null;
|
||||
let nextEvents = eventDeltaRef.current.key === deltaKey ? eventsRef.current : [];
|
||||
let response = null;
|
||||
do {
|
||||
response = await listCalendarEventsDelta(settings, {
|
||||
start_at: start,
|
||||
end_at: end,
|
||||
since
|
||||
});
|
||||
nextEvents = mergeCalendarEventDelta(nextEvents, response);
|
||||
since = response.watermark || null;
|
||||
} while (response.has_more && response.watermark);
|
||||
const response = await listCalendarEvents(settings, {
|
||||
start_at: start,
|
||||
end_at: end,
|
||||
expand_recurring: true
|
||||
});
|
||||
if (requestId !== eventRequestRef.current) return;
|
||||
eventsRef.current = nextEvents;
|
||||
eventDeltaRef.current = { key: deltaKey, watermark: since };
|
||||
setEvents(nextEvents);
|
||||
setEvents(response.events);
|
||||
} catch (err) {
|
||||
if (requestId !== eventRequestRef.current) return;
|
||||
setError(errorText(err));
|
||||
eventsRef.current = [];
|
||||
eventDeltaRef.current = { key: "", watermark: null };
|
||||
setEvents([]);
|
||||
} finally {
|
||||
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") {
|
||||
await loadEvents();
|
||||
} else {
|
||||
eventsRef.current = [];
|
||||
setEvents([]);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -469,15 +499,50 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
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) {
|
||||
if (!canWrite) {
|
||||
dragEvent.preventDefault();
|
||||
return;
|
||||
}
|
||||
dragActionRef.current = action;
|
||||
setDraggingEventId(action.event.id);
|
||||
setDraggingEventId(calendarEventInstanceId(action.event));
|
||||
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) {
|
||||
@@ -553,11 +618,25 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateCalendarEvent(settings, calendarEvent.id, {
|
||||
const payload = {
|
||||
start_at: next.startAt.toISOString(),
|
||||
end_at: next.endAt ? next.endAt.toISOString() : null,
|
||||
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();
|
||||
} catch (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);
|
||||
setError("");
|
||||
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);
|
||||
} else {
|
||||
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);
|
||||
setError("");
|
||||
try {
|
||||
await deleteCalendarEvent(settings, event.id);
|
||||
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);
|
||||
}
|
||||
setEventDialog(null);
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
@@ -690,15 +804,15 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
<h3>{agendaDateLabel(group.date)}</h3>
|
||||
{group.events.map((event) =>
|
||||
<button
|
||||
key={`${group.key}-${event.id}`}
|
||||
key={`${group.key}-${calendarEventInstanceId(event)}`}
|
||||
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))}
|
||||
onMouseEnter={() => setHoveredEventId(event.id)}
|
||||
onMouseLeave={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
||||
onFocus={() => setHoveredEventId(event.id)}
|
||||
onBlur={() => setHoveredEventId((current) => current === event.id ? "" : current)}
|
||||
onClick={() => setEventDialog({ kind: "edit", event })}>
|
||||
onMouseEnter={() => setHoveredEventId(calendarEventInstanceId(event))}
|
||||
onMouseLeave={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
|
||||
onFocus={() => setHoveredEventId(calendarEventInstanceId(event))}
|
||||
onBlur={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
|
||||
onClick={() => openEventEditor(event)}>
|
||||
|
||||
<EventInlineLabel event={event} />
|
||||
</button>
|
||||
@@ -779,7 +893,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
hoveredEventId={hoveredEventId}
|
||||
dropTarget={dropTarget}
|
||||
viewport={continuousViewport}
|
||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
||||
onEventSelect={openEventEditor}
|
||||
onEventHover={setHoveredEventId}
|
||||
onEventDragStart={handleEventDragStart}
|
||||
onEventDragEnd={handleEventDragEnd}
|
||||
@@ -800,7 +914,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
draggingEventId={draggingEventId}
|
||||
hoveredEventId={hoveredEventId}
|
||||
dropTarget={dropTarget}
|
||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
||||
onEventSelect={openEventEditor}
|
||||
onEventHover={setHoveredEventId}
|
||||
onEventDragStart={handleEventDragStart}
|
||||
onEventDragEnd={handleEventDragEnd}
|
||||
@@ -819,7 +933,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
draggingEventId={draggingEventId}
|
||||
hoveredEventId={hoveredEventId}
|
||||
dropTarget={dropTarget}
|
||||
onEventSelect={(event) => setEventDialog({ kind: "edit", event })}
|
||||
onEventSelect={openEventEditor}
|
||||
onEventHover={setHoveredEventId}
|
||||
onEventDragStart={handleEventDragStart}
|
||||
onEventResizeDragStart={handleEventResizeDragStart}
|
||||
@@ -838,13 +952,28 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
|
||||
{eventDialog && calendars.length > 0 &&
|
||||
<CalendarEventDialog
|
||||
key={
|
||||
eventDialog.kind === "edit"
|
||||
? `${calendarEventInstanceId(eventDialog.occurrence)}:${eventDialog.editScope}`
|
||||
: "new-event"
|
||||
}
|
||||
calendars={calendars}
|
||||
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}
|
||||
saving={saving}
|
||||
canWrite={canWrite}
|
||||
canDelete={canDelete}
|
||||
onEditScopeChange={setEventEditScope}
|
||||
onCancel={() => setEventDialog(null)}
|
||||
onSave={handleSave}
|
||||
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;
|
||||
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,
|
||||
INITIAL_SCROLL_HOUR,
|
||||
calendarEventColorStyle,
|
||||
calendarEventInstanceId,
|
||||
chunk,
|
||||
continuousVirtualWindow,
|
||||
dayKey,
|
||||
@@ -185,12 +186,12 @@ export function CalendarWeekRows({
|
||||
<div className="calendar-event-stack">
|
||||
{dayEvents.slice(0, eventLimit).map((event) => (
|
||||
<CalendarEventChip
|
||||
key={event.id}
|
||||
key={calendarEventInstanceId(event)}
|
||||
event={event}
|
||||
color={calendarColorById.get(event.calendar_id)}
|
||||
canDrag={canWrite}
|
||||
dragging={draggingEventId === event.id}
|
||||
linkedHover={hoveredEventId === event.id}
|
||||
dragging={draggingEventId === calendarEventInstanceId(event)}
|
||||
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
|
||||
onSelect={onEventSelect}
|
||||
onHover={onEventHover}
|
||||
onDragStart={onEventDragStart}
|
||||
@@ -350,13 +351,13 @@ export function CalendarTimeGrid({
|
||||
>
|
||||
{day.events.map((event) => (
|
||||
<CalendarEventChip
|
||||
key={event.id}
|
||||
key={calendarEventInstanceId(event)}
|
||||
event={event}
|
||||
color={calendarColorById.get(event.calendar_id)}
|
||||
compact
|
||||
canDrag={canWrite}
|
||||
dragging={draggingEventId === event.id}
|
||||
linkedHover={hoveredEventId === event.id}
|
||||
dragging={draggingEventId === calendarEventInstanceId(event)}
|
||||
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
|
||||
onSelect={onEventSelect}
|
||||
onHover={onEventHover}
|
||||
onDragStart={onEventDragStart}
|
||||
@@ -503,11 +504,11 @@ function TimedDayColumn({
|
||||
)}
|
||||
{layout.items.map((item) => (
|
||||
<div
|
||||
key={item.event.id}
|
||||
key={calendarEventInstanceId(item.event)}
|
||||
className={[
|
||||
"calendar-timed-event",
|
||||
draggingEventId === item.event.id ? "is-dragging" : "",
|
||||
hoveredEventId === item.event.id
|
||||
draggingEventId === calendarEventInstanceId(item.event) ? "is-dragging" : "",
|
||||
hoveredEventId === calendarEventInstanceId(item.event)
|
||||
? "is-linked-hover"
|
||||
: "",
|
||||
]
|
||||
@@ -518,7 +519,7 @@ function TimedDayColumn({
|
||||
calendarColorById.get(item.event.calendar_id),
|
||||
)}
|
||||
draggable={canWrite}
|
||||
onMouseEnter={() => onEventHover(item.event.id)}
|
||||
onMouseEnter={() => onEventHover(calendarEventInstanceId(item.event))}
|
||||
onMouseLeave={() => onEventHover("")}
|
||||
onDragStart={
|
||||
canWrite
|
||||
@@ -546,7 +547,7 @@ function TimedDayColumn({
|
||||
type="button"
|
||||
className="calendar-timed-event-content"
|
||||
onClick={() => onEventSelect(item.event)}
|
||||
onFocus={() => onEventHover(item.event.id)}
|
||||
onFocus={() => onEventHover(calendarEventInstanceId(item.event))}
|
||||
onBlur={() => onEventHover("")}
|
||||
title={i18nMessage(
|
||||
"i18n:govoplan-calendar.edit_value.fad75899",
|
||||
@@ -629,9 +630,9 @@ function CalendarEventChip({
|
||||
style={calendarEventColorStyle(color)}
|
||||
draggable={canDrag}
|
||||
onClick={() => onSelect(event)}
|
||||
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
|
||||
onMouseEnter={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
|
||||
onMouseLeave={onHover ? () => onHover("") : undefined}
|
||||
onFocus={onHover ? () => onHover(event.id) : undefined}
|
||||
onFocus={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
|
||||
onBlur={onHover ? () => onHover("") : undefined}
|
||||
onDragStart={
|
||||
canDrag && onDragStart
|
||||
|
||||
@@ -32,10 +32,11 @@ export default function UpcomingEventsWidget({
|
||||
end.setDate(end.getDate() + daysAhead);
|
||||
const response = await listCalendarEvents(settings, {
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString()
|
||||
end_at: end.toISOString(),
|
||||
expand_recurring: true
|
||||
});
|
||||
return [...response.events]
|
||||
.filter((event) => event.status !== "cancelled")
|
||||
.filter((event) => event.status.toUpperCase() !== "CANCELLED")
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
|
||||
@@ -57,7 +58,7 @@ export default function UpcomingEventsWidget({
|
||||
<DashboardWidgetList
|
||||
emptyText={`No events in the next ${daysAhead} days.`}
|
||||
items={(events ?? []).map((event) => ({
|
||||
id: event.id,
|
||||
id: event.instance_id || event.id,
|
||||
title: event.summary,
|
||||
detail:
|
||||
showLocation && event.location ? (
|
||||
|
||||
@@ -74,7 +74,7 @@ const MAX_TIMED_EVENT_COLUMNS = 3;
|
||||
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
|
||||
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
|
||||
"govoplan.calendar.viewPreferences";
|
||||
const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
||||
export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
||||
dimWeekends: true,
|
||||
dimOffHours: true,
|
||||
workdayStartHour: 6,
|
||||
@@ -84,6 +84,10 @@ const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
|
||||
alternateContinuousMonths: true,
|
||||
};
|
||||
|
||||
export function calendarEventInstanceId(event: CalendarEvent): string {
|
||||
return event.instance_id || event.id;
|
||||
}
|
||||
|
||||
export function rangeForMode(
|
||||
mode: CalendarMode,
|
||||
focusDate: Date,
|
||||
@@ -677,7 +681,7 @@ export function layoutTimedEventsForDay(
|
||||
}
|
||||
if (hidden.length > 0) {
|
||||
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)),
|
||||
column: overflowColumn,
|
||||
columns: MAX_TIMED_EVENT_COLUMNS,
|
||||
|
||||
+28
-2
@@ -2,7 +2,8 @@ import { createElement, lazy } from "react";
|
||||
import type {
|
||||
CalendarPickerUiCapability,
|
||||
DashboardWidgetsUiCapability,
|
||||
PlatformWebModule
|
||||
PlatformWebModule,
|
||||
SettingsSectionsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import "./styles/calendar.css";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
@@ -10,6 +11,9 @@ import CalendarPicker from "./features/calendar/CalendarPicker";
|
||||
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
|
||||
|
||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||
const CalendarSettingsPanel = lazy(
|
||||
() => import("./features/calendar/CalendarSettingsPanel")
|
||||
);
|
||||
|
||||
const eventRead = ["calendar:event:read"];
|
||||
const translations = {
|
||||
@@ -18,6 +22,20 @@ const translations = {
|
||||
};
|
||||
|
||||
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 = {
|
||||
widgets: [
|
||||
{
|
||||
@@ -87,6 +105,13 @@ export const calendarModule: PlatformWebModule = {
|
||||
kind: "section",
|
||||
label: "Upcoming events widget",
|
||||
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 }],
|
||||
@@ -94,7 +119,8 @@ export const calendarModule: PlatformWebModule = {
|
||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
|
||||
uiCapabilities: {
|
||||
"calendar.picker": calendarPicker,
|
||||
"dashboard.widgets": calendarDashboardWidgets
|
||||
"dashboard.widgets": calendarDashboardWidgets,
|
||||
"settings.sections": calendarSettingsSections
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -23,13 +23,19 @@
|
||||
],
|
||||
"react/jsx-runtime": [
|
||||
"../../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": [
|
||||
"../../govoplan-core/webui/src/vite-env.d.ts",
|
||||
"src/module.ts",
|
||||
"src/api/calendar.ts",
|
||||
"src/features/calendar/CalendarPage.tsx",
|
||||
"src/features/calendar/CalendarSettingsPanel.tsx",
|
||||
"src/features/calendar/UpcomingEventsWidget.tsx",
|
||||
"src/features/calendar/CalendarViews.tsx",
|
||||
"src/features/calendar/CalendarCollectionDialogs.tsx",
|
||||
"src/features/calendar/CalendarEventDialog.tsx",
|
||||
|
||||
Reference in New Issue
Block a user