Complete recurrence editing and calendar preferences

This commit is contained in:
2026-07-31 05:46:50 +02:00
parent 1e063f51cc
commit f00cff1d8c
23 changed files with 2354 additions and 368 deletions
+6 -4
View File
@@ -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
+9 -6
View File
@@ -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",
]
+236
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
+157
View File
@@ -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)
+42 -1
View File
@@ -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")
+11
View File
@@ -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",
),
),
@@ -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()
@@ -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")
+147
View File
@@ -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,
+38
View File
@@ -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")
+582 -276
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
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,
}
+113
View File
@@ -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&amp;&quot;&lt;@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()
+256
View File
@@ -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()
+79 -1
View File
@@ -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);
}
+188 -45
View File
@@ -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.";
}
+14 -13
View File
@@ -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
View File
@@ -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
}
};
+6
View File
@@ -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",