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

@@ -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)
return []
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 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 first_property_value(component: Any, name: str) -> Any | None:
values = property_values(component, name)
return values[0] if values else None
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 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 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
]