Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent 3bcb04ff76
commit b328a6d649
21 changed files with 5718 additions and 714 deletions

View File

@@ -33,7 +33,26 @@ The first backend implementation stores VEVENT data in two layers:
- normalized fields for API range queries and calendar rendering
- raw iCalendar property records and generated `text/calendar` output so unsupported VEVENT properties and parameters can round-trip
This is intentionally not yet a network CalDAV server. CalDAV protocol sync, external ETags, REPORT handling, scheduling inbox/outbox behavior, and full recurrence expansion are calendar-owned follow-up work.
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from
environment variables with `env:NAME`, stored through a platform secret provider
when one is available, or stored as encrypted calendar-owned credentials.
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
when a sync token exists. It imports all VEVENT components in a resource and
soft-deletes local events when remote resources disappear. Two-way sources also
write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and
ETag preconditions. If a remote resource changed, the local mutation is rejected
and the user must sync before retrying. A calendar-owned task,
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
cron-style scheduler.
This is not yet a full CalDAV network server. Scheduling inbox/outbox behavior,
CalDAV server endpoints, UI credential management, and full user-facing
recurrence editing remain follow-up work. The backend now includes a free/busy
API primitive for scheduling and appointment modules.
## Development

View File

@@ -22,9 +22,11 @@ The first standalone module provides:
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage and UI foundation on which CalDAV sync, Open-Xchange integration, recurrence expansion, and free/busy endpoints can be built.
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
## Integration Points
@@ -112,8 +114,9 @@ The connector boundary should hand calendar a configured profile and credentials
## Follow-Up Work
- Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
- Free/busy endpoint and capability for scheduling, appointments, and resource checks.
- CalDAV sync adapter with collection sync tokens, ETags, REPORT handling, and conflict resolution.
- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
- UI management for CalDAV credentials, sync status, sync direction, and conflict resolution.
- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections.
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
- Attendee RSVP workflow and mail/notification bridge.
- Resource calendars for rooms, equipment, counters, and service desks.

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -4,15 +4,17 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-calendar"
version = "0.1.4"
version = "0.1.5"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.4",
"govoplan-access>=0.1.4",
"govoplan-core>=0.1.5",
"govoplan-access>=0.1.5",
"icalendar>=7.2",
"python-dateutil>=2.9",
]
[tool.setuptools.packages.find]

View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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

View File

