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
|
- 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
|
- 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
|
## 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
|
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
|
||||||
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
|
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
|
||||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
||||||
|
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
||||||
|
- 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
|
- 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
|
## Integration Points
|
||||||
|
|
||||||
@@ -112,8 +114,9 @@ The connector boundary should hand calendar a configured profile and credentials
|
|||||||
## Follow-Up Work
|
## Follow-Up Work
|
||||||
|
|
||||||
- Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
- 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.
|
- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
|
||||||
- CalDAV sync adapter with collection sync tokens, ETags, REPORT handling, and conflict resolution.
|
- 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.
|
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
|
||||||
- Attendee RSVP workflow and mail/notification bridge.
|
- Attendee RSVP workflow and mail/notification bridge.
|
||||||
- Resource calendars for rooms, equipment, counters, and service desks.
|
- Resource calendars for rooms, equipment, counters, and service desks.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.4",
|
"@govoplan/core-webui": "^0.1.5",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-calendar"
|
name = "govoplan-calendar"
|
||||||
version = "0.1.4"
|
version = "0.1.5"
|
||||||
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.4",
|
"govoplan-core>=0.1.5",
|
||||||
"govoplan-access>=0.1.4",
|
"govoplan-access>=0.1.5",
|
||||||
|
"icalendar>=7.2",
|
||||||
|
"python-dateutil>=2.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[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")
|
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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
from datetime import date, datetime, time, timezone
|
|
||||||
from email.utils import format_datetime
|
from email.utils import format_datetime
|
||||||
from typing import Any
|
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):
|
class ICalendarError(ValueError):
|
||||||
"""Raised when iCalendar content cannot be parsed as a usable VEVENT."""
|
"""Raised when iCalendar content cannot be parsed as a usable VEVENT."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
GENERATED_EVENT_PROPERTIES = {
|
||||||
class ICalendarProperty:
|
"UID",
|
||||||
name: str
|
"DTSTAMP",
|
||||||
value: str
|
"DTSTART",
|
||||||
params: dict[str, list[str]]
|
"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]:
|
def parse_vevent(ics: str) -> dict[str, Any]:
|
||||||
lines = unfold_ical_lines(ics)
|
events = parse_vevents(ics)
|
||||||
event_lines = component_lines(lines, "VEVENT")
|
if not events:
|
||||||
if not event_lines:
|
|
||||||
raise ICalendarError("No VEVENT component found")
|
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:
|
if not uid:
|
||||||
raise ICalendarError("VEVENT is missing 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
|
end_at = None
|
||||||
duration_seconds = None
|
duration_seconds = None
|
||||||
dtend_prop = first_property(by_name, "DTEND")
|
dtend = first_property_value(event, "DTEND")
|
||||||
if dtend_prop is not None:
|
duration = decoded_property(event, "DURATION")
|
||||||
end_at, _end_all_day, _end_tzid = parse_temporal_property(dtend_prop)
|
if dtend is not None:
|
||||||
|
end_at, _end_all_day, _end_tzid = temporal_property(dtend)
|
||||||
duration_seconds = int((end_at - start_at).total_seconds())
|
duration_seconds = int((end_at - start_at).total_seconds())
|
||||||
elif first_value(by_name, "DURATION"):
|
elif isinstance(duration, timedelta):
|
||||||
# Keep full DURATION semantics in raw properties. The first module
|
end_at = start_at + duration
|
||||||
# stores it but does not yet calculate every RFC 5545 duration form.
|
duration_seconds = int(duration.total_seconds())
|
||||||
duration_seconds = None
|
|
||||||
|
|
||||||
summary = first_value(by_name, "SUMMARY") or "(Untitled event)"
|
properties = component_property_records(event)
|
||||||
organizer = property_party(first_property(by_name, "ORGANIZER"))
|
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
|
||||||
attendees = [property_party(prop) for prop in by_name.get("ATTENDEE", [])]
|
standard = standard_property_index(properties)
|
||||||
categories = split_csv(first_value(by_name, "CATEGORIES") or "")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
"recurrence_id": first_value(by_name, "RECURRENCE-ID"),
|
"recurrence_id": recurrence_id_value(first_property_value(event, "RECURRENCE-ID")),
|
||||||
"sequence": int_or_zero(first_value(by_name, "SEQUENCE")),
|
"sequence": int_or_zero(text_value(first_property_value(event, "SEQUENCE"))),
|
||||||
"summary": summary,
|
"summary": text_value(first_property_value(event, "SUMMARY")) or "(Untitled event)",
|
||||||
"description": first_value(by_name, "DESCRIPTION"),
|
"description": text_value(first_property_value(event, "DESCRIPTION")),
|
||||||
"location": first_value(by_name, "LOCATION"),
|
"location": text_value(first_property_value(event, "LOCATION")),
|
||||||
"status": (first_value(by_name, "STATUS") or "CONFIRMED").upper(),
|
"status": (text_value(first_property_value(event, "STATUS")) or "CONFIRMED").upper(),
|
||||||
"transparency": (first_value(by_name, "TRANSP") or "OPAQUE").upper(),
|
"transparency": (text_value(first_property_value(event, "TRANSP")) or "OPAQUE").upper(),
|
||||||
"classification": (first_value(by_name, "CLASS") or "PUBLIC").upper(),
|
"classification": (text_value(first_property_value(event, "CLASS")) or "PUBLIC").upper(),
|
||||||
"start_at": start_at,
|
"start_at": start_at,
|
||||||
"end_at": end_at,
|
"end_at": end_at,
|
||||||
"duration_seconds": duration_seconds,
|
"duration_seconds": duration_seconds,
|
||||||
"all_day": all_day,
|
"all_day": all_day,
|
||||||
"timezone": tzid,
|
"timezone": tzid,
|
||||||
"organizer": organizer,
|
"organizer": party_record(first_property_value(event, "ORGANIZER")),
|
||||||
"attendees": attendees,
|
"attendees": [party_record(value) for value in property_values(event, "ATTENDEE")],
|
||||||
"categories": categories,
|
"categories": category_values(event),
|
||||||
"rrule": parse_key_value_property(first_value(by_name, "RRULE")),
|
"rrule": recurrence_rule(first_property_value(event, "RRULE")),
|
||||||
"rdate": [prop.as_dict() for prop in by_name.get("RDATE", [])],
|
"rdate": records_for_name(properties, "RDATE"),
|
||||||
"exdate": [prop.as_dict() for prop in by_name.get("EXDATE", [])],
|
"exdate": records_for_name(properties, "EXDATE"),
|
||||||
"attachments": [prop.as_dict() for prop in by_name.get("ATTACH", [])],
|
"reminders": [alarm_to_reminder(alarm) for alarm in alarms],
|
||||||
"related_to": [prop.as_dict() for prop in by_name.get("RELATED-TO", [])],
|
"attachments": records_for_name(properties, "ATTACH"),
|
||||||
"icalendar": {"component": "VEVENT", "properties": props},
|
"related_to": records_for_name(properties, "RELATED-TO"),
|
||||||
"raw_ics": normalize_ics(ics),
|
"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:
|
def event_to_ics(event: Any) -> str:
|
||||||
properties = [
|
return events_to_ics([event])
|
||||||
("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)),
|
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:
|
if event.end_at is not None:
|
||||||
properties.append(("DTEND", date_params(event), format_ical_temporal(event.end_at, all_day=event.all_day)))
|
component.add("dtend", event_temporal_value(event.end_at, all_day=event.all_day, timezone_id=event.timezone))
|
||||||
properties.extend(
|
elif getattr(event, "duration_seconds", None) is not None:
|
||||||
[
|
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
||||||
("SUMMARY", {}, event.summary),
|
if getattr(event, "recurrence_id", None):
|
||||||
("SEQUENCE", {}, str(event.sequence or 0)),
|
add_recurrence_id(component, str(event.recurrence_id))
|
||||||
("STATUS", {}, event.status),
|
component.add("summary", event.summary)
|
||||||
("TRANSP", {}, event.transparency),
|
component.add("sequence", int(event.sequence or 0))
|
||||||
("CLASS", {}, event.classification),
|
component.add("status", event.status)
|
||||||
]
|
component.add("transp", event.transparency)
|
||||||
)
|
component.add("class", event.classification)
|
||||||
if event.description:
|
if event.description:
|
||||||
properties.append(("DESCRIPTION", {}, event.description))
|
component.add("description", event.description)
|
||||||
if event.location:
|
if event.location:
|
||||||
properties.append(("LOCATION", {}, event.location))
|
component.add("location", event.location)
|
||||||
for category in event.categories or []:
|
if event.categories:
|
||||||
if category:
|
component.add("categories", list(event.categories))
|
||||||
properties.append(("CATEGORIES", {}, category))
|
|
||||||
if event.rrule:
|
if event.rrule:
|
||||||
properties.append(("RRULE", {}, serialize_key_value_property(event.rrule)))
|
component.add("rrule", normalized_rrule_for_export(event.rrule))
|
||||||
if event.organizer:
|
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 []:
|
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"]
|
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
|
||||||
lines.extend(fold_ical_line(serialize_content_line(name, params, value)) for name, params, value in properties if value is not None)
|
for alarm in alarms_for_export(event):
|
||||||
lines.extend(["END:VEVENT", "END:VCALENDAR", ""])
|
component.add_component(alarm)
|
||||||
return "\r\n".join(lines)
|
return component
|
||||||
|
|
||||||
|
|
||||||
def unfold_ical_lines(ics: str) -> list[str]:
|
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
|
||||||
raw_lines = ics.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
"""Expand an event's recurrence primitives within a range.
|
||||||
lines: list[str] = []
|
|
||||||
for line in raw_lines:
|
This is a backend primitive for later API/UI recurrence handling. It expands
|
||||||
if not line:
|
RRULE/RDATE/EXDATE data stored by this module and returns occurrence starts
|
||||||
continue
|
with the event's original duration.
|
||||||
if line[:1] in {" ", "\t"} and lines:
|
"""
|
||||||
lines[-1] += line[1:]
|
|
||||||
else:
|
start_at = normalize_datetime(event.start_at)
|
||||||
lines.append(line)
|
end_at = normalize_datetime(event.end_at) if event.end_at is not None else None
|
||||||
return lines
|
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]:
|
def first_vevent(calendar: Calendar) -> Event:
|
||||||
begin = f"BEGIN:{component.upper()}"
|
for component in calendar.walk("VEVENT"):
|
||||||
end = f"END:{component.upper()}"
|
if component.name == "VEVENT":
|
||||||
depth = 0
|
return component
|
||||||
collected: list[str] = []
|
raise ICalendarError("No VEVENT component found")
|
||||||
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)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def parse_content_line(line: str) -> ICalendarProperty:
|
def first_property_value(component: Any, name: str) -> Any | None:
|
||||||
head, sep, value = line.partition(":")
|
values = property_values(component, name)
|
||||||
if not sep:
|
return values[0] if values else None
|
||||||
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]]:
|
def property_values(component: Any, name: str) -> list[Any]:
|
||||||
grouped: dict[str, list[ICalendarProperty]] = {}
|
value = component.get(name.upper())
|
||||||
for prop in properties:
|
if value is None:
|
||||||
grouped.setdefault(prop.name, []).append(prop)
|
return []
|
||||||
return grouped
|
return value if isinstance(value, list) else [value]
|
||||||
|
|
||||||
|
|
||||||
def first_property(grouped: dict[str, list[ICalendarProperty]], name: str) -> ICalendarProperty | None:
|
def decoded_property(component: Any, name: str) -> Any | None:
|
||||||
items = grouped.get(name.upper()) or []
|
try:
|
||||||
return items[0] if items else None
|
return component.decoded(name.upper())
|
||||||
|
except KeyError:
|
||||||
|
|
||||||
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:
|
|
||||||
return None
|
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]:
|
def temporal_property(value: Any) -> tuple[datetime, bool, str | None]:
|
||||||
params = party.get("params") if isinstance(party.get("params"), dict) else {}
|
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 "")
|
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)}
|
cal_address = vCalAddress(value)
|
||||||
return name, normalized_params, 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:
|
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(";"):
|
for item in value.split(";"):
|
||||||
key, sep, raw = item.partition("=")
|
key, sep, raw = item.partition("=")
|
||||||
if sep:
|
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}
|
return result or {"raw": value}
|
||||||
|
|
||||||
|
|
||||||
@@ -257,8 +572,59 @@ def serialize_key_value_property(value: dict[str, Any]) -> str:
|
|||||||
return ";".join(parts)
|
return ";".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def split_csv(value: str) -> list[str]:
|
def temporal_values_from_records(records: list[dict[str, Any]]) -> list[datetime]:
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
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:
|
def int_or_zero(value: str | None) -> int:
|
||||||
@@ -268,42 +634,20 @@ def int_or_zero(value: str | None) -> int:
|
|||||||
return 0
|
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:
|
def normalize_ics(ics: str) -> str:
|
||||||
stripped = ics.strip().replace("\r\n", "\n").replace("\r", "\n")
|
stripped = ics.strip().replace("\r\n", "\n").replace("\r", "\n")
|
||||||
return "\r\n".join(stripped.split("\n")) + "\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:
|
def format_ical_datetime(value: datetime) -> str:
|
||||||
param_text = "".join(f";{key}={','.join(escape_param(item) for item in items)}" for key, items in params.items() if items)
|
return normalize_datetime(value).strftime("%Y%m%dT%H%M%SZ")
|
||||||
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 http_last_modified(value: datetime) -> str:
|
def http_last_modified(value: datetime) -> str:
|
||||||
if value.tzinfo is None:
|
return format_datetime(normalize_datetime(value), usegmt=True)
|
||||||
value = value.replace(tzinfo=timezone.utc)
|
|
||||||
return format_datetime(value.astimezone(timezone.utc), usegmt=True)
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
|
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.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
@@ -64,11 +65,13 @@ ROLE_TEMPLATES = (
|
|||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
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 {
|
return {
|
||||||
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
|
"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_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(
|
manifest = ModuleManifest(
|
||||||
id="calendar",
|
id="calendar",
|
||||||
name="Calendar",
|
name="Calendar",
|
||||||
version="0.1.4",
|
version="0.1.5",
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
@@ -101,6 +104,24 @@ manifest = ModuleManifest(
|
|||||||
module_id="calendar",
|
module_id="calendar",
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
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 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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
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.ical import ICalendarError, event_to_ics, http_last_modified
|
||||||
|
from govoplan_calendar.backend.caldav import CalDAVError
|
||||||
from govoplan_calendar.backend.schemas import (
|
from govoplan_calendar.backend.schemas import (
|
||||||
|
CalendarCalDavDueSyncItemResponse,
|
||||||
|
CalendarCalDavDueSyncResponse,
|
||||||
|
CalendarCalDavSourceCreateRequest,
|
||||||
|
CalendarCalDavSourceListResponse,
|
||||||
|
CalendarCalDavSourceResponse,
|
||||||
|
CalendarCalDavSourceUpdateRequest,
|
||||||
|
CalendarCalDavSyncRequest,
|
||||||
|
CalendarCalDavSyncResponse,
|
||||||
CalendarCollectionCreateRequest,
|
CalendarCollectionCreateRequest,
|
||||||
|
CalendarCollectionDeleteRequest,
|
||||||
CalendarCollectionListResponse,
|
CalendarCollectionListResponse,
|
||||||
CalendarCollectionResponse,
|
CalendarCollectionResponse,
|
||||||
CalendarCollectionUpdateRequest,
|
CalendarCollectionUpdateRequest,
|
||||||
@@ -16,21 +26,33 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
CalendarEventListResponse,
|
CalendarEventListResponse,
|
||||||
CalendarEventResponse,
|
CalendarEventResponse,
|
||||||
CalendarEventUpdateRequest,
|
CalendarEventUpdateRequest,
|
||||||
|
CalendarFreeBusyRequest,
|
||||||
|
CalendarFreeBusyResponse,
|
||||||
CalendarIcsImportRequest,
|
CalendarIcsImportRequest,
|
||||||
)
|
)
|
||||||
from govoplan_calendar.backend.service import (
|
from govoplan_calendar.backend.service import (
|
||||||
CalendarError,
|
CalendarError,
|
||||||
|
caldav_source_response,
|
||||||
|
caldav_sync_response,
|
||||||
calendar_response,
|
calendar_response,
|
||||||
create_calendar,
|
create_calendar,
|
||||||
|
create_caldav_source,
|
||||||
create_event,
|
create_event,
|
||||||
delete_calendar,
|
delete_calendar,
|
||||||
|
delete_caldav_source,
|
||||||
delete_event,
|
delete_event,
|
||||||
event_response,
|
event_response,
|
||||||
|
get_caldav_source,
|
||||||
get_event,
|
get_event,
|
||||||
import_ics_event,
|
import_ics_event,
|
||||||
|
list_freebusy,
|
||||||
|
list_caldav_sources,
|
||||||
list_calendars,
|
list_calendars,
|
||||||
list_events,
|
list_events,
|
||||||
|
sync_due_caldav_sources,
|
||||||
|
sync_caldav_source,
|
||||||
update_calendar,
|
update_calendar,
|
||||||
|
update_caldav_source,
|
||||||
update_event,
|
update_event,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
@@ -56,6 +78,10 @@ def _event_response(event) -> CalendarEventResponse:
|
|||||||
return CalendarEventResponse.model_validate(event_response(event))
|
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)
|
@router.get("/calendars", response_model=CalendarCollectionListResponse)
|
||||||
def api_list_calendars(
|
def api_list_calendars(
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
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)
|
@router.delete("/calendars/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def api_delete_calendar(
|
def api_delete_calendar(
|
||||||
calendar_id: str,
|
calendar_id: str,
|
||||||
|
payload: CalendarCollectionDeleteRequest | None = Body(default=None),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:calendar:admin")
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
try:
|
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()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except CalendarError as exc:
|
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
|
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)
|
@router.get("/events", response_model=CalendarEventListResponse)
|
||||||
def api_list_events(
|
def api_list_events(
|
||||||
calendar_id: str | None = None,
|
calendar_id: str | None = None,
|
||||||
@@ -242,3 +385,23 @@ def api_export_ics_event(
|
|||||||
"Last-Modified": http_last_modified(event.updated_at),
|
"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 datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||||
|
|
||||||
|
|
||||||
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
||||||
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
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):
|
class CalendarCollectionCreateRequest(BaseModel):
|
||||||
@@ -37,6 +41,14 @@ class CalendarCollectionUpdateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] | None = None
|
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):
|
class CalendarCollectionResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
@@ -58,6 +70,113 @@ class CalendarCollectionListResponse(BaseModel):
|
|||||||
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
|
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):
|
class CalendarEventCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -164,6 +283,32 @@ class CalendarEventListResponse(BaseModel):
|
|||||||
events: list[CalendarEventResponse] = Field(default_factory=list)
|
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):
|
class CalendarIcsImportRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
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):
|
class ICalendarParsingTests(unittest.TestCase):
|
||||||
@@ -48,6 +50,154 @@ END:VCALENDAR
|
|||||||
self.assertTrue(event["all_day"])
|
self.assertTrue(event["all_day"])
|
||||||
self.assertEqual(event["start_at"].hour, 0)
|
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:
|
def test_generated_ics_contains_required_vevent_fields(self) -> None:
|
||||||
class Event:
|
class Event:
|
||||||
uid = "event-3@example.test"
|
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",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.4",
|
"version": "0.1.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./styles/calendar.css": "./src/styles/calendar.css"
|
"./styles/calendar.css": "./src/styles/calendar.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.4",
|
"@govoplan/core-webui": "^0.1.5",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ export type CalendarEvent = {
|
|||||||
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
||||||
export type CalendarEventListResponse = { events: CalendarEvent[] };
|
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 = {
|
export type CalendarEventCreatePayload = {
|
||||||
calendar_id?: string | null;
|
calendar_id?: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -71,10 +92,24 @@ export type CalendarEventCreatePayload = {
|
|||||||
categories?: string[];
|
categories?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
|
||||||
|
|
||||||
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
|
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
|
||||||
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export function listCalendarEvents(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
||||||
@@ -90,3 +125,11 @@ export function listCalendarEvents(
|
|||||||
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
||||||
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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