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
@@ -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,
}