@@ -94,4 +94,61 @@ class CalendarEvent(Base, TimestampMixin):
calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
__all__ = ["CalendarCollection", "CalendarEvent", "new_uuid"]
class CalendarSyncSource(Base, TimestampMixin):
__tablename__ = "calendar_sync_sources"
__table_args__ = (
Index("ix_calendar_sync_sources_calendar", "tenant_id", "calendar_id", "source_kind"),
Index(
"uq_calendar_sync_sources_active_url",
"tenant_id",
"source_kind",
"collection_url",
unique=True,
sqlite_where=text("deleted_at IS NULL"),
postgresql_where=text("deleted_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
source_kind: Mapped[str] = mapped_column(String(30), default="caldav", nullable=False, index=True)
collection_url: Mapped[str] = mapped_column(String(1000), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255))
auth_type: Mapped[str] = mapped_column(String(30), default="none", nullable=False)
username: Mapped[str | None] = mapped_column(String(255))
credential_ref: Mapped[str | None] = mapped_column(String(255))
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
sync_interval_seconds: Mapped[int] = mapped_column(Integer, default=900, nullable=False)
sync_direction: Mapped[str] = mapped_column(String(30), default="two_way", nullable=False)
conflict_policy: Mapped[str] = mapped_column(String(30), default="etag", nullable=False)
sync_token: Mapped[str | None] = mapped_column(Text)
ctag: Mapped[str | None] = mapped_column(String(255))
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
next_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
last_status: Mapped[str | None] = mapped_column(String(30))
last_error: Mapped[str | None] = mapped_column(Text)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
calendar: Mapped[CalendarCollection] = relationship()
class CalendarSyncCredential(Base, TimestampMixin):
__tablename__ = "calendar_sync_credentials"
__table_args__ = (
Index("ix_calendar_sync_credentials_tenant_kind", "tenant_id", "credential_kind"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
credential_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(255))
secret_encrypted: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
__all__ = ["CalendarCollection", "CalendarEvent", "CalendarSyncCredential", "CalendarSyncSource", "new_uuid"]

View File

@@ -1,239 +1,553 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timezone
from datetime import date, datetime, time, timedelta, timezone
from email.utils import format_datetime
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from dateutil.rrule import rruleset, rrulestr
from icalendar import Alarm, Calendar, Event, vCalAddress
from icalendar.parser import Parameters
class ICalendarError(ValueError):
"""Raised when iCalendar content cannot be parsed as a usable VEVENT."""
@dataclass(frozen=True, slots=True)
class ICalendarProperty:
name: str
value: str
params: dict[str, list[str]]
GENERATED_EVENT_PROPERTIES = {
"UID",
"DTSTAMP",
"DTSTART",
"DTEND",
"DURATION",
"RECURRENCE-ID",
"SUMMARY",
"SEQUENCE",
"STATUS",
"TRANSP",
"CLASS",
"DESCRIPTION",
"LOCATION",
"CATEGORIES",
"RRULE",
"RDATE",
"EXDATE",
"ORGANIZER",
"ATTENDEE",
"ATTACH",
"RELATED-TO",
}
def as_dict(self) -> dict[str, Any]:
return {"name": self.name, "value": self.value, "params": self.params}
class RawICalendarValue:
def __init__(self, value: str, params: dict[str, str]) -> None:
self.value = value
self.params = Parameters(params)
def to_ical(self) -> bytes:
return self.value.encode("utf-8")
def parse_vevent(ics: str) -> dict[str, Any]:
lines = unfold_ical_lines(ics)
event_lines = component_lines(lines, "VEVENT")
if not event_lines:
events = parse_vevents(ics)
if not events:
raise ICalendarError("No VEVENT component found")
return events[0]
properties = [parse_content_line(line) for line in event_lines]
props = [prop.as_dict() for prop in properties]
by_name = group_properties(properties)
uid = first_value(by_name, "UID")
def parse_vevents(ics: str) -> list[dict[str, Any]]:
try:
calendar = Calendar.from_ical(ics)
except Exception as exc: # noqa: BLE001 - normalize parser errors for API callers.
raise ICalendarError(f"Invalid iCalendar content: {exc}") from exc
events = [component for component in calendar.walk("VEVENT") if component.name == "VEVENT"]
if not events:
raise ICalendarError("No VEVENT component found")
normalized_ics = normalize_ics(ics)
return [parse_vevent_component(calendar, event, raw_ics=normalized_ics) for event in events]
def parse_vevent_component(calendar: Calendar, event: Event, *, raw_ics: str) -> dict[str, Any]:
uid = text_value(first_property_value(event, "UID"))
if not uid:
raise ICalendarError("VEVENT is missing UID")
dtstart_prop = first_property(by_name, "DTSTART")
if dtstart_prop is None:
raise ICalendarError("VEVENT is missing DTSTART")
start_at, all_day, tzid = parse_temporal_property(dtstart_prop)
dtstart = first_property_value(event, "DTSTART")
if dtstart is None:
raise ICalendarError("VEVENT is missing DTSTART")
start_at, all_day, tzid = temporal_property(dtstart)
end_at = None
duration_seconds = None
dtend_prop = first_property(by_name, "DTEND")
if dtend_prop is not None:
end_at, _end_all_day, _end_tzid = parse_temporal_property(dtend_prop)
dtend = first_property_value(event, "DTEND")
duration = decoded_property(event, "DURATION")
if dtend is not None:
end_at, _end_all_day, _end_tzid = temporal_property(dtend)
duration_seconds = int((end_at - start_at).total_seconds())
elif first_value(by_name, "DURATION"):
# Keep full DURATION semantics in raw properties. The first module
# stores it but does not yet calculate every RFC 5545 duration form.
duration_seconds = None
elif isinstance(duration, timedelta):
end_at = start_at + duration
duration_seconds = int(duration.total_seconds())
summary = first_value(by_name, "SUMMARY") or "(Untitled event)"
organizer = property_party(first_property(by_name, "ORGANIZER"))
attendees = [property_party(prop) for prop in by_name.get("ATTENDEE", [])]
categories = split_csv(first_value(by_name, "CATEGORIES") or "")
properties = component_property_records(event)
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
standard = standard_property_index(properties)
return {
"uid": uid,
"recurrence_id": first_value(by_name, "RECURRENCE-ID"),
"sequence": int_or_zero(first_value(by_name, "SEQUENCE")),
"summary": summary,
"description": first_value(by_name, "DESCRIPTION"),
"location": first_value(by_name, "LOCATION"),
"status": (first_value(by_name, "STATUS") or "CONFIRMED").upper(),
"transparency": (first_value(by_name, "TRANSP") or "OPAQUE").upper(),
"classification": (first_value(by_name, "CLASS") or "PUBLIC").upper(),
"recurrence_id": recurrence_id_value(first_property_value(event, "RECURRENCE-ID")),
"sequence": int_or_zero(text_value(first_property_value(event, "SEQUENCE"))),
"summary": text_value(first_property_value(event, "SUMMARY")) or "(Untitled event)",
"description": text_value(first_property_value(event, "DESCRIPTION")),
"location": text_value(first_property_value(event, "LOCATION")),
"status": (text_value(first_property_value(event, "STATUS")) or "CONFIRMED").upper(),
"transparency": (text_value(first_property_value(event, "TRANSP")) or "OPAQUE").upper(),
"classification": (text_value(first_property_value(event, "CLASS")) or "PUBLIC").upper(),
"start_at": start_at,
"end_at": end_at,
"duration_seconds": duration_seconds,
"all_day": all_day,
"timezone": tzid,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"rrule": parse_key_value_property(first_value(by_name, "RRULE")),
"rdate": [prop.as_dict() for prop in by_name.get("RDATE", [])],
"exdate": [prop.as_dict() for prop in by_name.get("EXDATE", [])],
"attachments": [prop.as_dict() for prop in by_name.get("ATTACH", [])],
"related_to": [prop.as_dict() for prop in by_name.get("RELATED-TO", [])],
"icalendar": {"component": "VEVENT", "properties": props},
"raw_ics": normalize_ics(ics),
"organizer": party_record(first_property_value(event, "ORGANIZER")),
"attendees": [party_record(value) for value in property_values(event, "ATTENDEE")],
"categories": category_values(event),
"rrule": recurrence_rule(first_property_value(event, "RRULE")),
"rdate": records_for_name(properties, "RDATE"),
"exdate": records_for_name(properties, "EXDATE"),
"reminders": [alarm_to_reminder(alarm) for alarm in alarms],
"attachments": records_for_name(properties, "ATTACH"),
"related_to": records_for_name(properties, "RELATED-TO"),
"icalendar": {
"component": "VEVENT",
"schema_version": 1,
"properties": properties,
"alarms": alarms,
"standard": standard,
"method": text_value(first_property_value(calendar, "METHOD")),
"timezones": calendar_timezones(calendar),
},
"raw_ics": raw_ics,
}
def event_to_ics(event: Any) -> str:
properties = [
("UID", {}, event.uid),
("DTSTAMP", {}, format_ical_datetime(datetime.now(timezone.utc))),
("DTSTART", date_params(event), format_ical_temporal(event.start_at, all_day=event.all_day)),
]
return events_to_ics([event])
def events_to_ics(events: list[Any]) -> str:
calendar = Calendar()
calendar.add("version", "2.0")
calendar.add("prodid", "-//GovOPlaN//Calendar//EN")
calendar.add("calscale", "GREGORIAN")
method = next((metadata_value(event, "method") for event in events if metadata_value(event, "method")), None)
if method:
calendar.add("method", method)
for event in events:
calendar.add_component(event_to_component(event))
return calendar.to_ical().decode("utf-8")
def event_to_component(event: Any) -> Event:
component = Event()
component.add("uid", event.uid)
component.add("dtstamp", datetime.now(timezone.utc))
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
if event.end_at is not None:
properties.append(("DTEND", date_params(event), format_ical_temporal(event.end_at, all_day=event.all_day)))
properties.extend(
[
("SUMMARY", {}, event.summary),
("SEQUENCE", {}, str(event.sequence or 0)),
("STATUS", {}, event.status),
("TRANSP", {}, event.transparency),
("CLASS", {}, event.classification),
]
)
component.add("dtend", event_temporal_value(event.end_at, all_day=event.all_day, timezone_id=event.timezone))
elif getattr(event, "duration_seconds", None) is not None:
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
if getattr(event, "recurrence_id", None):
add_recurrence_id(component, str(event.recurrence_id))
component.add("summary", event.summary)
component.add("sequence", int(event.sequence or 0))
component.add("status", event.status)
component.add("transp", event.transparency)
component.add("class", event.classification)
if event.description:
properties.append(("DESCRIPTION", {}, event.description))
component.add("description", event.description)
if event.location:
properties.append(("LOCATION", {}, event.location))
for category in event.categories or []:
if category:
properties.append(("CATEGORIES", {}, category))
component.add("location", event.location)
if event.categories:
component.add("categories", list(event.categories))
if event.rrule:
properties.append(("RRULE", {}, serialize_key_value_property(event.rrule)))
component.add("rrule", normalized_rrule_for_export(event.rrule))
if event.organizer:
properties.append(party_to_property("ORGANIZER", event.organizer))
component.add("organizer", party_for_export(event.organizer))
for attendee in event.attendees or []:
properties.append(party_to_property("ATTENDEE", attendee))
component.add("attendee", party_for_export(attendee))
for record in getattr(event, "rdate", None) or []:
add_raw_property(component, "RDATE", record)
for record in getattr(event, "exdate", None) or []:
add_raw_property(component, "EXDATE", record)
for record in getattr(event, "attachments", None) or []:
add_raw_property(component, "ATTACH", record)
for record in getattr(event, "related_to", None) or []:
add_raw_property(component, "RELATED-TO", record)
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//GovOPlaN//Calendar//EN", "CALSCALE:GREGORIAN", "BEGIN:VEVENT"]
lines.extend(fold_ical_line(serialize_content_line(name, params, value)) for name, params, value in properties if value is not None)
lines.extend(["END:VEVENT", "END:VCALENDAR", ""])
return "\r\n".join(lines)
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
for alarm in alarms_for_export(event):
component.add_component(alarm)
return component
def unfold_ical_lines(ics: str) -> list[str]:
raw_lines = ics.replace("\r\n", "\n").replace("\r", "\n").split("\n")
lines: list[str] = []
for line in raw_lines:
if not line:
continue
if line[:1] in {" ", "\t"} and lines:
lines[-1] += line[1:]
else:
lines.append(line)
return lines
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
"""Expand an event's recurrence primitives within a range.
This is a backend primitive for later API/UI recurrence handling. It expands
RRULE/RDATE/EXDATE data stored by this module and returns occurrence starts
with the event's original duration.
"""
start_at = normalize_datetime(event.start_at)
end_at = normalize_datetime(event.end_at) if event.end_at is not None else None
range_start = normalize_datetime(range_start)
range_end = normalize_datetime(range_end)
duration = occurrence_duration(event, start_at=start_at, end_at=end_at)
if not event.rrule and not event.rdate:
return [occurrence_payload(start_at, duration, event)] if occurrence_overlaps(start_at, duration, range_start, range_end) else []
rules = rruleset()
if event.rrule:
rules.rrule(rrulestr(serialize_key_value_property(event.rrule), dtstart=start_at))
for rdate in temporal_values_from_records(event.rdate or []):
rules.rdate(rdate)
for exdate in temporal_values_from_records(event.exdate or []):
rules.exdate(exdate)
starts = rules.between(range_start - duration, range_end, inc=True)
occurrences: list[dict[str, Any]] = []
for occurrence_start in starts:
occurrence_start = normalize_datetime(occurrence_start)
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
occurrences.append(occurrence_payload(occurrence_start, duration, event))
if len(occurrences) >= limit:
break
return occurrences
def component_lines(lines: list[str], component: str) -> list[str]:
begin = f"BEGIN:{component.upper()}"
end = f"END:{component.upper()}"
depth = 0
collected: list[str] = []
for line in lines:
upper = line.upper()
if upper == begin:
depth += 1
if depth == 1:
collected = []
continue
if upper == end and depth:
depth -= 1
if depth == 0:
return collected
continue
if depth:
collected.append(line)
def first_vevent(calendar: Calendar) -> Event:
for component in calendar.walk("VEVENT"):
if component.name == "VEVENT":
return component
raise ICalendarError("No VEVENT component found")
def first_property_value(component: Any, name: str) -> Any | None:
values = property_values(component, name)
return values[0] if values else None
def property_values(component: Any, name: str) -> list[Any]:
value = component.get(name.upper())
if value is None:
return []
return value if isinstance(value, list) else [value]
def parse_content_line(line: str) -> ICalendarProperty:
head, sep, value = line.partition(":")
if not sep:
raise ICalendarError(f"Invalid iCalendar content line: {line}")
parts = head.split(";")
name = parts[0].upper()
params: dict[str, list[str]] = {}
for raw_param in parts[1:]:
key, param_sep, raw_value = raw_param.partition("=")
if not param_sep:
params[key.upper()] = [""]
else:
params[key.upper()] = [unescape_text(item.strip('"')) for item in raw_value.split(",")]
return ICalendarProperty(name=name, params=params, value=unescape_text(value))
def group_properties(properties: list[ICalendarProperty]) -> dict[str, list[ICalendarProperty]]:
grouped: dict[str, list[ICalendarProperty]] = {}
for prop in properties:
grouped.setdefault(prop.name, []).append(prop)
return grouped
def first_property(grouped: dict[str, list[ICalendarProperty]], name: str) -> ICalendarProperty | None:
items = grouped.get(name.upper()) or []
return items[0] if items else None
def first_value(grouped: dict[str, list[ICalendarProperty]], name: str) -> str | None:
prop = first_property(grouped, name)
return prop.value if prop else None
def parse_temporal_property(prop: ICalendarProperty) -> tuple[datetime, bool, str | None]:
value_type = (prop.params.get("VALUE") or [""])[0].upper()
tzid = (prop.params.get("TZID") or [None])[0]
if value_type == "DATE" or (len(prop.value) == 8 and "T" not in prop.value):
parsed_date = datetime.strptime(prop.value, "%Y%m%d").date()
return datetime.combine(parsed_date, time.min, tzinfo=timezone.utc), True, tzid
return parse_ical_datetime(prop.value), False, tzid
def parse_ical_datetime(value: str) -> datetime:
if value.endswith("Z"):
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
parsed = datetime.strptime(value, "%Y%m%dT%H%M%S")
return parsed.replace(tzinfo=timezone.utc)
def format_ical_temporal(value: datetime, *, all_day: bool) -> str:
if all_day:
return value.date().strftime("%Y%m%d")
return format_ical_datetime(value)
def format_ical_datetime(value: datetime) -> str:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def date_params(event: Any) -> dict[str, list[str]]:
if event.all_day:
return {"VALUE": ["DATE"]}
if event.timezone:
return {"TZID": [event.timezone]}
return {}
def property_party(prop: ICalendarProperty | None) -> dict[str, Any] | None:
if prop is None:
def decoded_property(component: Any, name: str) -> Any | None:
try:
return component.decoded(name.upper())
except KeyError:
return None
return {"value": prop.value, "params": prop.params}
def party_to_property(name: str, party: dict[str, Any]) -> tuple[str, dict[str, list[str]], str]:
params = party.get("params") if isinstance(party.get("params"), dict) else {}
def temporal_property(value: Any) -> tuple[datetime, bool, str | None]:
decoded = decoded_temporal_value(value)
params = params_to_json(getattr(value, "params", {}))
tzid = first_param(params, "TZID")
if isinstance(decoded, datetime):
return normalize_datetime(decoded), False, tzid
if isinstance(decoded, date):
return datetime.combine(decoded, time.min, tzinfo=timezone.utc), True, tzid
raise ICalendarError(f"Unsupported temporal value: {value}")
def decoded_temporal_value(value: Any) -> Any:
decoded = getattr(value, "dt", None)
return decoded if decoded is not None else value
def recurrence_id_value(value: Any | None) -> str | None:
if value is None:
return None
raw_value = ical_value(value)
params = params_to_json(getattr(value, "params", {}))
tzid = first_param(params, "TZID")
return f"TZID={tzid}:{raw_value}" if tzid else raw_value
def recurrence_rule(value: Any | None) -> dict[str, Any] | None:
if value is None:
return None
if hasattr(value, "items"):
result: dict[str, Any] = {}
for key, raw_items in value.items():
items = raw_items if isinstance(raw_items, list) else [raw_items]
normalized = [rrule_item(item) for item in items]
result[str(key).upper()] = normalized if len(normalized) > 1 else normalized[0]
return result
return parse_key_value_property(ical_value(value))
def rrule_item(value: Any) -> str:
if isinstance(value, datetime):
return format_ical_datetime(value)
if isinstance(value, date):
return value.strftime("%Y%m%d")
return str(value).upper() if isinstance(value, str) else str(value)
def category_values(component: Any) -> list[str]:
decoded = decoded_property(component, "CATEGORIES")
if decoded is None:
return []
groups = decoded if isinstance(decoded, list) else [decoded]
categories: list[str] = []
for group in groups:
values = group if isinstance(group, list) else [group]
categories.extend(str(value) for value in values if str(value))
return categories
def component_property_records(component: Any) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for name, value in component.items():
for item in value if isinstance(value, list) else [value]:
records.append(property_record(name, item))
return records
def property_record(name: str, value: Any) -> dict[str, Any]:
record: dict[str, Any] = {
"name": str(name).upper(),
"value": ical_value(value),
"params": params_to_json(getattr(value, "params", {})),
}
decoded = decoded_temporal_value(value)
json_decoded = json_value(decoded)
if json_decoded is not None and json_decoded != record["value"]:
record["decoded"] = json_decoded
return record
def component_alarm_record(alarm: Any) -> dict[str, Any]:
return {
"component": "VALARM",
"properties": component_property_records(alarm),
}
def alarm_to_reminder(alarm: dict[str, Any]) -> dict[str, Any]:
properties = alarm.get("properties") or []
trigger = first_record(properties, "TRIGGER")
return {
"action": record_value(properties, "ACTION") or "DISPLAY",
"trigger": trigger,
"description": record_value(properties, "DESCRIPTION"),
"summary": record_value(properties, "SUMMARY"),
"attendees": records_for_name(properties, "ATTENDEE"),
"duration": first_record(properties, "DURATION"),
"repeat": record_value(properties, "REPEAT"),
"attachments": records_for_name(properties, "ATTACH"),
"properties": properties,
}
def standard_property_index(properties: list[dict[str, Any]]) -> dict[str, Any]:
names = (
"DTSTAMP",
"CREATED",
"LAST-MODIFIED",
"PRIORITY",
"URL",
"GEO",
"COMMENT",
"CONTACT",
"REQUEST-STATUS",
"RESOURCES",
)
result: dict[str, Any] = {}
for name in names:
matching = records_for_name(properties, name)
if not matching:
continue
result[name.lower().replace("-", "_")] = matching if len(matching) > 1 else matching[0]
return result
def records_for_name(records: list[dict[str, Any]], name: str) -> list[dict[str, Any]]:
return [record for record in records if record.get("name") == name.upper()]
def first_record(records: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
matching = records_for_name(records, name)
return matching[0] if matching else None
def record_value(records: list[dict[str, Any]], name: str) -> str | None:
record = first_record(records, name)
if not record:
return None
return str(record.get("value") or "")
def party_record(value: Any | None) -> dict[str, Any] | None:
if value is None:
return None
return {"value": text_value(value) or "", "params": params_to_json(getattr(value, "params", {}))}
def party_for_export(party: dict[str, Any]) -> Any:
value = str(party.get("value") or party.get("email") or "")
normalized_params = {str(key).upper(): [str(item) for item in value] for key, value in params.items() if isinstance(value, list)}
return name, normalized_params, value
cal_address = vCalAddress(value)
for key, item in flattened_params(party.get("params") if isinstance(party.get("params"), dict) else {}).items():
cal_address.params[key] = item
return cal_address
def text_value(value: Any | None) -> str | None:
if value is None:
return None
raw = value.decode("utf-8") if isinstance(value, bytes) else str(value)
return raw if raw != "" else None
def ical_value(value: Any) -> str:
if hasattr(value, "to_ical"):
raw = value.to_ical()
return raw.decode("utf-8") if isinstance(raw, bytes) else str(raw)
if isinstance(value, bytes):
return value.decode("utf-8")
return str(value)
def params_to_json(params: Any) -> dict[str, list[str]]:
result: dict[str, list[str]] = {}
for key, value in dict(params or {}).items():
values = value if isinstance(value, list) else [value]
result[str(key).upper()] = [str(item) for item in values]
return result
def flattened_params(params: dict[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
for key, value in (params or {}).items():
values = value if isinstance(value, list) else [value]
if values:
result[str(key).upper()] = ",".join(str(item) for item in values)
return result
def first_param(params: dict[str, list[str]], name: str) -> str | None:
values = params.get(name.upper()) or []
return values[0] if values else None
def json_value(value: Any) -> Any:
if isinstance(value, datetime):
return normalize_datetime(value).isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, timedelta):
return int(value.total_seconds())
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return None
def calendar_timezones(calendar: Calendar) -> list[str]:
timezones: list[str] = []
for component in calendar.walk("VTIMEZONE"):
tzid = text_value(first_property_value(component, "TZID"))
if tzid and tzid not in timezones:
timezones.append(tzid)
return timezones
def metadata_value(event: Any, key: str) -> str | None:
raw_metadata = getattr(event, "icalendar", None)
metadata = raw_metadata if isinstance(raw_metadata, dict) else {}
value = metadata.get(key)
return str(value) if value else None
def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime:
value = normalize_datetime(value)
if all_day:
return value.date()
if timezone_id:
try:
return value.astimezone(ZoneInfo(timezone_id))
except ZoneInfoNotFoundError:
return value
return value
def add_raw_property(component: Event | Alarm, default_name: str, record: dict[str, Any]) -> None:
name = str(record.get("name") or default_name).upper()
value = record.get("value")
if not name or value is None:
return
raw_value = RawICalendarValue(str(value), flattened_params(record.get("params") or {}))
component.add(name, raw_value, encode=False)
def add_recurrence_id(component: Event, value: str) -> None:
params: dict[str, list[str]] = {}
raw_value = value
if value.startswith("TZID=") and ":" in value:
tzid, raw_value = value.removeprefix("TZID=").split(":", 1)
params["TZID"] = [tzid]
add_raw_property(component, "RECURRENCE-ID", {"name": "RECURRENCE-ID", "value": raw_value, "params": params})
def add_preserved_properties(component: Event, metadata: dict[str, Any]) -> None:
for record in metadata.get("properties") or []:
if not isinstance(record, dict):
continue
name = str(record.get("name") or "").upper()
if not name or name in GENERATED_EVENT_PROPERTIES:
continue
add_raw_property(component, name, record)
def alarms_for_export(event: Any) -> list[Alarm]:
reminders = getattr(event, "reminders", None) or []
raw_metadata = getattr(event, "icalendar", None)
if not reminders and isinstance(raw_metadata, dict):
reminders = raw_metadata.get("alarms") or []
alarms: list[Alarm] = []
for reminder in reminders:
if not isinstance(reminder, dict):
continue
alarm = Alarm()
properties = reminder.get("properties")
if isinstance(properties, list):
for record in properties:
if isinstance(record, dict):
add_raw_property(alarm, str(record.get("name") or ""), record)
else:
alarm.add("action", reminder.get("action") or "DISPLAY")
trigger = reminder.get("trigger")
if isinstance(trigger, dict):
add_raw_property(alarm, "TRIGGER", trigger)
if reminder.get("description"):
alarm.add("description", reminder["description"])
if reminder.get("summary"):
alarm.add("summary", reminder["summary"])
if alarm:
alarms.append(alarm)
return alarms
def normalized_rrule_for_export(value: dict[str, Any]) -> dict[str, list[Any]]:
result: dict[str, list[Any]] = {}
for key, item in value.items():
result[str(key).lower()] = item if isinstance(item, list) else [item]
return result
def parse_key_value_property(value: str | None) -> dict[str, Any] | None:
@@ -243,7 +557,8 @@ def parse_key_value_property(value: str | None) -> dict[str, Any] | None:
for item in value.split(";"):
key, sep, raw = item.partition("=")
if sep:
result[key.upper()] = raw.split(",") if "," in raw else raw
values = raw.split(",") if "," in raw else [raw]
result[key.upper()] = values if len(values) > 1 else values[0]
return result or {"raw": value}
@@ -257,8 +572,59 @@ def serialize_key_value_property(value: dict[str, Any]) -> str:
return ";".join(parts)
def split_csv(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def temporal_values_from_records(records: list[dict[str, Any]]) -> list[datetime]:
values: list[datetime] = []
for record in records:
params = record.get("params") if isinstance(record.get("params"), dict) else {}
for raw_value in str(record.get("value") or "").split(","):
parsed = parse_ical_temporal(raw_value, params)
if parsed is not None:
values.append(parsed)
return values
def parse_ical_temporal(value: str, params: dict[str, Any]) -> datetime | None:
if not value:
return None
value_type = first_param(params_to_json(params), "VALUE")
if value_type == "DATE" or (len(value) == 8 and "T" not in value):
return datetime.combine(datetime.strptime(value, "%Y%m%d").date(), time.min, tzinfo=timezone.utc)
try:
if value.endswith("Z"):
return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
parsed = datetime.strptime(value, "%Y%m%dT%H%M%S")
except ValueError:
return None
tzid = first_param(params_to_json(params), "TZID")
if tzid:
try:
return parsed.replace(tzinfo=ZoneInfo(tzid)).astimezone(timezone.utc)
except ZoneInfoNotFoundError:
return parsed.replace(tzinfo=timezone.utc)
return parsed.replace(tzinfo=timezone.utc)
def occurrence_duration(event: Any, *, start_at: datetime, end_at: datetime | None) -> timedelta:
if end_at is not None:
return end_at - start_at
if event.duration_seconds is not None:
return timedelta(seconds=int(event.duration_seconds))
return timedelta(days=1) if event.all_day else timedelta(0)
def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: datetime, range_end: datetime) -> bool:
end_at = start_at + duration
return end_at >= range_start and start_at <= range_end
def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]:
return {
"uid": event.uid,
"recurrence_id": format_ical_datetime(start_at),
"start_at": start_at,
"end_at": start_at + duration if duration else None,
"all_day": event.all_day,
}
def int_or_zero(value: str | None) -> int:
@@ -268,42 +634,20 @@ def int_or_zero(value: str | None) -> int:
return 0
def normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def normalize_ics(ics: str) -> str:
stripped = ics.strip().replace("\r\n", "\n").replace("\r", "\n")
return "\r\n".join(stripped.split("\n")) + "\r\n"
def serialize_content_line(name: str, params: dict[str, list[str]], value: str) -> str:
param_text = "".join(f";{key}={','.join(escape_param(item) for item in items)}" for key, items in params.items() if items)
return f"{name}{param_text}:{escape_text(value)}"
def fold_ical_line(line: str, limit: int = 75) -> str:
if len(line) <= limit:
return line
parts = [line[:limit]]
line = line[limit:]
while line:
parts.append(" " + line[: limit - 1])
line = line[limit - 1 :]
return "\r\n".join(parts)
def escape_text(value: str) -> str:
return value.replace("\\", "\\\\").replace("\n", "\\n").replace(";", "\\;").replace(",", "\\,")
def unescape_text(value: str) -> str:
return value.replace("\\n", "\n").replace("\\N", "\n").replace("\\;", ";").replace("\\,", ",").replace("\\\\", "\\")
def escape_param(value: str) -> str:
if any(char in value for char in [":", ";", ","]):
return '"' + value.replace('"', "'") + '"'
return value
def format_ical_datetime(value: datetime) -> str:
return normalize_datetime(value).strftime("%Y%m%dT%H%M%SZ")
def http_last_modified(value: datetime) -> str:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return format_datetime(value.astimezone(timezone.utc), usegmt=True)
return format_datetime(normalize_datetime(value), usegmt=True)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from pathlib import Path
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base
@@ -64,11 +65,13 @@ ROLE_TEMPLATES = (
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
return {
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
}
@@ -84,7 +87,7 @@ def _calendar_router(context: ModuleContext):
manifest = ModuleManifest(
id="calendar",
name="Calendar",
version="0.1.4",
version="0.1.5",
dependencies=("access",),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
permissions=PERMISSIONS,
@@ -101,6 +104,24 @@ manifest = ModuleManifest(
module_id="calendar",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
),
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
),
),
)

View File

@@ -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")

View File

@@ -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)

View File

@@ -2,13 +2,23 @@ from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
from govoplan_calendar.backend.caldav import CalDAVError
from govoplan_calendar.backend.schemas import (
CalendarCalDavDueSyncItemResponse,
CalendarCalDavDueSyncResponse,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceListResponse,
CalendarCalDavSourceResponse,
CalendarCalDavSourceUpdateRequest,
CalendarCalDavSyncRequest,
CalendarCalDavSyncResponse,
CalendarCollectionCreateRequest,
CalendarCollectionDeleteRequest,
CalendarCollectionListResponse,
CalendarCollectionResponse,
CalendarCollectionUpdateRequest,
@@ -16,21 +26,33 @@ from govoplan_calendar.backend.schemas import (
CalendarEventListResponse,
CalendarEventResponse,
CalendarEventUpdateRequest,
CalendarFreeBusyRequest,
CalendarFreeBusyResponse,
CalendarIcsImportRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
caldav_source_response,
caldav_sync_response,
calendar_response,
create_calendar,
create_caldav_source,
create_event,
delete_calendar,
delete_caldav_source,
delete_event,
event_response,
get_caldav_source,
get_event,
import_ics_event,
list_freebusy,
list_caldav_sources,
list_calendars,
list_events,
sync_due_caldav_sources,
sync_caldav_source,
update_calendar,
update_caldav_source,
update_event,
)
from govoplan_core.db.session import get_session
@@ -56,6 +78,10 @@ def _event_response(event) -> CalendarEventResponse:
return CalendarEventResponse.model_validate(event_response(event))
def _caldav_source_response(source) -> CalendarCalDavSourceResponse:
return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source))
@router.get("/calendars", response_model=CalendarCollectionListResponse)
def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal),
@@ -105,12 +131,13 @@ def api_update_calendar(
@router.delete("/calendars/{calendar_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_calendar(
calendar_id: str,
payload: CalendarCollectionDeleteRequest | None = Body(default=None),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id)
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
@@ -118,6 +145,122 @@ def api_delete_calendar(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse)
def api_list_caldav_sources(
calendar_id: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:read")
sources = list_caldav_sources(session, tenant_id=principal.tenant_id, calendar_id=calendar_id)
return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in sources])
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
def api_create_caldav_source(
payload: CalendarCalDavSourceCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = create_caldav_source(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit()
session.refresh(source)
return _caldav_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse)
def api_update_caldav_source(
source_id: str,
payload: CalendarCalDavSourceUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
session.commit()
session.refresh(source)
return _caldav_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_caldav_source(
source_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
def api_sync_caldav_source(
source_id: str,
payload: CalendarCalDavSyncRequest | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
payload = payload or CalendarCalDavSyncRequest()
try:
source, stats = sync_caldav_source(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
source_id=source_id,
password=payload.password,
bearer_token=payload.bearer_token,
force_full=payload.force_full,
)
session.commit()
session.refresh(source)
return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats))
except (CalendarError, CalDAVError, ICalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse)
def api_sync_due_caldav_sources(
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit()
return CalendarCalDavDueSyncResponse(
results=[
CalendarCalDavDueSyncItemResponse(
source_id=item.source_id,
calendar_id=item.calendar_id,
status=item.status,
error=item.error,
created=item.stats.created if item.stats else 0,
updated=item.stats.updated if item.stats else 0,
deleted=item.stats.deleted if item.stats else 0,
unchanged=item.stats.unchanged if item.stats else 0,
fetched=item.stats.fetched if item.stats else 0,
)
for item in results
]
)
@router.get("/events", response_model=CalendarEventListResponse)
def api_list_events(
calendar_id: str | None = None,
@@ -242,3 +385,23 @@ def api_export_ics_event(
"Last-Modified": http_last_modified(event.updated_at),
},
)
@router.post("/availability/freebusy", response_model=CalendarFreeBusyResponse)
def api_freebusy(
payload: CalendarFreeBusyRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:availability:read")
try:
busy = list_freebusy(
session,
tenant_id=principal.tenant_id,
start_at=payload.start_at,
end_at=payload.end_at,
calendar_ids=payload.calendar_ids,
)
return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy)
except CalendarError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc

View File

@@ -3,11 +3,15 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, SecretStr
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
CalendarDeleteEventAction = Literal["delete", "move"]
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
CalendarSyncDirection = Literal["inbound", "two_way"]
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
class CalendarCollectionCreateRequest(BaseModel):
@@ -37,6 +41,14 @@ class CalendarCollectionUpdateRequest(BaseModel):
metadata: dict[str, Any] | None = None
class CalendarCollectionDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
event_action: CalendarDeleteEventAction = "delete"
target_calendar_id: str | None = None
make_target_default: bool = False
class CalendarCollectionResponse(BaseModel):
id: str
tenant_id: str
@@ -58,6 +70,113 @@ class CalendarCollectionListResponse(BaseModel):
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
class CalendarCalDavSourceCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
calendar_id: str
collection_url: str = Field(min_length=1, max_length=1000)
display_name: str | None = Field(default=None, max_length=255)
auth_type: CalendarSyncAuthType = "none"
username: str | None = Field(default=None, max_length=255)
credential_ref: str | None = Field(default=None, max_length=255)
password: SecretStr | None = None
bearer_token: SecretStr | None = None
sync_enabled: bool = True
sync_interval_seconds: int = Field(default=900, ge=60)
sync_direction: CalendarSyncDirection = "two_way"
conflict_policy: CalendarCalDavConflictPolicy = "etag"
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarCalDavSourceUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
calendar_id: str | None = None
collection_url: str | None = Field(default=None, min_length=1, max_length=1000)
display_name: str | None = Field(default=None, max_length=255)
auth_type: CalendarSyncAuthType | None = None
username: str | None = Field(default=None, max_length=255)
credential_ref: str | None = Field(default=None, max_length=255)
password: SecretStr | None = None
bearer_token: SecretStr | None = None
sync_enabled: bool | None = None
sync_interval_seconds: int | None = Field(default=None, ge=60)
sync_direction: CalendarSyncDirection | None = None
conflict_policy: CalendarCalDavConflictPolicy | None = None
sync_token: str | None = None
ctag: str | None = Field(default=None, max_length=255)
metadata: dict[str, Any] | None = None
class CalendarCalDavSourceResponse(BaseModel):
id: str
tenant_id: str
calendar_id: str
source_kind: str
collection_url: str
display_name: str | None = None
auth_type: str
username: str | None = None
credential_ref: str | None = None
has_credential: bool = False
sync_enabled: bool
sync_interval_seconds: int
sync_direction: str
conflict_policy: str
sync_token: str | None = None
ctag: str | None = None
last_attempt_at: datetime | None = None
last_synced_at: datetime | None = None
next_sync_at: datetime | None = None
last_status: str | None = None
last_error: str | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCalDavSyncRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str | None = None
bearer_token: str | None = None
force_full: bool = False
class CalendarCalDavSyncResponse(BaseModel):
source: CalendarCalDavSourceResponse
created: int = 0
updated: int = 0
deleted: int = 0
unchanged: int = 0
fetched: int = 0
full_sync: bool = False
used_sync_token: bool = False
sync_token: str | None = None
ctag: str | None = None
errors: list[str] = Field(default_factory=list)
class CalendarCalDavDueSyncItemResponse(BaseModel):
source_id: str
calendar_id: str
status: str
error: str | None = None
created: int = 0
updated: int = 0
deleted: int = 0
unchanged: int = 0
fetched: int = 0
class CalendarCalDavDueSyncResponse(BaseModel):
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
class CalendarEventCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -164,6 +283,32 @@ class CalendarEventListResponse(BaseModel):
events: list[CalendarEventResponse] = Field(default_factory=list)
class CalendarFreeBusyRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
start_at: datetime
end_at: datetime
calendar_ids: list[str] | None = None
class CalendarBusyBlockResponse(BaseModel):
calendar_id: str
event_id: str
uid: str
recurrence_id: str | None = None
start_at: datetime
end_at: datetime | None = None
all_day: bool = False
transparency: str
status: str
class CalendarFreeBusyResponse(BaseModel):
start_at: datetime
end_at: datetime
busy: list[CalendarBusyBlockResponse] = Field(default_factory=list)
class CalendarIcsImportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

File diff suppressed because it is too large Load Diff

View 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
View 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()

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from govoplan_calendar.backend.ical import event_to_ics, parse_vevent
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent
class ICalendarParsingTests(unittest.TestCase):
@@ -48,6 +50,154 @@ END:VCALENDAR
self.assertTrue(event["all_day"])
self.assertEqual(event["start_at"].hour, 0)
def test_parse_advanced_vevent_fields_and_valarm(self) -> None:
event = parse_vevent(
"""BEGIN:VCALENDAR
VERSION:2.0
METHOD:REQUEST
BEGIN:VEVENT
UID:event-advanced@example.test
DTSTAMP:20260707T080000Z
CREATED:20260707T070000Z
LAST-MODIFIED:20260707T073000Z
DTSTART;TZID=Europe/Berlin:20260708T090000
DURATION:PT1H30M
SUMMARY:Planning
DESCRIPTION:Line one\\nLine two
LOCATION:Room\\; 1
PRIORITY:5
URL:https://example.test/events/event-advanced
GEO:52.5200;13.4050
COMMENT:First comment
RESOURCES:Projector,Room
REQUEST-STATUS:2.0;Success
ORGANIZER;CN=Orga:mailto:orga@example.test
ATTENDEE;CN=Ada;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:ada@example.test
RRULE:FREQ=WEEKLY;COUNT=2
EXDATE;TZID=Europe/Berlin:20260715T090000
RDATE;TZID=Europe/Berlin:20260722T090000
ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf
RELATED-TO;RELTYPE=PARENT:parent@example.test
X-GOVOPLAN-CUSTOM:kept
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:-PT15M
END:VALARM
END:VEVENT
END:VCALENDAR
"""
)
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
self.assertEqual(event["duration_seconds"], 5400)
self.assertEqual(event["description"], "Line one\nLine two")
self.assertEqual(event["location"], "Room; 1")
self.assertEqual(event["timezone"], "Europe/Berlin")
self.assertEqual(event["organizer"]["params"]["CN"], ["Orga"])
self.assertEqual(event["attendees"][0]["params"]["PARTSTAT"], ["ACCEPTED"])
self.assertEqual(event["icalendar"]["method"], "REQUEST")
self.assertEqual(event["icalendar"]["standard"]["priority"]["value"], "5")
self.assertEqual(event["icalendar"]["standard"]["url"]["value"], "https://example.test/events/event-advanced")
self.assertEqual(event["reminders"][0]["trigger"]["value"], "-PT15M")
self.assertEqual(event["reminders"][0]["trigger"]["decoded"], -900)
property_names = [item["name"] for item in event["icalendar"]["properties"]]
self.assertIn("REQUEST-STATUS", property_names)
self.assertIn("X-GOVOPLAN-CUSTOM", property_names)
def test_generated_ics_preserves_unmapped_properties_and_alarms(self) -> None:
event = SimpleNamespace(
uid="event-export@example.test",
start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc),
duration_seconds=None,
all_day=False,
timezone="Europe/Berlin",
summary="Generated",
sequence=4,
status="CONFIRMED",
transparency="OPAQUE",
classification="PUBLIC",
description=None,
location=None,
categories=["GovOPlaN"],
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
organizer={"value": "mailto:orga@example.test", "params": {"CN": ["Orga"]}},
attendees=[{"value": "mailto:ada@example.test", "params": {"CN": ["Ada"], "ROLE": ["REQ-PARTICIPANT"]}}],
rdate=[],
exdate=[],
reminders=[],
attachments=[{"name": "ATTACH", "value": "https://example.test/a.pdf", "params": {"FMTTYPE": ["application/pdf"]}}],
related_to=[{"name": "RELATED-TO", "value": "parent@example.test", "params": {"RELTYPE": ["PARENT"]}}],
icalendar={
"method": "PUBLISH",
"properties": [
{"name": "URL", "value": "https://example.test/events/event-export", "params": {}},
{"name": "X-GOVOPLAN-CUSTOM", "value": "kept", "params": {}},
{"name": "SUMMARY", "value": "Stale summary", "params": {}},
],
"alarms": [
{
"component": "VALARM",
"properties": [
{"name": "ACTION", "value": "DISPLAY", "params": {}},
{"name": "DESCRIPTION", "value": "Reminder", "params": {}},
{"name": "TRIGGER", "value": "-PT15M", "params": {}},
],
}
],
},
)
ics = event_to_ics(event)
self.assertIn("METHOD:PUBLISH", ics)
self.assertIn("DTSTART;TZID=Europe/Berlin:20260708T090000", ics)
self.assertIn("SUMMARY:Generated", ics)
self.assertNotIn("SUMMARY:Stale summary", ics)
self.assertIn("RRULE:FREQ=WEEKLY;COUNT=2", ics)
self.assertIn("ATTACH;FMTTYPE=application/pdf:https://example.test/a.pdf", ics)
self.assertIn("RELATED-TO;RELTYPE=PARENT:parent@example.test", ics)
self.assertIn("URL:https://example.test/events/event-export", ics)
self.assertIn("X-GOVOPLAN-CUSTOM:kept", ics)
self.assertIn("BEGIN:VALARM", ics)
self.assertIn("TRIGGER:-PT15M", ics)
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
parsed = parse_vevent(
"""BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:event-recur@example.test
DTSTART;TZID=Europe/Berlin:20260708T090000
DTEND;TZID=Europe/Berlin:20260708T100000
SUMMARY:Recurring
RRULE:FREQ=WEEKLY;COUNT=3
EXDATE;TZID=Europe/Berlin:20260715T090000
RDATE;TZID=Europe/Berlin:20260729T090000
END:VEVENT
END:VCALENDAR
"""
)
event = SimpleNamespace(**parsed)
occurrences = expand_event_occurrences(
event,
datetime(2026, 7, 1, tzinfo=timezone.utc),
datetime(2026, 7, 31, 23, 59, tzinfo=timezone.utc),
)
self.assertEqual(
[item["start_at"] for item in occurrences],
[
datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
datetime(2026, 7, 22, 7, 0, tzinfo=timezone.utc),
datetime(2026, 7, 29, 7, 0, tzinfo=timezone.utc),
],
)
self.assertEqual(occurrences[0]["end_at"], datetime(2026, 7, 8, 8, 0, tzinfo=timezone.utc))
def test_generated_ics_contains_required_vevent_fields(self) -> None:
class Event:
uid = "event-3@example.test"

115
tests/test_service.py Normal file
View 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()

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/calendar.css": "./src/styles/calendar.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -56,6 +56,27 @@ export type CalendarEvent = {
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
export type CalendarEventListResponse = { events: CalendarEvent[] };
export type CalendarCollectionCreatePayload = {
name: string;
slug?: string | null;
description?: string | null;
timezone?: string;
color?: string | null;
owner_type?: "tenant" | "user" | "group" | "resource";
owner_id?: string | null;
visibility?: "private" | "tenant" | "shared" | "public";
is_default?: boolean;
metadata?: Record<string, unknown>;
};
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
export type CalendarDeleteEventAction = "delete" | "move";
export type CalendarCollectionDeletePayload = {
event_action?: CalendarDeleteEventAction;
target_calendar_id?: string | null;
make_target_default?: boolean;
};
export type CalendarEventCreatePayload = {
calendar_id?: string | null;
summary: string;
@@ -71,10 +92,24 @@ export type CalendarEventCreatePayload = {
categories?: string[];
};
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
}
export function createCalendar(settings: ApiSettings, payload: CalendarCollectionCreatePayload): Promise<CalendarCollection> {
return apiFetch<CalendarCollection>(settings, "/api/v1/calendar/calendars", { method: "POST", body: JSON.stringify(payload) });
}
export function updateCalendar(settings: ApiSettings, calendarId: string, payload: CalendarCollectionUpdatePayload): Promise<CalendarCollection> {
return apiFetch<CalendarCollection>(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteCalendar(settings: ApiSettings, calendarId: string, payload: CalendarCollectionDeletePayload = { event_action: "delete" }): Promise<void> {
return apiFetch<void>(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "DELETE", body: JSON.stringify(payload) });
}
export function listCalendarEvents(
settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
@@ -90,3 +125,11 @@ export function listCalendarEvents(
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
}
export function updateCalendarEvent(settings: ApiSettings, eventId: string, payload: CalendarEventUpdatePayload): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, `/api/v1/calendar/events/${eventId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff