Release v0.1.5
This commit is contained in:
21
README.md
21
README.md
@@ -33,7 +33,26 @@ The first backend implementation stores VEVENT data in two layers:
|
||||
- normalized fields for API range queries and calendar rendering
|
||||
- raw iCalendar property records and generated `text/calendar` output so unsupported VEVENT properties and parameters can round-trip
|
||||
|
||||
This is intentionally not yet a network CalDAV server. CalDAV protocol sync, external ETags, REPORT handling, scheduling inbox/outbox behavior, and full recurrence expansion are calendar-owned follow-up work.
|
||||
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
|
||||
records the remote collection URL, sync token, ETag/ctag state, username,
|
||||
credential reference, sync interval, sync direction, and conflict policy.
|
||||
Credentials can be supplied transiently for manual sync, referenced from
|
||||
environment variables with `env:NAME`, stored through a platform secret provider
|
||||
when one is available, or stored as encrypted calendar-owned credentials.
|
||||
|
||||
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
|
||||
when a sync token exists. It imports all VEVENT components in a resource and
|
||||
soft-deletes local events when remote resources disappear. Two-way sources also
|
||||
write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and
|
||||
ETag preconditions. If a remote resource changed, the local mutation is rejected
|
||||
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.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@ 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
|
||||
- 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
|
||||
- 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 and UI foundation on which CalDAV sync, Open-Xchange integration, recurrence expansion, and free/busy endpoints can be built.
|
||||
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.
|
||||
|
||||
## Integration Points
|
||||
|
||||
@@ -112,8 +114,9 @@ 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.
|
||||
- Free/busy endpoint and capability for scheduling, appointments, and resource checks.
|
||||
- CalDAV sync adapter with collection sync tokens, ETags, REPORT handling, and conflict resolution.
|
||||
- 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.
|
||||
- Resource calendars for rooms, equipment, counters, and service desks.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.4",
|
||||
"@govoplan/core-webui": "^0.1.5",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,15 +4,17 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-calendar"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.4",
|
||||
"govoplan-access>=0.1.4",
|
||||
"govoplan-core>=0.1.5",
|
||||
"govoplan-access>=0.1.5",
|
||||
"icalendar>=7.2",
|
||||
"python-dateutil>=2.9",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
275
src/govoplan_calendar/backend/caldav.py
Normal file
275
src/govoplan_calendar/backend/caldav.py
Normal file
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Mapping, Protocol
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
class CalDAVError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CalDAVSyncUnsupported(CalDAVError):
|
||||
pass
|
||||
|
||||
|
||||
class CalDAVPreconditionFailed(CalDAVError):
|
||||
pass
|
||||
|
||||
|
||||
class CalDAVTransport(Protocol):
|
||||
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalDAVObject:
|
||||
href: str
|
||||
etag: str | None = None
|
||||
calendar_data: str | None = None
|
||||
deleted: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalDAVReportResult:
|
||||
objects: list[CalDAVObject] = field(default_factory=list)
|
||||
sync_token: str | None = None
|
||||
ctag: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalDAVWriteResult:
|
||||
href: str
|
||||
etag: str | None = None
|
||||
status: int = 0
|
||||
|
||||
|
||||
class CalDAVClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
collection_url: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
bearer_token: str | None = None,
|
||||
timeout: int = 30,
|
||||
transport: CalDAVTransport | None = None,
|
||||
) -> None:
|
||||
self.collection_url = ensure_collection_url(collection_url)
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.bearer_token = bearer_token
|
||||
self.timeout = timeout
|
||||
self.transport = transport or urllib_transport
|
||||
|
||||
def propfind_collection(self) -> CalDAVReportResult:
|
||||
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:" xmlns:CS="http://calendarserver.org/ns/">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:sync-token/>
|
||||
<CS:getctag/>
|
||||
</D:prop>
|
||||
</D:propfind>"""
|
||||
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
|
||||
return parse_multistatus(payload)
|
||||
|
||||
def list_objects(self) -> CalDAVReportResult:
|
||||
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT"/>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>"""
|
||||
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
|
||||
return parse_multistatus(payload)
|
||||
|
||||
def sync_collection(self, sync_token: str) -> CalDAVReportResult:
|
||||
body = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:sync-collection xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:sync-token>{xml_escape(sync_token)}</D:sync-token>
|
||||
<D:sync-level>1</D:sync-level>
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
</D:sync-collection>""".encode("utf-8")
|
||||
try:
|
||||
payload = self.request("REPORT", self.collection_url, body=body, depth="1", expected={207})
|
||||
except CalDAVError as exc:
|
||||
raise CalDAVSyncUnsupported(str(exc)) from exc
|
||||
return parse_multistatus(payload)
|
||||
|
||||
def fetch_object(self, href: str) -> str:
|
||||
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
|
||||
return payload.decode("utf-8")
|
||||
|
||||
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
|
||||
headers = {"Content-Type": "text/calendar; charset=utf-8"}
|
||||
if create:
|
||||
headers["If-None-Match"] = "*"
|
||||
elif etag and not overwrite:
|
||||
headers["If-Match"] = etag
|
||||
elif not overwrite:
|
||||
raise CalDAVPreconditionFailed("Refusing CalDAV PUT without an ETag")
|
||||
status, response_headers, _payload = self.request_raw(
|
||||
"PUT",
|
||||
self.object_url(href),
|
||||
body=ics.encode("utf-8"),
|
||||
depth=None,
|
||||
expected={200, 201, 204},
|
||||
extra_headers=headers,
|
||||
)
|
||||
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||
|
||||
def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> CalDAVWriteResult:
|
||||
headers: dict[str, str] = {}
|
||||
if etag and not overwrite:
|
||||
headers["If-Match"] = etag
|
||||
elif not overwrite:
|
||||
raise CalDAVPreconditionFailed("Refusing CalDAV DELETE without an ETag")
|
||||
status, response_headers, _payload = self.request_raw(
|
||||
"DELETE",
|
||||
self.object_url(href),
|
||||
body=None,
|
||||
depth=None,
|
||||
expected={200, 202, 204, 404},
|
||||
extra_headers=headers,
|
||||
)
|
||||
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||
|
||||
def object_url(self, href: str) -> str:
|
||||
return urllib.parse.urljoin(self.collection_url, href)
|
||||
|
||||
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
||||
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
||||
return payload
|
||||
|
||||
def request_raw(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
body: bytes | None,
|
||||
depth: str | None,
|
||||
expected: set[int],
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
) -> tuple[int, Mapping[str, str], bytes]:
|
||||
headers: dict[str, str] = {
|
||||
"Accept": "application/xml,text/calendar,*/*",
|
||||
"User-Agent": "govoplan-calendar-caldav/0.1",
|
||||
}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/xml; charset=utf-8"
|
||||
if depth is not None:
|
||||
headers["Depth"] = depth
|
||||
if self.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||
elif self.username and self.password:
|
||||
token = base64.b64encode(f"{self.username}:{self.password}".encode("utf-8")).decode("ascii")
|
||||
headers["Authorization"] = f"Basic {token}"
|
||||
if extra_headers:
|
||||
headers.update(dict(extra_headers))
|
||||
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
|
||||
if status not in expected:
|
||||
if status == 412:
|
||||
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
|
||||
raise CalDAVError(f"{method} {url} returned HTTP {status}")
|
||||
return status, response_headers, payload
|
||||
|
||||
|
||||
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return response.status, dict(response.headers.items()), response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, dict(exc.headers.items()), exc.read()
|
||||
except urllib.error.URLError as exc:
|
||||
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||
|
||||
|
||||
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
|
||||
try:
|
||||
root = ElementTree.fromstring(payload)
|
||||
except ElementTree.ParseError as exc:
|
||||
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
|
||||
objects: list[CalDAVObject] = []
|
||||
sync_token = first_child_text(root, "sync-token")
|
||||
ctag = first_child_text(root, "getctag")
|
||||
for response in child_elements(root, "response"):
|
||||
href = first_child_text(response, "href")
|
||||
if not href:
|
||||
continue
|
||||
deleted = False
|
||||
etag = None
|
||||
calendar_data = None
|
||||
for propstat in child_elements(response, "propstat"):
|
||||
status = first_child_text(propstat, "status") or ""
|
||||
prop = first_child(propstat, "prop")
|
||||
if prop is None:
|
||||
continue
|
||||
if " 404 " in status or status.endswith(" 404"):
|
||||
deleted = True
|
||||
continue
|
||||
if " 200 " not in status and not status.endswith(" 200"):
|
||||
continue
|
||||
etag = first_child_text(prop, "getetag") or etag
|
||||
calendar_data = first_child_text(prop, "calendar-data") or calendar_data
|
||||
sync_token = first_child_text(prop, "sync-token") or sync_token
|
||||
ctag = first_child_text(prop, "getctag") or ctag
|
||||
objects.append(CalDAVObject(href=href, etag=strip_weak_etag(etag), calendar_data=calendar_data, deleted=deleted))
|
||||
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
|
||||
|
||||
|
||||
def ensure_collection_url(value: str) -> str:
|
||||
return value if value.endswith("/") else f"{value}/"
|
||||
|
||||
|
||||
def strip_weak_etag(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
|
||||
def response_etag(headers: Mapping[str, str]) -> str | None:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == "etag" and value:
|
||||
return strip_weak_etag(value)
|
||||
return None
|
||||
|
||||
|
||||
def xml_escape(value: str) -> str:
|
||||
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
|
||||
for child in element:
|
||||
if local_name(child.tag) == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def first_child_text(element: ElementTree.Element, name: str) -> str | None:
|
||||
found = first_child(element, name)
|
||||
if found is None or found.text is None:
|
||||
return None
|
||||
return found.text.strip()
|
||||
|
||||
|
||||
def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]:
|
||||
return [child for child in element if local_name(child.tag) == name]
|
||||
|
||||
|
||||
def local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||||
@@ -94,4 +94,61 @@ class CalendarEvent(Base, TimestampMixin):
|
||||
calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
|
||||
|
||||
|
||||
__all__ = ["CalendarCollection", "CalendarEvent", "new_uuid"]
|
||||
class CalendarSyncSource(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_sync_sources"
|
||||
__table_args__ = (
|
||||
Index("ix_calendar_sync_sources_calendar", "tenant_id", "calendar_id", "source_kind"),
|
||||
Index(
|
||||
"uq_calendar_sync_sources_active_url",
|
||||
"tenant_id",
|
||||
"source_kind",
|
||||
"collection_url",
|
||||
unique=True,
|
||||
sqlite_where=text("deleted_at IS NULL"),
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
source_kind: Mapped[str] = mapped_column(String(30), default="caldav", nullable=False, index=True)
|
||||
collection_url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
auth_type: Mapped[str] = mapped_column(String(30), default="none", nullable=False)
|
||||
username: Mapped[str | None] = mapped_column(String(255))
|
||||
credential_ref: Mapped[str | None] = mapped_column(String(255))
|
||||
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
sync_interval_seconds: Mapped[int] = mapped_column(Integer, default=900, nullable=False)
|
||||
sync_direction: Mapped[str] = mapped_column(String(30), default="two_way", nullable=False)
|
||||
conflict_policy: Mapped[str] = mapped_column(String(30), default="etag", nullable=False)
|
||||
sync_token: Mapped[str | None] = mapped_column(Text)
|
||||
ctag: Mapped[str | None] = mapped_column(String(255))
|
||||
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
next_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
last_status: Mapped[str | None] = mapped_column(String(30))
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
calendar: Mapped[CalendarCollection] = relationship()
|
||||
|
||||
|
||||
class CalendarSyncCredential(Base, TimestampMixin):
|
||||
__tablename__ = "calendar_sync_credentials"
|
||||
__table_args__ = (
|
||||
Index("ix_calendar_sync_credentials_tenant_kind", "tenant_id", "credential_kind"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
credential_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
label: Mapped[str | None] = mapped_column(String(255))
|
||||
secret_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
|
||||
__all__ = ["CalendarCollection", "CalendarEvent", "CalendarSyncCredential", "CalendarSyncSource", "new_uuid"]
|
||||
|
||||
@@ -1,239 +1,553 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timezone
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from email.utils import format_datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from dateutil.rrule import rruleset, rrulestr
|
||||
from icalendar import Alarm, Calendar, Event, vCalAddress
|
||||
from icalendar.parser import Parameters
|
||||
|
||||
|
||||
class ICalendarError(ValueError):
|
||||
"""Raised when iCalendar content cannot be parsed as a usable VEVENT."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ICalendarProperty:
|
||||
name: str
|
||||
value: str
|
||||
params: dict[str, list[str]]
|
||||
GENERATED_EVENT_PROPERTIES = {
|
||||
"UID",
|
||||
"DTSTAMP",
|
||||
"DTSTART",
|
||||
"DTEND",
|
||||
"DURATION",
|
||||
"RECURRENCE-ID",
|
||||
"SUMMARY",
|
||||
"SEQUENCE",
|
||||
"STATUS",
|
||||
"TRANSP",
|
||||
"CLASS",
|
||||
"DESCRIPTION",
|
||||
"LOCATION",
|
||||
"CATEGORIES",
|
||||
"RRULE",
|
||||
"RDATE",
|
||||
"EXDATE",
|
||||
"ORGANIZER",
|
||||
"ATTENDEE",
|
||||
"ATTACH",
|
||||
"RELATED-TO",
|
||||
}
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"name": self.name, "value": self.value, "params": self.params}
|
||||
|
||||
class RawICalendarValue:
|
||||
def __init__(self, value: str, params: dict[str, str]) -> None:
|
||||
self.value = value
|
||||
self.params = Parameters(params)
|
||||
|
||||
def to_ical(self) -> bytes:
|
||||
return self.value.encode("utf-8")
|
||||
|
||||
|
||||
def parse_vevent(ics: str) -> dict[str, Any]:
|
||||
lines = unfold_ical_lines(ics)
|
||||
event_lines = component_lines(lines, "VEVENT")
|
||||
if not event_lines:
|
||||
events = parse_vevents(ics)
|
||||
if not events:
|
||||
raise ICalendarError("No VEVENT component found")
|
||||
return events[0]
|
||||
|
||||
properties = [parse_content_line(line) for line in event_lines]
|
||||
props = [prop.as_dict() for prop in properties]
|
||||
by_name = group_properties(properties)
|
||||
|
||||
uid = first_value(by_name, "UID")
|
||||
def parse_vevents(ics: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
calendar = Calendar.from_ical(ics)
|
||||
except Exception as exc: # noqa: BLE001 - normalize parser errors for API callers.
|
||||
raise ICalendarError(f"Invalid iCalendar content: {exc}") from exc
|
||||
|
||||
events = [component for component in calendar.walk("VEVENT") if component.name == "VEVENT"]
|
||||
if not events:
|
||||
raise ICalendarError("No VEVENT component found")
|
||||
normalized_ics = normalize_ics(ics)
|
||||
return [parse_vevent_component(calendar, event, raw_ics=normalized_ics) for event in events]
|
||||
|
||||
|
||||
def parse_vevent_component(calendar: Calendar, event: Event, *, raw_ics: str) -> dict[str, Any]:
|
||||
uid = text_value(first_property_value(event, "UID"))
|
||||
if not uid:
|
||||
raise ICalendarError("VEVENT is missing UID")
|
||||
dtstart_prop = first_property(by_name, "DTSTART")
|
||||
if dtstart_prop is None:
|
||||
raise ICalendarError("VEVENT is missing DTSTART")
|
||||
|
||||
start_at, all_day, tzid = parse_temporal_property(dtstart_prop)
|
||||
dtstart = first_property_value(event, "DTSTART")
|
||||
if dtstart is None:
|
||||
raise ICalendarError("VEVENT is missing DTSTART")
|
||||
start_at, all_day, tzid = temporal_property(dtstart)
|
||||
|
||||
end_at = None
|
||||
duration_seconds = None
|
||||
dtend_prop = first_property(by_name, "DTEND")
|
||||
if dtend_prop is not None:
|
||||
end_at, _end_all_day, _end_tzid = parse_temporal_property(dtend_prop)
|
||||
dtend = first_property_value(event, "DTEND")
|
||||
duration = decoded_property(event, "DURATION")
|
||||
if dtend is not None:
|
||||
end_at, _end_all_day, _end_tzid = temporal_property(dtend)
|
||||
duration_seconds = int((end_at - start_at).total_seconds())
|
||||
elif first_value(by_name, "DURATION"):
|
||||
# Keep full DURATION semantics in raw properties. The first module
|
||||
# stores it but does not yet calculate every RFC 5545 duration form.
|
||||
duration_seconds = None
|
||||
elif isinstance(duration, timedelta):
|
||||
end_at = start_at + duration
|
||||
duration_seconds = int(duration.total_seconds())
|
||||
|
||||
summary = first_value(by_name, "SUMMARY") or "(Untitled event)"
|
||||
organizer = property_party(first_property(by_name, "ORGANIZER"))
|
||||
attendees = [property_party(prop) for prop in by_name.get("ATTENDEE", [])]
|
||||
categories = split_csv(first_value(by_name, "CATEGORIES") or "")
|
||||
properties = component_property_records(event)
|
||||
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
|
||||
standard = standard_property_index(properties)
|
||||
|
||||
return {
|
||||
"uid": uid,
|
||||
"recurrence_id": first_value(by_name, "RECURRENCE-ID"),
|
||||
"sequence": int_or_zero(first_value(by_name, "SEQUENCE")),
|
||||
"summary": summary,
|
||||
"description": first_value(by_name, "DESCRIPTION"),
|
||||
"location": first_value(by_name, "LOCATION"),
|
||||
"status": (first_value(by_name, "STATUS") or "CONFIRMED").upper(),
|
||||
"transparency": (first_value(by_name, "TRANSP") or "OPAQUE").upper(),
|
||||
"classification": (first_value(by_name, "CLASS") or "PUBLIC").upper(),
|
||||
"recurrence_id": recurrence_id_value(first_property_value(event, "RECURRENCE-ID")),
|
||||
"sequence": int_or_zero(text_value(first_property_value(event, "SEQUENCE"))),
|
||||
"summary": text_value(first_property_value(event, "SUMMARY")) or "(Untitled event)",
|
||||
"description": text_value(first_property_value(event, "DESCRIPTION")),
|
||||
"location": text_value(first_property_value(event, "LOCATION")),
|
||||
"status": (text_value(first_property_value(event, "STATUS")) or "CONFIRMED").upper(),
|
||||
"transparency": (text_value(first_property_value(event, "TRANSP")) or "OPAQUE").upper(),
|
||||
"classification": (text_value(first_property_value(event, "CLASS")) or "PUBLIC").upper(),
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"duration_seconds": duration_seconds,
|
||||
"all_day": all_day,
|
||||
"timezone": tzid,
|
||||
"organizer": organizer,
|
||||
"attendees": attendees,
|
||||
"categories": categories,
|
||||
"rrule": parse_key_value_property(first_value(by_name, "RRULE")),
|
||||
"rdate": [prop.as_dict() for prop in by_name.get("RDATE", [])],
|
||||
"exdate": [prop.as_dict() for prop in by_name.get("EXDATE", [])],
|
||||
"attachments": [prop.as_dict() for prop in by_name.get("ATTACH", [])],
|
||||
"related_to": [prop.as_dict() for prop in by_name.get("RELATED-TO", [])],
|
||||
"icalendar": {"component": "VEVENT", "properties": props},
|
||||
"raw_ics": normalize_ics(ics),
|
||||
"organizer": party_record(first_property_value(event, "ORGANIZER")),
|
||||
"attendees": [party_record(value) for value in property_values(event, "ATTENDEE")],
|
||||
"categories": category_values(event),
|
||||
"rrule": recurrence_rule(first_property_value(event, "RRULE")),
|
||||
"rdate": records_for_name(properties, "RDATE"),
|
||||
"exdate": records_for_name(properties, "EXDATE"),
|
||||
"reminders": [alarm_to_reminder(alarm) for alarm in alarms],
|
||||
"attachments": records_for_name(properties, "ATTACH"),
|
||||
"related_to": records_for_name(properties, "RELATED-TO"),
|
||||
"icalendar": {
|
||||
"component": "VEVENT",
|
||||
"schema_version": 1,
|
||||
"properties": properties,
|
||||
"alarms": alarms,
|
||||
"standard": standard,
|
||||
"method": text_value(first_property_value(calendar, "METHOD")),
|
||||
"timezones": calendar_timezones(calendar),
|
||||
},
|
||||
"raw_ics": raw_ics,
|
||||
}
|
||||
|
||||
|
||||
def event_to_ics(event: Any) -> str:
|
||||
properties = [
|
||||
("UID", {}, event.uid),
|
||||
("DTSTAMP", {}, format_ical_datetime(datetime.now(timezone.utc))),
|
||||
("DTSTART", date_params(event), format_ical_temporal(event.start_at, all_day=event.all_day)),
|
||||
]
|
||||
return events_to_ics([event])
|
||||
|
||||
|
||||
def events_to_ics(events: list[Any]) -> str:
|
||||
calendar = Calendar()
|
||||
calendar.add("version", "2.0")
|
||||
calendar.add("prodid", "-//GovOPlaN//Calendar//EN")
|
||||
calendar.add("calscale", "GREGORIAN")
|
||||
|
||||
method = next((metadata_value(event, "method") for event in events if metadata_value(event, "method")), None)
|
||||
if method:
|
||||
calendar.add("method", method)
|
||||
|
||||
for event in events:
|
||||
calendar.add_component(event_to_component(event))
|
||||
return calendar.to_ical().decode("utf-8")
|
||||
|
||||
|
||||
def event_to_component(event: Any) -> Event:
|
||||
component = Event()
|
||||
component.add("uid", event.uid)
|
||||
component.add("dtstamp", datetime.now(timezone.utc))
|
||||
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
|
||||
if event.end_at is not None:
|
||||
properties.append(("DTEND", date_params(event), format_ical_temporal(event.end_at, all_day=event.all_day)))
|
||||
properties.extend(
|
||||
[
|
||||
("SUMMARY", {}, event.summary),
|
||||
("SEQUENCE", {}, str(event.sequence or 0)),
|
||||
("STATUS", {}, event.status),
|
||||
("TRANSP", {}, event.transparency),
|
||||
("CLASS", {}, event.classification),
|
||||
]
|
||||
)
|
||||
component.add("dtend", event_temporal_value(event.end_at, all_day=event.all_day, timezone_id=event.timezone))
|
||||
elif getattr(event, "duration_seconds", None) is not None:
|
||||
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
||||
if getattr(event, "recurrence_id", None):
|
||||
add_recurrence_id(component, str(event.recurrence_id))
|
||||
component.add("summary", event.summary)
|
||||
component.add("sequence", int(event.sequence or 0))
|
||||
component.add("status", event.status)
|
||||
component.add("transp", event.transparency)
|
||||
component.add("class", event.classification)
|
||||
if event.description:
|
||||
properties.append(("DESCRIPTION", {}, event.description))
|
||||
component.add("description", event.description)
|
||||
if event.location:
|
||||
properties.append(("LOCATION", {}, event.location))
|
||||
for category in event.categories or []:
|
||||
if category:
|
||||
properties.append(("CATEGORIES", {}, category))
|
||||
component.add("location", event.location)
|
||||
if event.categories:
|
||||
component.add("categories", list(event.categories))
|
||||
if event.rrule:
|
||||
properties.append(("RRULE", {}, serialize_key_value_property(event.rrule)))
|
||||
component.add("rrule", normalized_rrule_for_export(event.rrule))
|
||||
if event.organizer:
|
||||
properties.append(party_to_property("ORGANIZER", event.organizer))
|
||||
component.add("organizer", party_for_export(event.organizer))
|
||||
for attendee in event.attendees or []:
|
||||
properties.append(party_to_property("ATTENDEE", attendee))
|
||||
component.add("attendee", party_for_export(attendee))
|
||||
for record in getattr(event, "rdate", None) or []:
|
||||
add_raw_property(component, "RDATE", record)
|
||||
for record in getattr(event, "exdate", None) or []:
|
||||
add_raw_property(component, "EXDATE", record)
|
||||
for record in getattr(event, "attachments", None) or []:
|
||||
add_raw_property(component, "ATTACH", record)
|
||||
for record in getattr(event, "related_to", None) or []:
|
||||
add_raw_property(component, "RELATED-TO", record)
|
||||
|
||||
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//GovOPlaN//Calendar//EN", "CALSCALE:GREGORIAN", "BEGIN:VEVENT"]
|
||||
lines.extend(fold_ical_line(serialize_content_line(name, params, value)) for name, params, value in properties if value is not None)
|
||||
lines.extend(["END:VEVENT", "END:VCALENDAR", ""])
|
||||
return "\r\n".join(lines)
|
||||
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
|
||||
for alarm in alarms_for_export(event):
|
||||
component.add_component(alarm)
|
||||
return component
|
||||
|
||||
|
||||
def unfold_ical_lines(ics: str) -> list[str]:
|
||||
raw_lines = ics.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
lines: list[str] = []
|
||||
for line in raw_lines:
|
||||
if not line:
|
||||
continue
|
||||
if line[:1] in {" ", "\t"} and lines:
|
||||
lines[-1] += line[1:]
|
||||
else:
|
||||
lines.append(line)
|
||||
return lines
|
||||
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
|
||||
"""Expand an event's recurrence primitives within a range.
|
||||
|
||||
This is a backend primitive for later API/UI recurrence handling. It expands
|
||||
RRULE/RDATE/EXDATE data stored by this module and returns occurrence starts
|
||||
with the event's original duration.
|
||||
"""
|
||||
|
||||
start_at = normalize_datetime(event.start_at)
|
||||
end_at = normalize_datetime(event.end_at) if event.end_at is not None else None
|
||||
range_start = normalize_datetime(range_start)
|
||||
range_end = normalize_datetime(range_end)
|
||||
duration = occurrence_duration(event, start_at=start_at, end_at=end_at)
|
||||
|
||||
if not event.rrule and not event.rdate:
|
||||
return [occurrence_payload(start_at, duration, event)] if occurrence_overlaps(start_at, duration, range_start, range_end) else []
|
||||
|
||||
rules = rruleset()
|
||||
if event.rrule:
|
||||
rules.rrule(rrulestr(serialize_key_value_property(event.rrule), dtstart=start_at))
|
||||
for rdate in temporal_values_from_records(event.rdate or []):
|
||||
rules.rdate(rdate)
|
||||
for exdate in temporal_values_from_records(event.exdate or []):
|
||||
rules.exdate(exdate)
|
||||
|
||||
starts = rules.between(range_start - duration, range_end, inc=True)
|
||||
occurrences: list[dict[str, Any]] = []
|
||||
for occurrence_start in starts:
|
||||
occurrence_start = normalize_datetime(occurrence_start)
|
||||
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
|
||||
occurrences.append(occurrence_payload(occurrence_start, duration, event))
|
||||
if len(occurrences) >= limit:
|
||||
break
|
||||
return occurrences
|
||||
|
||||
|
||||
def component_lines(lines: list[str], component: str) -> list[str]:
|
||||
begin = f"BEGIN:{component.upper()}"
|
||||
end = f"END:{component.upper()}"
|
||||
depth = 0
|
||||
collected: list[str] = []
|
||||
for line in lines:
|
||||
upper = line.upper()
|
||||
if upper == begin:
|
||||
depth += 1
|
||||
if depth == 1:
|
||||
collected = []
|
||||
continue
|
||||
if upper == end and depth:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return collected
|
||||
continue
|
||||
if depth:
|
||||
collected.append(line)
|
||||
def first_vevent(calendar: Calendar) -> Event:
|
||||
for component in calendar.walk("VEVENT"):
|
||||
if component.name == "VEVENT":
|
||||
return component
|
||||
raise ICalendarError("No VEVENT component found")
|
||||
|
||||
|
||||
def first_property_value(component: Any, name: str) -> Any | None:
|
||||
values = property_values(component, name)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def property_values(component: Any, name: str) -> list[Any]:
|
||||
value = component.get(name.upper())
|
||||
if value is None:
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def parse_content_line(line: str) -> ICalendarProperty:
|
||||
head, sep, value = line.partition(":")
|
||||
if not sep:
|
||||
raise ICalendarError(f"Invalid iCalendar content line: {line}")
|
||||
parts = head.split(";")
|
||||
name = parts[0].upper()
|
||||
params: dict[str, list[str]] = {}
|
||||
for raw_param in parts[1:]:
|
||||
key, param_sep, raw_value = raw_param.partition("=")
|
||||
if not param_sep:
|
||||
params[key.upper()] = [""]
|
||||
else:
|
||||
params[key.upper()] = [unescape_text(item.strip('"')) for item in raw_value.split(",")]
|
||||
return ICalendarProperty(name=name, params=params, value=unescape_text(value))
|
||||
|
||||
|
||||
def group_properties(properties: list[ICalendarProperty]) -> dict[str, list[ICalendarProperty]]:
|
||||
grouped: dict[str, list[ICalendarProperty]] = {}
|
||||
for prop in properties:
|
||||
grouped.setdefault(prop.name, []).append(prop)
|
||||
return grouped
|
||||
|
||||
|
||||
def first_property(grouped: dict[str, list[ICalendarProperty]], name: str) -> ICalendarProperty | None:
|
||||
items = grouped.get(name.upper()) or []
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def first_value(grouped: dict[str, list[ICalendarProperty]], name: str) -> str | None:
|
||||
prop = first_property(grouped, name)
|
||||
return prop.value if prop else None
|
||||
|
||||
|
||||
def parse_temporal_property(prop: ICalendarProperty) -> tuple[datetime, bool, str | None]:
|
||||
value_type = (prop.params.get("VALUE") or [""])[0].upper()
|
||||
tzid = (prop.params.get("TZID") or [None])[0]
|
||||
if value_type == "DATE" or (len(prop.value) == 8 and "T" not in prop.value):
|
||||
parsed_date = datetime.strptime(prop.value, "%Y%m%d").date()
|
||||
return datetime.combine(parsed_date, time.min, tzinfo=timezone.utc), True, tzid
|
||||
return parse_ical_datetime(prop.value), False, tzid
|
||||
|
||||
|
||||
def parse_ical_datetime(value: str) -> datetime:
|
||||
if value.endswith("Z"):
|
||||
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||
parsed = datetime.strptime(value, "%Y%m%dT%H%M%S")
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def format_ical_temporal(value: datetime, *, all_day: bool) -> str:
|
||||
if all_day:
|
||||
return value.date().strftime("%Y%m%d")
|
||||
return format_ical_datetime(value)
|
||||
|
||||
|
||||
def format_ical_datetime(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def date_params(event: Any) -> dict[str, list[str]]:
|
||||
if event.all_day:
|
||||
return {"VALUE": ["DATE"]}
|
||||
if event.timezone:
|
||||
return {"TZID": [event.timezone]}
|
||||
return {}
|
||||
|
||||
|
||||
def property_party(prop: ICalendarProperty | None) -> dict[str, Any] | None:
|
||||
if prop is None:
|
||||
def decoded_property(component: Any, name: str) -> Any | None:
|
||||
try:
|
||||
return component.decoded(name.upper())
|
||||
except KeyError:
|
||||
return None
|
||||
return {"value": prop.value, "params": prop.params}
|
||||
|
||||
|
||||
def party_to_property(name: str, party: dict[str, Any]) -> tuple[str, dict[str, list[str]], str]:
|
||||
params = party.get("params") if isinstance(party.get("params"), dict) else {}
|
||||
def temporal_property(value: Any) -> tuple[datetime, bool, str | None]:
|
||||
decoded = decoded_temporal_value(value)
|
||||
params = params_to_json(getattr(value, "params", {}))
|
||||
tzid = first_param(params, "TZID")
|
||||
if isinstance(decoded, datetime):
|
||||
return normalize_datetime(decoded), False, tzid
|
||||
if isinstance(decoded, date):
|
||||
return datetime.combine(decoded, time.min, tzinfo=timezone.utc), True, tzid
|
||||
raise ICalendarError(f"Unsupported temporal value: {value}")
|
||||
|
||||
|
||||
def decoded_temporal_value(value: Any) -> Any:
|
||||
decoded = getattr(value, "dt", None)
|
||||
return decoded if decoded is not None else value
|
||||
|
||||
|
||||
def recurrence_id_value(value: Any | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
raw_value = ical_value(value)
|
||||
params = params_to_json(getattr(value, "params", {}))
|
||||
tzid = first_param(params, "TZID")
|
||||
return f"TZID={tzid}:{raw_value}" if tzid else raw_value
|
||||
|
||||
|
||||
def recurrence_rule(value: Any | None) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "items"):
|
||||
result: dict[str, Any] = {}
|
||||
for key, raw_items in value.items():
|
||||
items = raw_items if isinstance(raw_items, list) else [raw_items]
|
||||
normalized = [rrule_item(item) for item in items]
|
||||
result[str(key).upper()] = normalized if len(normalized) > 1 else normalized[0]
|
||||
return result
|
||||
return parse_key_value_property(ical_value(value))
|
||||
|
||||
|
||||
def rrule_item(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return format_ical_datetime(value)
|
||||
if isinstance(value, date):
|
||||
return value.strftime("%Y%m%d")
|
||||
return str(value).upper() if isinstance(value, str) else str(value)
|
||||
|
||||
|
||||
def category_values(component: Any) -> list[str]:
|
||||
decoded = decoded_property(component, "CATEGORIES")
|
||||
if decoded is None:
|
||||
return []
|
||||
groups = decoded if isinstance(decoded, list) else [decoded]
|
||||
categories: list[str] = []
|
||||
for group in groups:
|
||||
values = group if isinstance(group, list) else [group]
|
||||
categories.extend(str(value) for value in values if str(value))
|
||||
return categories
|
||||
|
||||
|
||||
def component_property_records(component: Any) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
for name, value in component.items():
|
||||
for item in value if isinstance(value, list) else [value]:
|
||||
records.append(property_record(name, item))
|
||||
return records
|
||||
|
||||
|
||||
def property_record(name: str, value: Any) -> dict[str, Any]:
|
||||
record: dict[str, Any] = {
|
||||
"name": str(name).upper(),
|
||||
"value": ical_value(value),
|
||||
"params": params_to_json(getattr(value, "params", {})),
|
||||
}
|
||||
decoded = decoded_temporal_value(value)
|
||||
json_decoded = json_value(decoded)
|
||||
if json_decoded is not None and json_decoded != record["value"]:
|
||||
record["decoded"] = json_decoded
|
||||
return record
|
||||
|
||||
|
||||
def component_alarm_record(alarm: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"component": "VALARM",
|
||||
"properties": component_property_records(alarm),
|
||||
}
|
||||
|
||||
|
||||
def alarm_to_reminder(alarm: dict[str, Any]) -> dict[str, Any]:
|
||||
properties = alarm.get("properties") or []
|
||||
trigger = first_record(properties, "TRIGGER")
|
||||
return {
|
||||
"action": record_value(properties, "ACTION") or "DISPLAY",
|
||||
"trigger": trigger,
|
||||
"description": record_value(properties, "DESCRIPTION"),
|
||||
"summary": record_value(properties, "SUMMARY"),
|
||||
"attendees": records_for_name(properties, "ATTENDEE"),
|
||||
"duration": first_record(properties, "DURATION"),
|
||||
"repeat": record_value(properties, "REPEAT"),
|
||||
"attachments": records_for_name(properties, "ATTACH"),
|
||||
"properties": properties,
|
||||
}
|
||||
|
||||
|
||||
def standard_property_index(properties: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
names = (
|
||||
"DTSTAMP",
|
||||
"CREATED",
|
||||
"LAST-MODIFIED",
|
||||
"PRIORITY",
|
||||
"URL",
|
||||
"GEO",
|
||||
"COMMENT",
|
||||
"CONTACT",
|
||||
"REQUEST-STATUS",
|
||||
"RESOURCES",
|
||||
)
|
||||
result: dict[str, Any] = {}
|
||||
for name in names:
|
||||
matching = records_for_name(properties, name)
|
||||
if not matching:
|
||||
continue
|
||||
result[name.lower().replace("-", "_")] = matching if len(matching) > 1 else matching[0]
|
||||
return result
|
||||
|
||||
|
||||
def records_for_name(records: list[dict[str, Any]], name: str) -> list[dict[str, Any]]:
|
||||
return [record for record in records if record.get("name") == name.upper()]
|
||||
|
||||
|
||||
def first_record(records: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
|
||||
matching = records_for_name(records, name)
|
||||
return matching[0] if matching else None
|
||||
|
||||
|
||||
def record_value(records: list[dict[str, Any]], name: str) -> str | None:
|
||||
record = first_record(records, name)
|
||||
if not record:
|
||||
return None
|
||||
return str(record.get("value") or "")
|
||||
|
||||
|
||||
def party_record(value: Any | None) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return {"value": text_value(value) or "", "params": params_to_json(getattr(value, "params", {}))}
|
||||
|
||||
|
||||
def party_for_export(party: dict[str, Any]) -> Any:
|
||||
value = str(party.get("value") or party.get("email") or "")
|
||||
normalized_params = {str(key).upper(): [str(item) for item in value] for key, value in params.items() if isinstance(value, list)}
|
||||
return name, normalized_params, value
|
||||
cal_address = vCalAddress(value)
|
||||
for key, item in flattened_params(party.get("params") if isinstance(party.get("params"), dict) else {}).items():
|
||||
cal_address.params[key] = item
|
||||
return cal_address
|
||||
|
||||
|
||||
def text_value(value: Any | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
raw = value.decode("utf-8") if isinstance(value, bytes) else str(value)
|
||||
return raw if raw != "" else None
|
||||
|
||||
|
||||
def ical_value(value: Any) -> str:
|
||||
if hasattr(value, "to_ical"):
|
||||
raw = value.to_ical()
|
||||
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return str(value)
|
||||
|
||||
|
||||
def params_to_json(params: Any) -> dict[str, list[str]]:
|
||||
result: dict[str, list[str]] = {}
|
||||
for key, value in dict(params or {}).items():
|
||||
values = value if isinstance(value, list) else [value]
|
||||
result[str(key).upper()] = [str(item) for item in values]
|
||||
return result
|
||||
|
||||
|
||||
def flattened_params(params: dict[str, Any]) -> dict[str, str]:
|
||||
result: dict[str, str] = {}
|
||||
for key, value in (params or {}).items():
|
||||
values = value if isinstance(value, list) else [value]
|
||||
if values:
|
||||
result[str(key).upper()] = ",".join(str(item) for item in values)
|
||||
return result
|
||||
|
||||
|
||||
def first_param(params: dict[str, list[str]], name: str) -> str | None:
|
||||
values = params.get(name.upper()) or []
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def json_value(value: Any) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return normalize_datetime(value).isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
if isinstance(value, timedelta):
|
||||
return int(value.total_seconds())
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def calendar_timezones(calendar: Calendar) -> list[str]:
|
||||
timezones: list[str] = []
|
||||
for component in calendar.walk("VTIMEZONE"):
|
||||
tzid = text_value(first_property_value(component, "TZID"))
|
||||
if tzid and tzid not in timezones:
|
||||
timezones.append(tzid)
|
||||
return timezones
|
||||
|
||||
|
||||
def metadata_value(event: Any, key: str) -> str | None:
|
||||
raw_metadata = getattr(event, "icalendar", None)
|
||||
metadata = raw_metadata if isinstance(raw_metadata, dict) else {}
|
||||
value = metadata.get(key)
|
||||
return str(value) if value else None
|
||||
|
||||
|
||||
def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime:
|
||||
value = normalize_datetime(value)
|
||||
if all_day:
|
||||
return value.date()
|
||||
if timezone_id:
|
||||
try:
|
||||
return value.astimezone(ZoneInfo(timezone_id))
|
||||
except ZoneInfoNotFoundError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def add_raw_property(component: Event | Alarm, default_name: str, record: dict[str, Any]) -> None:
|
||||
name = str(record.get("name") or default_name).upper()
|
||||
value = record.get("value")
|
||||
if not name or value is None:
|
||||
return
|
||||
raw_value = RawICalendarValue(str(value), flattened_params(record.get("params") or {}))
|
||||
component.add(name, raw_value, encode=False)
|
||||
|
||||
|
||||
def add_recurrence_id(component: Event, value: str) -> None:
|
||||
params: dict[str, list[str]] = {}
|
||||
raw_value = value
|
||||
if value.startswith("TZID=") and ":" in value:
|
||||
tzid, raw_value = value.removeprefix("TZID=").split(":", 1)
|
||||
params["TZID"] = [tzid]
|
||||
add_raw_property(component, "RECURRENCE-ID", {"name": "RECURRENCE-ID", "value": raw_value, "params": params})
|
||||
|
||||
|
||||
def add_preserved_properties(component: Event, metadata: dict[str, Any]) -> None:
|
||||
for record in metadata.get("properties") or []:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
name = str(record.get("name") or "").upper()
|
||||
if not name or name in GENERATED_EVENT_PROPERTIES:
|
||||
continue
|
||||
add_raw_property(component, name, record)
|
||||
|
||||
|
||||
def alarms_for_export(event: Any) -> list[Alarm]:
|
||||
reminders = getattr(event, "reminders", None) or []
|
||||
raw_metadata = getattr(event, "icalendar", None)
|
||||
if not reminders and isinstance(raw_metadata, dict):
|
||||
reminders = raw_metadata.get("alarms") or []
|
||||
alarms: list[Alarm] = []
|
||||
for reminder in reminders:
|
||||
if not isinstance(reminder, dict):
|
||||
continue
|
||||
alarm = Alarm()
|
||||
properties = reminder.get("properties")
|
||||
if isinstance(properties, list):
|
||||
for record in properties:
|
||||
if isinstance(record, dict):
|
||||
add_raw_property(alarm, str(record.get("name") or ""), record)
|
||||
else:
|
||||
alarm.add("action", reminder.get("action") or "DISPLAY")
|
||||
trigger = reminder.get("trigger")
|
||||
if isinstance(trigger, dict):
|
||||
add_raw_property(alarm, "TRIGGER", trigger)
|
||||
if reminder.get("description"):
|
||||
alarm.add("description", reminder["description"])
|
||||
if reminder.get("summary"):
|
||||
alarm.add("summary", reminder["summary"])
|
||||
if alarm:
|
||||
alarms.append(alarm)
|
||||
return alarms
|
||||
|
||||
|
||||
def normalized_rrule_for_export(value: dict[str, Any]) -> dict[str, list[Any]]:
|
||||
result: dict[str, list[Any]] = {}
|
||||
for key, item in value.items():
|
||||
result[str(key).lower()] = item if isinstance(item, list) else [item]
|
||||
return result
|
||||
|
||||
|
||||
def parse_key_value_property(value: str | None) -> dict[str, Any] | None:
|
||||
@@ -243,7 +557,8 @@ def parse_key_value_property(value: str | None) -> dict[str, Any] | None:
|
||||
for item in value.split(";"):
|
||||
key, sep, raw = item.partition("=")
|
||||
if sep:
|
||||
result[key.upper()] = raw.split(",") if "," in raw else raw
|
||||
values = raw.split(",") if "," in raw else [raw]
|
||||
result[key.upper()] = values if len(values) > 1 else values[0]
|
||||
return result or {"raw": value}
|
||||
|
||||
|
||||
@@ -257,8 +572,59 @@ def serialize_key_value_property(value: dict[str, Any]) -> str:
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def split_csv(value: str) -> list[str]:
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
def temporal_values_from_records(records: list[dict[str, Any]]) -> list[datetime]:
|
||||
values: list[datetime] = []
|
||||
for record in records:
|
||||
params = record.get("params") if isinstance(record.get("params"), dict) else {}
|
||||
for raw_value in str(record.get("value") or "").split(","):
|
||||
parsed = parse_ical_temporal(raw_value, params)
|
||||
if parsed is not None:
|
||||
values.append(parsed)
|
||||
return values
|
||||
|
||||
|
||||
def parse_ical_temporal(value: str, params: dict[str, Any]) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
value_type = first_param(params_to_json(params), "VALUE")
|
||||
if value_type == "DATE" or (len(value) == 8 and "T" not in value):
|
||||
return datetime.combine(datetime.strptime(value, "%Y%m%d").date(), time.min, tzinfo=timezone.utc)
|
||||
try:
|
||||
if value.endswith("Z"):
|
||||
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
|
||||
parsed = datetime.strptime(value, "%Y%m%dT%H%M%S")
|
||||
except ValueError:
|
||||
return None
|
||||
tzid = first_param(params_to_json(params), "TZID")
|
||||
if tzid:
|
||||
try:
|
||||
return parsed.replace(tzinfo=ZoneInfo(tzid)).astimezone(timezone.utc)
|
||||
except ZoneInfoNotFoundError:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def occurrence_duration(event: Any, *, start_at: datetime, end_at: datetime | None) -> timedelta:
|
||||
if end_at is not None:
|
||||
return end_at - start_at
|
||||
if event.duration_seconds is not None:
|
||||
return timedelta(seconds=int(event.duration_seconds))
|
||||
return timedelta(days=1) if event.all_day else timedelta(0)
|
||||
|
||||
|
||||
def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: datetime, range_end: datetime) -> bool:
|
||||
end_at = start_at + duration
|
||||
return end_at >= range_start and start_at <= range_end
|
||||
|
||||
|
||||
def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"uid": event.uid,
|
||||
"recurrence_id": format_ical_datetime(start_at),
|
||||
"start_at": start_at,
|
||||
"end_at": start_at + duration if duration else None,
|
||||
"all_day": event.all_day,
|
||||
}
|
||||
|
||||
|
||||
def int_or_zero(value: str | None) -> int:
|
||||
@@ -268,42 +634,20 @@ def int_or_zero(value: str | None) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def normalize_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def normalize_ics(ics: str) -> str:
|
||||
stripped = ics.strip().replace("\r\n", "\n").replace("\r", "\n")
|
||||
return "\r\n".join(stripped.split("\n")) + "\r\n"
|
||||
|
||||
|
||||
def serialize_content_line(name: str, params: dict[str, list[str]], value: str) -> str:
|
||||
param_text = "".join(f";{key}={','.join(escape_param(item) for item in items)}" for key, items in params.items() if items)
|
||||
return f"{name}{param_text}:{escape_text(value)}"
|
||||
|
||||
|
||||
def fold_ical_line(line: str, limit: int = 75) -> str:
|
||||
if len(line) <= limit:
|
||||
return line
|
||||
parts = [line[:limit]]
|
||||
line = line[limit:]
|
||||
while line:
|
||||
parts.append(" " + line[: limit - 1])
|
||||
line = line[limit - 1 :]
|
||||
return "\r\n".join(parts)
|
||||
|
||||
|
||||
def escape_text(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("\n", "\\n").replace(";", "\\;").replace(",", "\\,")
|
||||
|
||||
|
||||
def unescape_text(value: str) -> str:
|
||||
return value.replace("\\n", "\n").replace("\\N", "\n").replace("\\;", ";").replace("\\,", ",").replace("\\\\", "\\")
|
||||
|
||||
|
||||
def escape_param(value: str) -> str:
|
||||
if any(char in value for char in [":", ";", ","]):
|
||||
return '"' + value.replace('"', "'") + '"'
|
||||
return value
|
||||
def format_ical_datetime(value: datetime) -> str:
|
||||
return normalize_datetime(value).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def http_last_modified(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return format_datetime(value.astimezone(timezone.utc), usegmt=True)
|
||||
return format_datetime(normalize_datetime(value), usegmt=True)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
@@ -64,11 +65,13 @@ ROLE_TEMPLATES = (
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||
|
||||
return {
|
||||
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
|
||||
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
|
||||
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +87,7 @@ def _calendar_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="calendar",
|
||||
name="Calendar",
|
||||
version="0.1.4",
|
||||
version="0.1.5",
|
||||
dependencies=("access",),
|
||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
||||
permissions=PERMISSIONS,
|
||||
@@ -101,6 +104,24 @@ manifest = ModuleManifest(
|
||||
module_id="calendar",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
calendar_models.CalendarCollection,
|
||||
calendar_models.CalendarEvent,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
label="Calendar",
|
||||
),
|
||||
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
calendar_models.CalendarCollection,
|
||||
calendar_models.CalendarEvent,
|
||||
calendar_models.CalendarSyncCredential,
|
||||
calendar_models.CalendarSyncSource,
|
||||
label="Calendar",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""CalDAV sync sources
|
||||
|
||||
Revision ID: 8d9e0f1a2b3c
|
||||
Revises: 7c8d9e0f1a2b
|
||||
Create Date: 2026-07-07 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "8d9e0f1a2b3c"
|
||||
down_revision = "7c8d9e0f1a2b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "calendar_sync_sources" in tables:
|
||||
return
|
||||
op.create_table(
|
||||
"calendar_sync_sources",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("calendar_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("collection_url", sa.String(length=1000), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("auth_type", sa.String(length=30), nullable=False),
|
||||
sa.Column("username", sa.String(length=255), nullable=True),
|
||||
sa.Column("credential_ref", sa.String(length=255), nullable=True),
|
||||
sa.Column("sync_token", sa.Text(), nullable=True),
|
||||
sa.Column("ctag", sa.String(length=255), nullable=True),
|
||||
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_status", sa.String(length=30), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_sync_sources_calendar_id_calendar_collections"), ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_sources_tenant_id_tenants"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_sources")),
|
||||
)
|
||||
for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"):
|
||||
op.create_index(op.f(f"ix_calendar_sync_sources_{column}"), "calendar_sync_sources", [column], unique=False)
|
||||
op.create_index("ix_calendar_sync_sources_calendar", "calendar_sync_sources", ["tenant_id", "calendar_id", "source_kind"], unique=False)
|
||||
op.create_index(
|
||||
"uq_calendar_sync_sources_active_url",
|
||||
"calendar_sync_sources",
|
||||
["tenant_id", "source_kind", "collection_url"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("deleted_at IS NULL"),
|
||||
postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "calendar_sync_sources" not in tables:
|
||||
return
|
||||
for name in (
|
||||
"uq_calendar_sync_sources_active_url",
|
||||
"ix_calendar_sync_sources_calendar",
|
||||
op.f("ix_calendar_sync_sources_deleted_at"),
|
||||
op.f("ix_calendar_sync_sources_source_kind"),
|
||||
op.f("ix_calendar_sync_sources_calendar_id"),
|
||||
op.f("ix_calendar_sync_sources_tenant_id"),
|
||||
):
|
||||
op.drop_index(name, table_name="calendar_sync_sources")
|
||||
op.drop_table("calendar_sync_sources")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""CalDAV credentials and outbound sync metadata
|
||||
|
||||
Revision ID: 9e0f1a2b3c4d
|
||||
Revises: 8d9e0f1a2b3c
|
||||
Create Date: 2026-07-07 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "9e0f1a2b3c4d"
|
||||
down_revision = "8d9e0f1a2b3c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
tables = set(inspector.get_table_names())
|
||||
if "calendar_sync_sources" in tables:
|
||||
columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")}
|
||||
add_column_if_missing(columns, "sync_enabled", sa.Column("sync_enabled", sa.Boolean(), server_default=sa.true(), nullable=False))
|
||||
add_column_if_missing(columns, "sync_interval_seconds", sa.Column("sync_interval_seconds", sa.Integer(), server_default="900", nullable=False))
|
||||
add_column_if_missing(columns, "sync_direction", sa.Column("sync_direction", sa.String(length=30), server_default="two_way", nullable=False))
|
||||
add_column_if_missing(columns, "conflict_policy", sa.Column("conflict_policy", sa.String(length=30), server_default="etag", nullable=False))
|
||||
add_column_if_missing(columns, "last_attempt_at", sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True))
|
||||
add_column_if_missing(columns, "next_sync_at", sa.Column("next_sync_at", sa.DateTime(timezone=True), nullable=True))
|
||||
indexes = {index["name"] for index in inspector.get_indexes("calendar_sync_sources")}
|
||||
if "ix_calendar_sync_sources_sync_enabled" not in indexes:
|
||||
op.create_index(op.f("ix_calendar_sync_sources_sync_enabled"), "calendar_sync_sources", ["sync_enabled"], unique=False)
|
||||
if "ix_calendar_sync_sources_next_sync_at" not in indexes:
|
||||
op.create_index(op.f("ix_calendar_sync_sources_next_sync_at"), "calendar_sync_sources", ["next_sync_at"], unique=False)
|
||||
|
||||
if "calendar_sync_credentials" not in tables:
|
||||
op.create_table(
|
||||
"calendar_sync_credentials",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("credential_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("label", sa.String(length=255), nullable=True),
|
||||
sa.Column("secret_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_sync_credentials_created_by_user_id_users"), ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_credentials_tenant_id_tenants"), ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_credentials")),
|
||||
)
|
||||
for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"):
|
||||
op.create_index(op.f(f"ix_calendar_sync_credentials_{column}"), "calendar_sync_credentials", [column], unique=False)
|
||||
op.create_index("ix_calendar_sync_credentials_tenant_kind", "calendar_sync_credentials", ["tenant_id", "credential_kind"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "calendar_sync_credentials" in tables:
|
||||
for name in (
|
||||
"ix_calendar_sync_credentials_tenant_kind",
|
||||
op.f("ix_calendar_sync_credentials_deleted_at"),
|
||||
op.f("ix_calendar_sync_credentials_created_by_user_id"),
|
||||
op.f("ix_calendar_sync_credentials_credential_kind"),
|
||||
op.f("ix_calendar_sync_credentials_tenant_id"),
|
||||
):
|
||||
op.drop_index(name, table_name="calendar_sync_credentials")
|
||||
op.drop_table("calendar_sync_credentials")
|
||||
if "calendar_sync_sources" in tables:
|
||||
indexes = {index["name"] for index in inspector.get_indexes("calendar_sync_sources")}
|
||||
if "ix_calendar_sync_sources_next_sync_at" in indexes:
|
||||
op.drop_index(op.f("ix_calendar_sync_sources_next_sync_at"), table_name="calendar_sync_sources")
|
||||
if "ix_calendar_sync_sources_sync_enabled" in indexes:
|
||||
op.drop_index(op.f("ix_calendar_sync_sources_sync_enabled"), table_name="calendar_sync_sources")
|
||||
columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")}
|
||||
for column in ("next_sync_at", "last_attempt_at", "conflict_policy", "sync_direction", "sync_interval_seconds", "sync_enabled"):
|
||||
if column in columns:
|
||||
op.drop_column("calendar_sync_sources", column)
|
||||
|
||||
|
||||
def add_column_if_missing(existing: set[str], name: str, column: sa.Column) -> None:
|
||||
if name in existing:
|
||||
return
|
||||
op.add_column("calendar_sync_sources", column)
|
||||
existing.add(name)
|
||||
@@ -2,13 +2,23 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
|
||||
from govoplan_calendar.backend.caldav import CalDAVError
|
||||
from govoplan_calendar.backend.schemas import (
|
||||
CalendarCalDavDueSyncItemResponse,
|
||||
CalendarCalDavDueSyncResponse,
|
||||
CalendarCalDavSourceCreateRequest,
|
||||
CalendarCalDavSourceListResponse,
|
||||
CalendarCalDavSourceResponse,
|
||||
CalendarCalDavSourceUpdateRequest,
|
||||
CalendarCalDavSyncRequest,
|
||||
CalendarCalDavSyncResponse,
|
||||
CalendarCollectionCreateRequest,
|
||||
CalendarCollectionDeleteRequest,
|
||||
CalendarCollectionListResponse,
|
||||
CalendarCollectionResponse,
|
||||
CalendarCollectionUpdateRequest,
|
||||
@@ -16,21 +26,33 @@ from govoplan_calendar.backend.schemas import (
|
||||
CalendarEventListResponse,
|
||||
CalendarEventResponse,
|
||||
CalendarEventUpdateRequest,
|
||||
CalendarFreeBusyRequest,
|
||||
CalendarFreeBusyResponse,
|
||||
CalendarIcsImportRequest,
|
||||
)
|
||||
from govoplan_calendar.backend.service import (
|
||||
CalendarError,
|
||||
caldav_source_response,
|
||||
caldav_sync_response,
|
||||
calendar_response,
|
||||
create_calendar,
|
||||
create_caldav_source,
|
||||
create_event,
|
||||
delete_calendar,
|
||||
delete_caldav_source,
|
||||
delete_event,
|
||||
event_response,
|
||||
get_caldav_source,
|
||||
get_event,
|
||||
import_ics_event,
|
||||
list_freebusy,
|
||||
list_caldav_sources,
|
||||
list_calendars,
|
||||
list_events,
|
||||
sync_due_caldav_sources,
|
||||
sync_caldav_source,
|
||||
update_calendar,
|
||||
update_caldav_source,
|
||||
update_event,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
@@ -56,6 +78,10 @@ def _event_response(event) -> CalendarEventResponse:
|
||||
return CalendarEventResponse.model_validate(event_response(event))
|
||||
|
||||
|
||||
def _caldav_source_response(source) -> CalendarCalDavSourceResponse:
|
||||
return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source))
|
||||
|
||||
|
||||
@router.get("/calendars", response_model=CalendarCollectionListResponse)
|
||||
def api_list_calendars(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
@@ -105,12 +131,13 @@ def api_update_calendar(
|
||||
@router.delete("/calendars/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_calendar(
|
||||
calendar_id: str,
|
||||
payload: CalendarCollectionDeleteRequest | None = Body(default=None),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id)
|
||||
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
@@ -118,6 +145,122 @@ def api_delete_calendar(
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse)
|
||||
def api_list_caldav_sources(
|
||||
calendar_id: str | None = None,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:read")
|
||||
sources = list_caldav_sources(session, tenant_id=principal.tenant_id, calendar_id=calendar_id)
|
||||
return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in sources])
|
||||
|
||||
|
||||
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||
def api_create_caldav_source(
|
||||
payload: CalendarCalDavSourceCreateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
source = create_caldav_source(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
||||
session.commit()
|
||||
session.refresh(source)
|
||||
return _caldav_source_response(source)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse)
|
||||
def api_update_caldav_source(
|
||||
source_id: str,
|
||||
payload: CalendarCalDavSourceUpdateRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
|
||||
session.commit()
|
||||
session.refresh(source)
|
||||
return _caldav_source_response(source)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def api_delete_caldav_source(
|
||||
source_id: str,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:calendar:admin")
|
||||
try:
|
||||
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except CalendarError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
|
||||
def api_sync_caldav_source(
|
||||
source_id: str,
|
||||
payload: CalendarCalDavSyncRequest | None = None,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:import")
|
||||
payload = payload or CalendarCalDavSyncRequest()
|
||||
try:
|
||||
source, stats = sync_caldav_source(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
source_id=source_id,
|
||||
password=payload.password,
|
||||
bearer_token=payload.bearer_token,
|
||||
force_full=payload.force_full,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(source)
|
||||
return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats))
|
||||
except (CalendarError, CalDAVError, ICalendarError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse)
|
||||
def api_sync_due_caldav_sources(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:event:import")
|
||||
results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
|
||||
session.commit()
|
||||
return CalendarCalDavDueSyncResponse(
|
||||
results=[
|
||||
CalendarCalDavDueSyncItemResponse(
|
||||
source_id=item.source_id,
|
||||
calendar_id=item.calendar_id,
|
||||
status=item.status,
|
||||
error=item.error,
|
||||
created=item.stats.created if item.stats else 0,
|
||||
updated=item.stats.updated if item.stats else 0,
|
||||
deleted=item.stats.deleted if item.stats else 0,
|
||||
unchanged=item.stats.unchanged if item.stats else 0,
|
||||
fetched=item.stats.fetched if item.stats else 0,
|
||||
)
|
||||
for item in results
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/events", response_model=CalendarEventListResponse)
|
||||
def api_list_events(
|
||||
calendar_id: str | None = None,
|
||||
@@ -242,3 +385,23 @@ def api_export_ics_event(
|
||||
"Last-Modified": http_last_modified(event.updated_at),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/availability/freebusy", response_model=CalendarFreeBusyResponse)
|
||||
def api_freebusy(
|
||||
payload: CalendarFreeBusyRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "calendar:availability:read")
|
||||
try:
|
||||
busy = list_freebusy(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
start_at=payload.start_at,
|
||||
end_at=payload.end_at,
|
||||
calendar_ids=payload.calendar_ids,
|
||||
)
|
||||
return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy)
|
||||
except CalendarError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
@@ -3,11 +3,15 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
|
||||
|
||||
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
||||
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
||||
CalendarDeleteEventAction = Literal["delete", "move"]
|
||||
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
|
||||
CalendarSyncDirection = Literal["inbound", "two_way"]
|
||||
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
|
||||
|
||||
|
||||
class CalendarCollectionCreateRequest(BaseModel):
|
||||
@@ -37,6 +41,14 @@ class CalendarCollectionUpdateRequest(BaseModel):
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CalendarCollectionDeleteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
event_action: CalendarDeleteEventAction = "delete"
|
||||
target_calendar_id: str | None = None
|
||||
make_target_default: bool = False
|
||||
|
||||
|
||||
class CalendarCollectionResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
@@ -58,6 +70,113 @@ class CalendarCollectionListResponse(BaseModel):
|
||||
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarCalDavSourceCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
calendar_id: str
|
||||
collection_url: str = Field(min_length=1, max_length=1000)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
auth_type: CalendarSyncAuthType = "none"
|
||||
username: str | None = Field(default=None, max_length=255)
|
||||
credential_ref: str | None = Field(default=None, max_length=255)
|
||||
password: SecretStr | None = None
|
||||
bearer_token: SecretStr | None = None
|
||||
sync_enabled: bool = True
|
||||
sync_interval_seconds: int = Field(default=900, ge=60)
|
||||
sync_direction: CalendarSyncDirection = "two_way"
|
||||
conflict_policy: CalendarCalDavConflictPolicy = "etag"
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarCalDavSourceUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
calendar_id: str | None = None
|
||||
collection_url: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
auth_type: CalendarSyncAuthType | None = None
|
||||
username: str | None = Field(default=None, max_length=255)
|
||||
credential_ref: str | None = Field(default=None, max_length=255)
|
||||
password: SecretStr | None = None
|
||||
bearer_token: SecretStr | None = None
|
||||
sync_enabled: bool | None = None
|
||||
sync_interval_seconds: int | None = Field(default=None, ge=60)
|
||||
sync_direction: CalendarSyncDirection | None = None
|
||||
conflict_policy: CalendarCalDavConflictPolicy | None = None
|
||||
sync_token: str | None = None
|
||||
ctag: str | None = Field(default=None, max_length=255)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CalendarCalDavSourceResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
calendar_id: str
|
||||
source_kind: str
|
||||
collection_url: str
|
||||
display_name: str | None = None
|
||||
auth_type: str
|
||||
username: str | None = None
|
||||
credential_ref: str | None = None
|
||||
has_credential: bool = False
|
||||
sync_enabled: bool
|
||||
sync_interval_seconds: int
|
||||
sync_direction: str
|
||||
conflict_policy: str
|
||||
sync_token: str | None = None
|
||||
ctag: str | None = None
|
||||
last_attempt_at: datetime | None = None
|
||||
last_synced_at: datetime | None = None
|
||||
next_sync_at: datetime | None = None
|
||||
last_status: str | None = None
|
||||
last_error: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CalendarCalDavSourceListResponse(BaseModel):
|
||||
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarCalDavSyncRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
password: str | None = None
|
||||
bearer_token: str | None = None
|
||||
force_full: bool = False
|
||||
|
||||
|
||||
class CalendarCalDavSyncResponse(BaseModel):
|
||||
source: CalendarCalDavSourceResponse
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
deleted: int = 0
|
||||
unchanged: int = 0
|
||||
fetched: int = 0
|
||||
full_sync: bool = False
|
||||
used_sync_token: bool = False
|
||||
sync_token: str | None = None
|
||||
ctag: str | None = None
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarCalDavDueSyncItemResponse(BaseModel):
|
||||
source_id: str
|
||||
calendar_id: str
|
||||
status: str
|
||||
error: str | None = None
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
deleted: int = 0
|
||||
unchanged: int = 0
|
||||
fetched: int = 0
|
||||
|
||||
|
||||
class CalendarCalDavDueSyncResponse(BaseModel):
|
||||
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarEventCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -164,6 +283,32 @@ class CalendarEventListResponse(BaseModel):
|
||||
events: list[CalendarEventResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarFreeBusyRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
calendar_ids: list[str] | None = None
|
||||
|
||||
|
||||
class CalendarBusyBlockResponse(BaseModel):
|
||||
calendar_id: str
|
||||
event_id: str
|
||||
uid: str
|
||||
recurrence_id: str | None = None
|
||||
start_at: datetime
|
||||
end_at: datetime | None = None
|
||||
all_day: bool = False
|
||||
transparency: str
|
||||
status: str
|
||||
|
||||
|
||||
class CalendarFreeBusyResponse(BaseModel):
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
busy: list[CalendarBusyBlockResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CalendarIcsImportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
27
src/govoplan_calendar/backend/tasks.py
Normal file
27
src/govoplan_calendar/backend/tasks.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task(name="govoplan_calendar.sync_due_caldav_sources")
|
||||
def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]:
|
||||
from govoplan_calendar.backend.service import sync_due_caldav_sources
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
with get_database().SessionLocal() as session:
|
||||
results = sync_due_caldav_sources(session, tenant_id=tenant_id, limit=limit)
|
||||
session.commit()
|
||||
return [
|
||||
{
|
||||
"source_id": item.source_id,
|
||||
"calendar_id": item.calendar_id,
|
||||
"status": item.status,
|
||||
"error": item.error,
|
||||
"created": item.stats.created if item.stats else 0,
|
||||
"updated": item.stats.updated if item.stats else 0,
|
||||
"deleted": item.stats.deleted if item.stats else 0,
|
||||
"unchanged": item.stats.unchanged if item.stats else 0,
|
||||
"fetched": item.stats.fetched if item.stats else 0,
|
||||
}
|
||||
for item in results
|
||||
]
|
||||
435
tests/test_caldav.py
Normal file
435
tests/test_caldav.py
Normal file
@@ -0,0 +1,435 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
||||
from govoplan_calendar.backend.caldav import CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
||||
from govoplan_calendar.backend.service import (
|
||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||
CalendarError,
|
||||
caldav_client_for_source,
|
||||
create_calendar,
|
||||
create_caldav_source,
|
||||
create_event,
|
||||
delete_event,
|
||||
list_freebusy,
|
||||
sync_caldav_source,
|
||||
sync_due_caldav_sources,
|
||||
update_event,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
|
||||
|
||||
class FakeCalDAVClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
list_result: CalDAVReportResult | None = None,
|
||||
sync_result: CalDAVReportResult | None = None,
|
||||
objects: dict[str, str] | None = None,
|
||||
put_etags: list[str | None] | None = None,
|
||||
fail_precondition: bool = False,
|
||||
) -> None:
|
||||
self.list_result = list_result or CalDAVReportResult()
|
||||
self.sync_result = sync_result
|
||||
self.objects = objects or {}
|
||||
self.put_etags = put_etags or ['"etag-written"']
|
||||
self.fail_precondition = fail_precondition
|
||||
self.fetched: list[str] = []
|
||||
self.puts: list[dict[str, object]] = []
|
||||
self.deletes: list[dict[str, object]] = []
|
||||
|
||||
def propfind_collection(self) -> CalDAVReportResult:
|
||||
return CalDAVReportResult(sync_token="prop-token", ctag="ctag-1")
|
||||
|
||||
def list_objects(self) -> CalDAVReportResult:
|
||||
return self.list_result
|
||||
|
||||
def sync_collection(self, sync_token: str) -> CalDAVReportResult:
|
||||
if self.sync_result is None:
|
||||
raise AssertionError(f"Unexpected sync_collection call with {sync_token}")
|
||||
return self.sync_result
|
||||
|
||||
def fetch_object(self, href: str) -> str:
|
||||
self.fetched.append(href)
|
||||
return self.objects[href]
|
||||
|
||||
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
|
||||
if self.fail_precondition:
|
||||
raise CalDAVPreconditionFailed("changed")
|
||||
self.puts.append({"href": href, "ics": ics, "etag": etag, "create": create, "overwrite": overwrite})
|
||||
result_etag = self.put_etags.pop(0) if self.put_etags else None
|
||||
return CalDAVWriteResult(href=href, etag=result_etag, status=201 if create else 204)
|
||||
|
||||
def delete_object(self, href: str, *, etag: str | None = None, overwrite: bool = False) -> CalDAVWriteResult:
|
||||
if self.fail_precondition:
|
||||
raise CalDAVPreconditionFailed("changed")
|
||||
self.deletes.append({"href": href, "etag": etag, "overwrite": overwrite})
|
||||
return CalDAVWriteResult(href=href, status=204)
|
||||
|
||||
|
||||
class CalDAVParsingTests(unittest.TestCase):
|
||||
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
|
||||
result = parse_multistatus(
|
||||
b"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:sync-token>token-2</D:sync-token>
|
||||
<D:response>
|
||||
<D:href>/cal/event-1.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:getetag>"etag-1"</D:getetag>
|
||||
<C:calendar-data>BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
UID:event-1@example.test
|
||||
DTSTART:20260708T090000Z
|
||||
SUMMARY:One
|
||||
END:VEVENT
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
<D:response>
|
||||
<D:href>/cal/event-2.ics</D:href>
|
||||
<D:propstat>
|
||||
<D:prop><D:getetag/></D:prop>
|
||||
<D:status>HTTP/1.1 404 Not Found</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(result.sync_token, "token-2")
|
||||
self.assertEqual(result.objects[0].href, "/cal/event-1.ics")
|
||||
self.assertEqual(result.objects[0].etag, '"etag-1"')
|
||||
self.assertIn("BEGIN:VEVENT", result.objects[0].calendar_data or "")
|
||||
self.assertTrue(result.objects[1].deleted)
|
||||
|
||||
|
||||
class CalDAVSyncTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
Base.metadata.drop_all(bind=self.engine)
|
||||
|
||||
def test_source_creation_encrypts_credential_and_resolves_client_secret(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
|
||||
source = create_caldav_source(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCalDavSourceCreateRequest(
|
||||
calendar_id=calendar.id,
|
||||
collection_url="https://dav.example.test/cal",
|
||||
auth_type="basic",
|
||||
username="ada",
|
||||
password="secret",
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
credential = session.query(CalendarSyncCredential).one()
|
||||
client = caldav_client_for_source(session, source)
|
||||
|
||||
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
|
||||
self.assertNotEqual(credential.secret_encrypted, "secret")
|
||||
self.assertEqual(client.username, "ada")
|
||||
self.assertEqual(client.password, "secret")
|
||||
|
||||
def test_due_sync_runs_due_sources_and_reschedules(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
source = create_caldav_source(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"),
|
||||
)
|
||||
source.next_sync_at = datetime(2026, 7, 7, 8, 0, tzinfo=timezone.utc)
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(list_result=CalDAVReportResult(sync_token="token-1"))
|
||||
results = sync_due_caldav_sources(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc),
|
||||
client_factory=lambda _source: fake,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(results[0].status, "ok")
|
||||
self.assertEqual(source.sync_token, "token-1")
|
||||
next_sync_at = source.next_sync_at if source.next_sync_at.tzinfo else source.next_sync_at.replace(tzinfo=timezone.utc)
|
||||
self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc))
|
||||
|
||||
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"'])
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
event = create_event(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=calendar.id,
|
||||
uid="event-1@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),
|
||||
),
|
||||
)
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(len(fake.puts), 2)
|
||||
self.assertTrue(fake.puts[0]["create"])
|
||||
self.assertEqual(fake.puts[1]["etag"], '"etag-1"')
|
||||
self.assertFalse(fake.puts[1]["create"])
|
||||
self.assertEqual(event.source_kind, "caldav")
|
||||
self.assertEqual(event.etag, '"etag-2"')
|
||||
self.assertIn("SUMMARY:Updated", event.raw_ics or "")
|
||||
|
||||
def test_update_event_reports_remote_etag_conflict(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
|
||||
event = caldav_event(calendar.id, summary="Remote", href="https://dav.example.test/cal/event-1.ics", etag='"etag-1"')
|
||||
session.add(event)
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(fail_precondition=True)
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
with self.assertRaisesRegex(CalendarError, "changed remotely"):
|
||||
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
|
||||
|
||||
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
|
||||
href = "https://dav.example.test/cal/series.ics"
|
||||
master = caldav_event(calendar.id, summary="Master", href=href, etag='"etag-1"')
|
||||
override = caldav_event(calendar.id, summary="Override", href=href, etag='"etag-1"', recurrence_id="20260715T090000Z")
|
||||
session.add_all([master, override])
|
||||
session.commit()
|
||||
|
||||
fake = FakeCalDAVClient(put_etags=['"etag-2"'])
|
||||
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
|
||||
delete_event(session, tenant_id="tenant-1", event_id=override.id)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(len(fake.puts), 1)
|
||||
self.assertEqual(fake.deletes, [])
|
||||
self.assertIn("SUMMARY:Master", fake.puts[0]["ics"])
|
||||
self.assertNotIn("SUMMARY:Override", fake.puts[0]["ics"])
|
||||
self.assertEqual(master.etag, '"etag-2"')
|
||||
self.assertIsNotNone(override.deleted_at)
|
||||
|
||||
def test_freebusy_expands_recurring_events_and_skips_transparent_items(self) -> None:
|
||||
session = self.Session()
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Local"))
|
||||
create_event(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=calendar.id,
|
||||
uid="busy@example.test",
|
||||
summary="Busy",
|
||||
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": "2"},
|
||||
),
|
||||
)
|
||||
create_event(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarEventCreateRequest(
|
||||
calendar_id=calendar.id,
|
||||
uid="free@example.test",
|
||||
summary="Free",
|
||||
transparency="TRANSPARENT",
|
||||
start_at=datetime(2026, 7, 8, 11, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 8, 12, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
busy = list_freebusy(
|
||||
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([item["uid"] for item in busy], ["busy@example.test", "busy@example.test"])
|
||||
self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc))
|
||||
|
||||
def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None:
|
||||
session = self.Session()
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
|
||||
session.add(tenant)
|
||||
calendar = create_calendar(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCollectionCreateRequest(name="Remote", metadata={}),
|
||||
)
|
||||
source = create_caldav_source(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
href = "/cal/series.ics"
|
||||
client = FakeCalDAVClient(
|
||||
list_result=CalDAVReportResult(
|
||||
objects=[CalDAVObject(href=href, etag='"etag-1"', calendar_data=None)],
|
||||
sync_token="token-1",
|
||||
ctag="ctag-2",
|
||||
),
|
||||
objects={href: recurring_resource()},
|
||||
)
|
||||
source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client)
|
||||
session.commit()
|
||||
|
||||
events = session.query(CalendarEvent).filter(CalendarEvent.calendar_id == calendar.id, CalendarEvent.deleted_at.is_(None)).order_by(CalendarEvent.recurrence_id.asc()).all()
|
||||
self.assertEqual(stats.created, 2)
|
||||
self.assertEqual(stats.fetched, 1)
|
||||
self.assertTrue(stats.full_sync)
|
||||
self.assertEqual(source.sync_token, "token-1")
|
||||
self.assertEqual(source.ctag, "ctag-2")
|
||||
self.assertEqual(client.fetched, [href])
|
||||
self.assertEqual({event.summary for event in events}, {"Series", "Moved instance"})
|
||||
self.assertEqual({event.source_href for event in events}, {"https://dav.example.test/cal/series.ics"})
|
||||
self.assertEqual(calendar.metadata_["source_kind"], "caldav")
|
||||
|
||||
def test_sync_token_deletion_soft_deletes_remote_resource_events(self) -> None:
|
||||
session = self.Session()
|
||||
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
|
||||
session.add(tenant)
|
||||
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
|
||||
source = create_caldav_source(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
user_id=None,
|
||||
payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"),
|
||||
)
|
||||
source.sync_token = "token-1"
|
||||
session.add(
|
||||
CalendarEvent(
|
||||
tenant_id="tenant-1",
|
||||
calendar_id=calendar.id,
|
||||
uid="event-1@example.test",
|
||||
recurrence_id=None,
|
||||
summary="Remote",
|
||||
status="CONFIRMED",
|
||||
transparency="OPAQUE",
|
||||
classification="PUBLIC",
|
||||
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
|
||||
all_day=False,
|
||||
attendees=[],
|
||||
categories=[],
|
||||
rdate=[],
|
||||
exdate=[],
|
||||
reminders=[],
|
||||
attachments=[],
|
||||
related_to=[],
|
||||
source_kind="caldav",
|
||||
source_href="https://dav.example.test/cal/event-1.ics",
|
||||
icalendar={},
|
||||
metadata_={"caldav": {"source_id": source.id}},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
client = FakeCalDAVClient(sync_result=CalDAVReportResult(objects=[CalDAVObject(href="/cal/event-1.ics", deleted=True)], sync_token="token-2"))
|
||||
source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client)
|
||||
session.commit()
|
||||
|
||||
event = session.query(CalendarEvent).filter(CalendarEvent.uid == "event-1@example.test").one()
|
||||
self.assertTrue(stats.used_sync_token)
|
||||
self.assertEqual(stats.deleted, 1)
|
||||
self.assertEqual(source.sync_token, "token-2")
|
||||
self.assertIsNotNone(event.deleted_at)
|
||||
|
||||
|
||||
def recurring_resource() -> str:
|
||||
return """BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:series@example.test
|
||||
DTSTART;TZID=Europe/Berlin:20260708T090000
|
||||
DTEND;TZID=Europe/Berlin:20260708T100000
|
||||
RRULE:FREQ=WEEKLY;COUNT=2
|
||||
SUMMARY:Series
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:series@example.test
|
||||
RECURRENCE-ID;TZID=Europe/Berlin:20260715T090000
|
||||
DTSTART;TZID=Europe/Berlin:20260715T110000
|
||||
DTEND;TZID=Europe/Berlin:20260715T120000
|
||||
SUMMARY:Moved instance
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
|
||||
|
||||
def caldav_event(calendar_id: str, *, summary: str, href: str, etag: str, recurrence_id: str | None = None) -> CalendarEvent:
|
||||
return CalendarEvent(
|
||||
tenant_id="tenant-1",
|
||||
calendar_id=calendar_id,
|
||||
uid="series@example.test" if recurrence_id else "event-1@example.test",
|
||||
recurrence_id=recurrence_id,
|
||||
summary=summary,
|
||||
status="CONFIRMED",
|
||||
transparency="OPAQUE",
|
||||
classification="PUBLIC",
|
||||
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
|
||||
all_day=False,
|
||||
attendees=[],
|
||||
categories=[],
|
||||
rdate=[],
|
||||
exdate=[],
|
||||
reminders=[],
|
||||
attachments=[],
|
||||
related_to=[],
|
||||
source_kind="caldav",
|
||||
source_href=href,
|
||||
etag=etag,
|
||||
icalendar={},
|
||||
metadata_={},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_calendar.backend.ical import event_to_ics, parse_vevent
|
||||
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent
|
||||
|
||||
|
||||
class ICalendarParsingTests(unittest.TestCase):
|
||||
@@ -48,6 +50,154 @@ END:VCALENDAR
|
||||
self.assertTrue(event["all_day"])
|
||||
self.assertEqual(event["start_at"].hour, 0)
|
||||
|
||||
def test_parse_advanced_vevent_fields_and_valarm(self) -> None:
|
||||
event = parse_vevent(
|
||||
"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:REQUEST
|
||||
BEGIN:VEVENT
|
||||
UID:event-advanced@example.test
|
||||
DTSTAMP:20260707T080000Z
|
||||
CREATED:20260707T070000Z
|
||||
LAST-MODIFIED:20260707T073000Z
|
||||
DTSTART;TZID=Europe/Berlin:20260708T090000
|
||||
DURATION:PT1H30M
|
||||
SUMMARY:Planning
|
||||
DESCRIPTION:Line one\\nLine two
|
||||
LOCATION:Room\\; 1
|
||||
PRIORITY:5
|
||||
URL:https://example.test/events/event-advanced
|
||||
GEO:52.5200;13.4050
|
||||
COMMENT:First comment
|
||||
RESOURCES:Projector,Room
|
||||
REQUEST-STATUS:2.0;Success
|
||||
ORGANIZER;CN=Orga:mailto:orga@example.test
|
||||
ATTENDEE;CN=Ada;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:ada@example.test
|
||||
RRULE:FREQ=WEEKLY;COUNT=2
|
||||
EXDATE;TZID=Europe/Berlin:20260715T090000
|
||||
RDATE;TZID=Europe/Berlin:20260722T090000
|
||||
ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf
|
||||
RELATED-TO;RELTYPE=PARENT:parent@example.test
|
||||
X-GOVOPLAN-CUSTOM:kept
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Reminder
|
||||
TRIGGER:-PT15M
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
|
||||
self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
|
||||
self.assertEqual(event["duration_seconds"], 5400)
|
||||
self.assertEqual(event["description"], "Line one\nLine two")
|
||||
self.assertEqual(event["location"], "Room; 1")
|
||||
self.assertEqual(event["timezone"], "Europe/Berlin")
|
||||
self.assertEqual(event["organizer"]["params"]["CN"], ["Orga"])
|
||||
self.assertEqual(event["attendees"][0]["params"]["PARTSTAT"], ["ACCEPTED"])
|
||||
self.assertEqual(event["icalendar"]["method"], "REQUEST")
|
||||
self.assertEqual(event["icalendar"]["standard"]["priority"]["value"], "5")
|
||||
self.assertEqual(event["icalendar"]["standard"]["url"]["value"], "https://example.test/events/event-advanced")
|
||||
self.assertEqual(event["reminders"][0]["trigger"]["value"], "-PT15M")
|
||||
self.assertEqual(event["reminders"][0]["trigger"]["decoded"], -900)
|
||||
property_names = [item["name"] for item in event["icalendar"]["properties"]]
|
||||
self.assertIn("REQUEST-STATUS", property_names)
|
||||
self.assertIn("X-GOVOPLAN-CUSTOM", property_names)
|
||||
|
||||
def test_generated_ics_preserves_unmapped_properties_and_alarms(self) -> None:
|
||||
event = SimpleNamespace(
|
||||
uid="event-export@example.test",
|
||||
start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
|
||||
end_at=datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc),
|
||||
duration_seconds=None,
|
||||
all_day=False,
|
||||
timezone="Europe/Berlin",
|
||||
summary="Generated",
|
||||
sequence=4,
|
||||
status="CONFIRMED",
|
||||
transparency="OPAQUE",
|
||||
classification="PUBLIC",
|
||||
description=None,
|
||||
location=None,
|
||||
categories=["GovOPlaN"],
|
||||
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
|
||||
organizer={"value": "mailto:orga@example.test", "params": {"CN": ["Orga"]}},
|
||||
attendees=[{"value": "mailto:ada@example.test", "params": {"CN": ["Ada"], "ROLE": ["REQ-PARTICIPANT"]}}],
|
||||
rdate=[],
|
||||
exdate=[],
|
||||
reminders=[],
|
||||
attachments=[{"name": "ATTACH", "value": "https://example.test/a.pdf", "params": {"FMTTYPE": ["application/pdf"]}}],
|
||||
related_to=[{"name": "RELATED-TO", "value": "parent@example.test", "params": {"RELTYPE": ["PARENT"]}}],
|
||||
icalendar={
|
||||
"method": "PUBLISH",
|
||||
"properties": [
|
||||
{"name": "URL", "value": "https://example.test/events/event-export", "params": {}},
|
||||
{"name": "X-GOVOPLAN-CUSTOM", "value": "kept", "params": {}},
|
||||
{"name": "SUMMARY", "value": "Stale summary", "params": {}},
|
||||
],
|
||||
"alarms": [
|
||||
{
|
||||
"component": "VALARM",
|
||||
"properties": [
|
||||
{"name": "ACTION", "value": "DISPLAY", "params": {}},
|
||||
{"name": "DESCRIPTION", "value": "Reminder", "params": {}},
|
||||
{"name": "TRIGGER", "value": "-PT15M", "params": {}},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
ics = event_to_ics(event)
|
||||
|
||||
self.assertIn("METHOD:PUBLISH", ics)
|
||||
self.assertIn("DTSTART;TZID=Europe/Berlin:20260708T090000", ics)
|
||||
self.assertIn("SUMMARY:Generated", ics)
|
||||
self.assertNotIn("SUMMARY:Stale summary", ics)
|
||||
self.assertIn("RRULE:FREQ=WEEKLY;COUNT=2", ics)
|
||||
self.assertIn("ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf", ics)
|
||||
self.assertIn("RELATED-TO;RELTYPE=PARENT:parent@example.test", ics)
|
||||
self.assertIn("URL:https://example.test/events/event-export", ics)
|
||||
self.assertIn("X-GOVOPLAN-CUSTOM:kept", ics)
|
||||
self.assertIn("BEGIN:VALARM", ics)
|
||||
self.assertIn("TRIGGER:-PT15M", ics)
|
||||
|
||||
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
|
||||
parsed = parse_vevent(
|
||||
"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event-recur@example.test
|
||||
DTSTART;TZID=Europe/Berlin:20260708T090000
|
||||
DTEND;TZID=Europe/Berlin:20260708T100000
|
||||
SUMMARY:Recurring
|
||||
RRULE:FREQ=WEEKLY;COUNT=3
|
||||
EXDATE;TZID=Europe/Berlin:20260715T090000
|
||||
RDATE;TZID=Europe/Berlin:20260729T090000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
"""
|
||||
)
|
||||
event = SimpleNamespace(**parsed)
|
||||
|
||||
occurrences = expand_event_occurrences(
|
||||
event,
|
||||
datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
datetime(2026, 7, 31, 23, 59, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[item["start_at"] for item in occurrences],
|
||||
[
|
||||
datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 7, 22, 7, 0, tzinfo=timezone.utc),
|
||||
datetime(2026, 7, 29, 7, 0, tzinfo=timezone.utc),
|
||||
],
|
||||
)
|
||||
self.assertEqual(occurrences[0]["end_at"], datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc))
|
||||
|
||||
def test_generated_ics_contains_required_vevent_fields(self) -> None:
|
||||
class Event:
|
||||
uid = "event-3@example.test"
|
||||
|
||||
115
tests/test_service.py
Normal file
115
tests/test_service.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
|
||||
from govoplan_calendar.backend.service import delete_calendar, event_response
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.flushed = False
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
|
||||
class CalendarServiceResponseTests(unittest.TestCase):
|
||||
def test_event_response_marks_naive_database_datetimes_as_utc(self) -> None:
|
||||
event = SimpleNamespace(
|
||||
id="event-1",
|
||||
tenant_id="tenant-1",
|
||||
calendar_id="calendar-1",
|
||||
uid="event-1@example.test",
|
||||
recurrence_id=None,
|
||||
sequence=0,
|
||||
summary="Planning",
|
||||
description=None,
|
||||
location=None,
|
||||
status="CONFIRMED",
|
||||
transparency="OPAQUE",
|
||||
classification="PUBLIC",
|
||||
start_at=datetime(2026, 7, 7, 18, 0),
|
||||
end_at=datetime(2026, 7, 7, 19, 0),
|
||||
duration_seconds=None,
|
||||
all_day=False,
|
||||
timezone=None,
|
||||
organizer=None,
|
||||
attendees=[],
|
||||
categories=[],
|
||||
rrule=None,
|
||||
rdate=[],
|
||||
exdate=[],
|
||||
reminders=[],
|
||||
attachments=[],
|
||||
related_to=[],
|
||||
source_kind="local",
|
||||
source_href=None,
|
||||
etag=None,
|
||||
icalendar={},
|
||||
created_at=datetime(2026, 7, 7, 17, 55),
|
||||
updated_at=datetime(2026, 7, 7, 17, 55),
|
||||
metadata_={},
|
||||
)
|
||||
|
||||
payload = event_response(event)
|
||||
response = CalendarEventResponse.model_validate(payload)
|
||||
|
||||
self.assertEqual(response.start_at.utcoffset(), timedelta(0))
|
||||
self.assertEqual(response.end_at.utcoffset(), timedelta(0))
|
||||
self.assertEqual(response.created_at.utcoffset(), timedelta(0))
|
||||
self.assertEqual(response.updated_at.utcoffset(), timedelta(0))
|
||||
self.assertEqual(response.start_at.tzinfo, timezone.utc)
|
||||
|
||||
|
||||
class CalendarServiceDeleteTests(unittest.TestCase):
|
||||
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
|
||||
session = FakeSession()
|
||||
event = SimpleNamespace(calendar_id="calendar-1", deleted_at=None)
|
||||
calendar = SimpleNamespace(id="calendar-1", is_default=True, deleted_at=None, events=[event])
|
||||
|
||||
with patch("govoplan_calendar.backend.service.get_calendar", return_value=calendar):
|
||||
delete_calendar(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
calendar_id="calendar-1",
|
||||
payload=CalendarCollectionDeleteRequest(event_action="delete"),
|
||||
)
|
||||
|
||||
self.assertTrue(session.flushed)
|
||||
self.assertFalse(calendar.is_default)
|
||||
self.assertIsNotNone(calendar.deleted_at)
|
||||
self.assertEqual(event.deleted_at, calendar.deleted_at)
|
||||
self.assertEqual(event.calendar_id, "calendar-1")
|
||||
|
||||
def test_delete_calendar_can_move_events_to_another_calendar_and_make_it_default(self) -> None:
|
||||
session = FakeSession()
|
||||
event = SimpleNamespace(calendar_id="calendar-1", deleted_at=None)
|
||||
calendar = SimpleNamespace(id="calendar-1", is_default=True, deleted_at=None, events=[event])
|
||||
target = SimpleNamespace(id="calendar-2", is_default=False, deleted_at=None, events=[])
|
||||
|
||||
with (
|
||||
patch("govoplan_calendar.backend.service.get_calendar", side_effect=[calendar, target]),
|
||||
patch("govoplan_calendar.backend.service.clear_default_calendar") as clear_default_calendar,
|
||||
):
|
||||
delete_calendar(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
calendar_id="calendar-1",
|
||||
payload=CalendarCollectionDeleteRequest(event_action="move", target_calendar_id="calendar-2", make_target_default=True),
|
||||
)
|
||||
|
||||
self.assertTrue(session.flushed)
|
||||
clear_default_calendar.assert_called_once_with(session, tenant_id="tenant-1")
|
||||
self.assertFalse(calendar.is_default)
|
||||
self.assertTrue(target.is_default)
|
||||
self.assertIsNotNone(calendar.deleted_at)
|
||||
self.assertIsNone(event.deleted_at)
|
||||
self.assertEqual(event.calendar_id, "calendar-2")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,7 +14,7 @@
|
||||
"./styles/calendar.css": "./src/styles/calendar.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.4",
|
||||
"@govoplan/core-webui": "^0.1.5",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -56,6 +56,27 @@ export type CalendarEvent = {
|
||||
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
||||
export type CalendarEventListResponse = { events: CalendarEvent[] };
|
||||
|
||||
export type CalendarCollectionCreatePayload = {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
timezone?: string;
|
||||
color?: string | null;
|
||||
owner_type?: "tenant" | "user" | "group" | "resource";
|
||||
owner_id?: string | null;
|
||||
visibility?: "private" | "tenant" | "shared" | "public";
|
||||
is_default?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
|
||||
export type CalendarDeleteEventAction = "delete" | "move";
|
||||
export type CalendarCollectionDeletePayload = {
|
||||
event_action?: CalendarDeleteEventAction;
|
||||
target_calendar_id?: string | null;
|
||||
make_target_default?: boolean;
|
||||
};
|
||||
|
||||
export type CalendarEventCreatePayload = {
|
||||
calendar_id?: string | null;
|
||||
summary: string;
|
||||
@@ -71,10 +92,24 @@ export type CalendarEventCreatePayload = {
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
|
||||
|
||||
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
|
||||
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
||||
}
|
||||
|
||||
export function createCalendar(settings: ApiSettings, payload: CalendarCollectionCreatePayload): Promise<CalendarCollection> {
|
||||
return apiFetch<CalendarCollection>(settings, "/api/v1/calendar/calendars", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateCalendar(settings: ApiSettings, calendarId: string, payload: CalendarCollectionUpdatePayload): Promise<CalendarCollection> {
|
||||
return apiFetch<CalendarCollection>(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteCalendar(settings: ApiSettings, calendarId: string, payload: CalendarCollectionDeletePayload = { event_action: "delete" }): Promise<void> {
|
||||
return apiFetch<void>(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "DELETE", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function listCalendarEvents(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
||||
@@ -90,3 +125,11 @@ export function listCalendarEvents(
|
||||
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
||||
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function updateCalendarEvent(settings: ApiSettings, eventId: string, payload: CalendarEventUpdatePayload): Promise<CalendarEvent> {
|
||||
return apiFetch<CalendarEvent>(settings, `/api/v1/calendar/events/${eventId}`, { method: "PATCH", body: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
|
||||
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user