intermittent commit
This commit is contained in:
@@ -5,8 +5,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Mapping, Protocol
|
from typing import Any, Mapping, Protocol
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
from defusedxml import ElementTree as SafeElementTree
|
from defusedxml import ElementTree as SafeElementTree
|
||||||
|
|
||||||
@@ -77,6 +76,18 @@ class _DAVDiscoveryResponse:
|
|||||||
supported_components: tuple[str, ...] = ()
|
supported_components: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _DAVDiscoveryDraft:
|
||||||
|
display_name: str | None = None
|
||||||
|
color: str | None = None
|
||||||
|
ctag: str | None = None
|
||||||
|
sync_token: str | None = None
|
||||||
|
is_calendar: bool = False
|
||||||
|
principal_hrefs: list[str] = field(default_factory=list)
|
||||||
|
calendar_home_set_hrefs: list[str] = field(default_factory=list)
|
||||||
|
supported_components: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalDAVClient:
|
class CalDAVClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -313,7 +324,7 @@ def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: by
|
|||||||
url = validate_http_url(url)
|
url = validate_http_url(url)
|
||||||
try:
|
try:
|
||||||
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV HTTP(S) URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV HTTP(S) URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
return response.status, dict(response.headers.items()), response.read()
|
return response.status, dict(response.headers.items()), response.read()
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
return exc.code, dict(exc.headers.items()), exc.read()
|
return exc.code, dict(exc.headers.items()), exc.read()
|
||||||
@@ -361,62 +372,86 @@ def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
|
|||||||
root = SafeElementTree.fromstring(payload)
|
root = SafeElementTree.fromstring(payload)
|
||||||
except SafeElementTree.ParseError as exc:
|
except SafeElementTree.ParseError as exc:
|
||||||
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
|
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
|
||||||
responses: list[_DAVDiscoveryResponse] = []
|
return [
|
||||||
for response in child_elements(root, "response"):
|
parsed
|
||||||
href = first_child_text(response, "href")
|
for response in child_elements(root, "response")
|
||||||
if not href:
|
if (parsed := _parse_discovery_response(response)) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_discovery_response(response: Any) -> _DAVDiscoveryResponse | None:
|
||||||
|
href = first_child_text(response, "href")
|
||||||
|
if not href:
|
||||||
|
return None
|
||||||
|
draft = _DAVDiscoveryDraft()
|
||||||
|
for propstat in child_elements(response, "propstat"):
|
||||||
|
_apply_discovery_propstat(draft, propstat)
|
||||||
|
return _DAVDiscoveryResponse(
|
||||||
|
href=href,
|
||||||
|
display_name=draft.display_name,
|
||||||
|
color=draft.color,
|
||||||
|
ctag=draft.ctag,
|
||||||
|
sync_token=draft.sync_token,
|
||||||
|
is_calendar=draft.is_calendar,
|
||||||
|
principal_hrefs=dedupe_tuple(draft.principal_hrefs),
|
||||||
|
calendar_home_set_hrefs=dedupe_tuple(draft.calendar_home_set_hrefs),
|
||||||
|
supported_components=dedupe_tuple(draft.supported_components),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_discovery_propstat(draft: _DAVDiscoveryDraft, propstat: Any) -> None:
|
||||||
|
if not discovery_propstat_is_success(propstat):
|
||||||
|
return
|
||||||
|
prop = first_child(propstat, "prop")
|
||||||
|
if prop is None:
|
||||||
|
return
|
||||||
|
for item in prop:
|
||||||
|
_apply_discovery_property(draft, item)
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_propstat_is_success(propstat: Any) -> bool:
|
||||||
|
status = first_child_text(propstat, "status") or ""
|
||||||
|
return not status or " 200 " in status or status.endswith(" 200") or " 207 " in status
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_discovery_property(draft: _DAVDiscoveryDraft, item: Any) -> None:
|
||||||
|
name = local_name(item.tag)
|
||||||
|
text = item.text.strip() if item.text else ""
|
||||||
|
if name == "displayname" and text:
|
||||||
|
draft.display_name = text or draft.display_name
|
||||||
|
elif name == "calendar-color" and text:
|
||||||
|
draft.color = text or draft.color
|
||||||
|
elif name == "getctag" and text:
|
||||||
|
draft.ctag = text or draft.ctag
|
||||||
|
elif name == "sync-token" and text:
|
||||||
|
draft.sync_token = text or draft.sync_token
|
||||||
|
elif name == "resourcetype":
|
||||||
|
draft.is_calendar = draft.is_calendar or discovery_resource_is_calendar(item)
|
||||||
|
elif name in {"current-user-principal", "principal-URL"}:
|
||||||
|
draft.principal_hrefs.extend(nested_href_texts(item))
|
||||||
|
elif name == "calendar-home-set":
|
||||||
|
draft.calendar_home_set_hrefs.extend(nested_href_texts(item))
|
||||||
|
elif name == "supported-calendar-component-set":
|
||||||
|
draft.supported_components.extend(supported_calendar_component_names(item))
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_resource_is_calendar(item: Any) -> bool:
|
||||||
|
return any(local_name(child.tag) == "calendar" for child in item)
|
||||||
|
|
||||||
|
|
||||||
|
def supported_calendar_component_names(item: Any) -> list[str]:
|
||||||
|
names: list[str] = []
|
||||||
|
for component in item:
|
||||||
|
if local_name(component.tag) != "comp":
|
||||||
continue
|
continue
|
||||||
display_name = None
|
component_name = (component.attrib.get("name") or "").strip().upper()
|
||||||
color = None
|
if component_name:
|
||||||
ctag = None
|
names.append(component_name)
|
||||||
sync_token = None
|
return names
|
||||||
is_calendar = False
|
|
||||||
principal_hrefs: list[str] = []
|
|
||||||
calendar_home_set_hrefs: list[str] = []
|
def dedupe_tuple(values: list[str]) -> tuple[str, ...]:
|
||||||
supported_components: list[str] = []
|
return tuple(dict.fromkeys(values))
|
||||||
for propstat in child_elements(response, "propstat"):
|
|
||||||
status = first_child_text(propstat, "status") or ""
|
|
||||||
if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status:
|
|
||||||
continue
|
|
||||||
prop = first_child(propstat, "prop")
|
|
||||||
if prop is None:
|
|
||||||
continue
|
|
||||||
for item in prop:
|
|
||||||
name = local_name(item.tag)
|
|
||||||
if name == "displayname" and item.text:
|
|
||||||
display_name = item.text.strip() or display_name
|
|
||||||
elif name == "calendar-color" and item.text:
|
|
||||||
color = item.text.strip() or color
|
|
||||||
elif name == "getctag" and item.text:
|
|
||||||
ctag = item.text.strip() or ctag
|
|
||||||
elif name == "sync-token" and item.text:
|
|
||||||
sync_token = item.text.strip() or sync_token
|
|
||||||
elif name == "resourcetype":
|
|
||||||
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item)
|
|
||||||
elif name in {"current-user-principal", "principal-URL"}:
|
|
||||||
principal_hrefs.extend(nested_href_texts(item))
|
|
||||||
elif name == "calendar-home-set":
|
|
||||||
calendar_home_set_hrefs.extend(nested_href_texts(item))
|
|
||||||
elif name == "supported-calendar-component-set":
|
|
||||||
for component in item:
|
|
||||||
if local_name(component.tag) == "comp":
|
|
||||||
component_name = (component.attrib.get("name") or "").strip().upper()
|
|
||||||
if component_name:
|
|
||||||
supported_components.append(component_name)
|
|
||||||
responses.append(
|
|
||||||
_DAVDiscoveryResponse(
|
|
||||||
href=href,
|
|
||||||
display_name=display_name,
|
|
||||||
color=color,
|
|
||||||
ctag=ctag,
|
|
||||||
sync_token=sync_token,
|
|
||||||
is_calendar=is_calendar,
|
|
||||||
principal_hrefs=tuple(dict.fromkeys(principal_hrefs)),
|
|
||||||
calendar_home_set_hrefs=tuple(dict.fromkeys(calendar_home_set_hrefs)),
|
|
||||||
supported_components=tuple(dict.fromkeys(supported_components)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return responses
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_collection_url(value: str) -> str:
|
def ensure_collection_url(value: str) -> str:
|
||||||
@@ -457,25 +492,25 @@ def xml_escape(value: str) -> str:
|
|||||||
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
|
def first_child(element: Any, name: str) -> Any | None:
|
||||||
for child in element:
|
for child in element:
|
||||||
if local_name(child.tag) == name:
|
if local_name(child.tag) == name:
|
||||||
return child
|
return child
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def first_child_text(element: ElementTree.Element, name: str) -> str | None:
|
def first_child_text(element: Any, name: str) -> str | None:
|
||||||
found = first_child(element, name)
|
found = first_child(element, name)
|
||||||
if found is None or found.text is None:
|
if found is None or found.text is None:
|
||||||
return None
|
return None
|
||||||
return found.text.strip()
|
return found.text.strip()
|
||||||
|
|
||||||
|
|
||||||
def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]:
|
def child_elements(element: Any, name: str) -> list[Any]:
|
||||||
return [child for child in element if local_name(child.tag) == name]
|
return [child for child in element if local_name(child.tag) == name]
|
||||||
|
|
||||||
|
|
||||||
def nested_href_texts(element: ElementTree.Element) -> list[str]:
|
def nested_href_texts(element: Any) -> list[str]:
|
||||||
hrefs: list[str] = []
|
hrefs: list[str] = []
|
||||||
for child in element.iter():
|
for child in element.iter():
|
||||||
if local_name(child.tag) == "href" and child.text and child.text.strip():
|
if local_name(child.tag) == "href" and child.text and child.text.strip():
|
||||||
|
|||||||
@@ -153,6 +153,18 @@ def events_to_ics(events: list[Any]) -> str:
|
|||||||
|
|
||||||
def event_to_component(event: Any) -> Event:
|
def event_to_component(event: Any) -> Event:
|
||||||
component = Event()
|
component = Event()
|
||||||
|
add_event_identity_and_time(component, event)
|
||||||
|
add_event_status_fields(component, event)
|
||||||
|
add_event_optional_text_fields(component, event)
|
||||||
|
add_event_recurrence_and_parties(component, event)
|
||||||
|
add_event_raw_records(component, event)
|
||||||
|
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
|
||||||
|
for alarm in alarms_for_export(event):
|
||||||
|
component.add_component(alarm)
|
||||||
|
return component
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_identity_and_time(component: Event, event: Any) -> None:
|
||||||
component.add("uid", event.uid)
|
component.add("uid", event.uid)
|
||||||
component.add("dtstamp", datetime.now(timezone.utc))
|
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))
|
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
|
||||||
@@ -164,23 +176,35 @@ def event_to_component(event: Any) -> Event:
|
|||||||
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
||||||
if getattr(event, "recurrence_id", None):
|
if getattr(event, "recurrence_id", None):
|
||||||
add_recurrence_id(component, str(event.recurrence_id))
|
add_recurrence_id(component, str(event.recurrence_id))
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_status_fields(component: Event, event: Any) -> None:
|
||||||
component.add("summary", event.summary)
|
component.add("summary", event.summary)
|
||||||
component.add("sequence", int(event.sequence or 0))
|
component.add("sequence", int(event.sequence or 0))
|
||||||
component.add("status", event.status)
|
component.add("status", event.status)
|
||||||
component.add("transp", event.transparency)
|
component.add("transp", event.transparency)
|
||||||
component.add("class", event.classification)
|
component.add("class", event.classification)
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_optional_text_fields(component: Event, event: Any) -> None:
|
||||||
if event.description:
|
if event.description:
|
||||||
component.add("description", event.description)
|
component.add("description", event.description)
|
||||||
if event.location:
|
if event.location:
|
||||||
component.add("location", event.location)
|
component.add("location", event.location)
|
||||||
if event.categories:
|
if event.categories:
|
||||||
component.add("categories", list(event.categories))
|
component.add("categories", list(event.categories))
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_recurrence_and_parties(component: Event, event: Any) -> None:
|
||||||
if event.rrule:
|
if event.rrule:
|
||||||
component.add("rrule", normalized_rrule_for_export(event.rrule))
|
component.add("rrule", normalized_rrule_for_export(event.rrule))
|
||||||
if event.organizer:
|
if event.organizer:
|
||||||
component.add("organizer", party_for_export(event.organizer))
|
component.add("organizer", party_for_export(event.organizer))
|
||||||
for attendee in event.attendees or []:
|
for attendee in event.attendees or []:
|
||||||
component.add("attendee", party_for_export(attendee))
|
component.add("attendee", party_for_export(attendee))
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_raw_records(component: Event, event: Any) -> None:
|
||||||
for record in getattr(event, "rdate", None) or []:
|
for record in getattr(event, "rdate", None) or []:
|
||||||
add_raw_property(component, "RDATE", record)
|
add_raw_property(component, "RDATE", record)
|
||||||
for record in getattr(event, "exdate", None) or []:
|
for record in getattr(event, "exdate", None) or []:
|
||||||
@@ -190,11 +214,6 @@ def event_to_component(event: Any) -> Event:
|
|||||||
for record in getattr(event, "related_to", None) or []:
|
for record in getattr(event, "related_to", None) or []:
|
||||||
add_raw_property(component, "RELATED-TO", record)
|
add_raw_property(component, "RELATED-TO", record)
|
||||||
|
|
||||||
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
|
|
||||||
for alarm in alarms_for_export(event):
|
|
||||||
component.add_component(alarm)
|
|
||||||
return component
|
|
||||||
|
|
||||||
|
|
||||||
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
|
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.
|
"""Expand an event's recurrence primitives within a range.
|
||||||
|
|||||||
@@ -1,36 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from govoplan_core.core.runtime import ModuleRuntimeState
|
||||||
|
|
||||||
_runtime_registry: object | None = None
|
_runtime = ModuleRuntimeState("Calendar")
|
||||||
_runtime_settings: object | None = None
|
|
||||||
|
|
||||||
|
configure_runtime = _runtime.configure_runtime
|
||||||
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
get_registry = _runtime.get_registry
|
||||||
global _runtime_registry, _runtime_settings
|
get_settings = _runtime.get_settings
|
||||||
if registry is not None:
|
settings = _runtime.settings
|
||||||
_runtime_registry = registry
|
|
||||||
if settings is not None:
|
|
||||||
_runtime_settings = settings
|
|
||||||
|
|
||||||
|
|
||||||
def get_registry() -> object | None:
|
|
||||||
return _runtime_registry
|
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> object:
|
|
||||||
if _runtime_settings is not None:
|
|
||||||
return _runtime_settings
|
|
||||||
try:
|
|
||||||
from govoplan_core.settings import settings as legacy_settings
|
|
||||||
except ModuleNotFoundError as exc:
|
|
||||||
raise RuntimeError("GovOPlaN Calendar runtime settings are not configured") from exc
|
|
||||||
return legacy_settings
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsProxy:
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
|
||||||
return getattr(get_settings(), name)
|
|
||||||
|
|
||||||
|
|
||||||
settings = SettingsProxy()
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import os
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -19,7 +18,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||||
from govoplan_calendar.backend.ical import event_to_ics, events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents
|
from govoplan_calendar.backend.ical import events_to_ics, expand_event_occurrences, parse_vevent, parse_vevents
|
||||||
from govoplan_calendar.backend.runtime import get_registry
|
from govoplan_calendar.backend.runtime import get_registry
|
||||||
from govoplan_calendar.backend.schemas import (
|
from govoplan_calendar.backend.schemas import (
|
||||||
CalendarCalDavDiscoveryRequest,
|
CalendarCalDavDiscoveryRequest,
|
||||||
@@ -33,6 +32,7 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
CalendarSyncSourceCreateRequest,
|
CalendarSyncSourceCreateRequest,
|
||||||
CalendarSyncSourceUpdateRequest,
|
CalendarSyncSourceUpdateRequest,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
from govoplan_core.core.change_sequence import record_change
|
from govoplan_core.core.change_sequence import record_change
|
||||||
from govoplan_core.security.secrets import CAPABILITY_SECURITY_SECRET_PROVIDER, decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import CAPABILITY_SECURITY_SECRET_PROVIDER, decrypt_secret, encrypt_secret
|
||||||
@@ -54,6 +54,7 @@ EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types"
|
|||||||
CALENDAR_MODULE_ID = "calendar"
|
CALENDAR_MODULE_ID = "calendar"
|
||||||
CALENDAR_EVENTS_COLLECTION = "calendar.events"
|
CALENDAR_EVENTS_COLLECTION = "calendar.events"
|
||||||
CALENDAR_EVENT_RESOURCE = "calendar_event"
|
CALENDAR_EVENT_RESOURCE = "calendar_event"
|
||||||
|
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]:
|
def calendar_event_change_payload(event: CalendarEvent, *, prefix: str = "") -> dict[str, Any]:
|
||||||
@@ -186,6 +187,107 @@ def sync_source_label(source_kind: str) -> str:
|
|||||||
}.get(source_kind, source_kind)
|
}.get(source_kind, source_kind)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_stats_payload(stats: CalendarCalDavSyncStats | None) -> dict[str, object]:
|
||||||
|
if stats is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"created": stats.created,
|
||||||
|
"updated": stats.updated,
|
||||||
|
"deleted": stats.deleted,
|
||||||
|
"unchanged": stats.unchanged,
|
||||||
|
"fetched": stats.fetched,
|
||||||
|
"full_sync": stats.full_sync,
|
||||||
|
"used_sync_token": stats.used_sync_token,
|
||||||
|
"errors": list(stats.errors),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_calendar_sync_notification(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
status: str,
|
||||||
|
previous_status: str | None,
|
||||||
|
stats: CalendarCalDavSyncStats | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if status == "ok" and previous_status != "error":
|
||||||
|
return
|
||||||
|
if status == "error" and previous_status == "error":
|
||||||
|
return
|
||||||
|
provider = notification_dispatch_provider(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
label = source.display_name or sync_source_label(source.source_kind)
|
||||||
|
calendar_name = source.calendar.name if source.calendar else "calendar"
|
||||||
|
if status == "ok":
|
||||||
|
subject = f"Calendar sync recovered: {calendar_name}"
|
||||||
|
body_text = f"{label} synchronized successfully after a previous failure."
|
||||||
|
priority = 1
|
||||||
|
else:
|
||||||
|
subject = f"Calendar sync failed: {calendar_name}"
|
||||||
|
body_text = f"{label} could not be synchronized. {error or 'No error detail was provided.'}"
|
||||||
|
priority = 5
|
||||||
|
try:
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=source.tenant_id,
|
||||||
|
source_module=CALENDAR_MODULE_ID,
|
||||||
|
source_resource_type="calendar_sync_source",
|
||||||
|
source_resource_id=source.id,
|
||||||
|
event_kind=f"calendar.sync.{status}",
|
||||||
|
channel="inbox",
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
action_url="/calendar",
|
||||||
|
priority=priority,
|
||||||
|
payload={
|
||||||
|
"calendar_id": source.calendar_id,
|
||||||
|
"source_id": source.id,
|
||||||
|
"source_kind": source.source_kind,
|
||||||
|
"status": status,
|
||||||
|
"previous_status": previous_status,
|
||||||
|
"error": error,
|
||||||
|
"stats": _sync_stats_payload(stats),
|
||||||
|
},
|
||||||
|
metadata={"calendar_id": source.calendar_id, "source_kind": source.source_kind},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_sync_success(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
stats: CalendarCalDavSyncStats | None = None,
|
||||||
|
mark_calendar: Callable[[CalendarCollection, CalendarSyncSource], None] | None = None,
|
||||||
|
update_stats_tokens: bool = False,
|
||||||
|
) -> None:
|
||||||
|
source.last_synced_at = utcnow()
|
||||||
|
source.last_status = "ok"
|
||||||
|
source.last_error = None
|
||||||
|
schedule_next_caldav_sync(source)
|
||||||
|
if stats is not None and update_stats_tokens:
|
||||||
|
stats.sync_token = source.sync_token
|
||||||
|
stats.ctag = source.ctag
|
||||||
|
if mark_calendar is not None and source.calendar is not None:
|
||||||
|
mark_calendar(source.calendar, source)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_sync_error(session: Session, *, source: CalendarSyncSource, error: BaseException) -> None:
|
||||||
|
source.last_attempt_at = utcnow()
|
||||||
|
source.last_status = "error"
|
||||||
|
source.last_error = str(error)
|
||||||
|
schedule_next_caldav_sync(source)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_calendar(session: Session, *, tenant_id: str, user_id: str | None = None) -> CalendarCollection:
|
def ensure_default_calendar(session: Session, *, tenant_id: str, user_id: str | None = None) -> CalendarCollection:
|
||||||
existing = (
|
existing = (
|
||||||
session.query(CalendarCollection)
|
session.query(CalendarCollection)
|
||||||
@@ -265,10 +367,10 @@ def update_calendar(session: Session, *, tenant_id: str, calendar_id: str, paylo
|
|||||||
calendar.is_default = True
|
calendar.is_default = True
|
||||||
elif payload.is_default is False:
|
elif payload.is_default is False:
|
||||||
calendar.is_default = False
|
calendar.is_default = False
|
||||||
for field in ("name", "description", "timezone", "color", "visibility"):
|
for attr in ("name", "description", "timezone", "color", "visibility"):
|
||||||
value = getattr(payload, field)
|
value = getattr(payload, attr)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
setattr(calendar, field, value)
|
setattr(calendar, attr, value)
|
||||||
if payload.metadata is not None:
|
if payload.metadata is not None:
|
||||||
calendar.metadata_ = payload.metadata
|
calendar.metadata_ = payload.metadata
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -753,7 +855,7 @@ def http_request(
|
|||||||
url = validate_http_url(url, label="Calendar source URL")
|
url = validate_http_url(url, label="Calendar source URL")
|
||||||
request = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
request = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated admin-configured sync source URL. # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated admin-configured sync source URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
|
||||||
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
response_headers = {key.lower(): value for key, value in response.headers.items()}
|
||||||
payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace")
|
payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace")
|
||||||
return int(response.status), response_headers, payload
|
return int(response.status), response_headers, payload
|
||||||
@@ -857,21 +959,16 @@ def sync_caldav_source(
|
|||||||
apply_caldav_report(session, source=source, client=client, report=report, stats=stats, user_id=user_id)
|
apply_caldav_report(session, source=source, client=client, report=report, stats=stats, user_id=user_id)
|
||||||
source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token
|
source.sync_token = report.sync_token or collection_props.sync_token or source.sync_token
|
||||||
source.ctag = report.ctag or collection_props.ctag or source.ctag
|
source.ctag = report.ctag or collection_props.ctag or source.ctag
|
||||||
source.last_synced_at = utcnow()
|
_finalize_sync_success(
|
||||||
source.last_status = "ok"
|
session,
|
||||||
source.last_error = None
|
source=source,
|
||||||
schedule_next_caldav_sync(source)
|
stats=stats,
|
||||||
stats.sync_token = source.sync_token
|
mark_calendar=mark_calendar_caldav,
|
||||||
stats.ctag = source.ctag
|
update_stats_tokens=True,
|
||||||
mark_calendar_caldav(source.calendar, source)
|
)
|
||||||
session.flush()
|
|
||||||
return source, stats
|
return source, stats
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
source.last_attempt_at = utcnow()
|
_finalize_sync_error(session, source=source, error=exc)
|
||||||
source.last_status = "error"
|
|
||||||
source.last_error = str(exc)
|
|
||||||
schedule_next_caldav_sync(source)
|
|
||||||
session.flush()
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -900,11 +997,7 @@ def sync_ics_source(
|
|||||||
try:
|
try:
|
||||||
status_code, response_headers, body = http_request(source.collection_url, headers=headers)
|
status_code, response_headers, body = http_request(source.collection_url, headers=headers)
|
||||||
if status_code == 304:
|
if status_code == 304:
|
||||||
source.last_synced_at = utcnow()
|
_finalize_sync_success(session, source=source, stats=stats)
|
||||||
source.last_status = "ok"
|
|
||||||
source.last_error = None
|
|
||||||
schedule_next_caldav_sync(source)
|
|
||||||
session.flush()
|
|
||||||
return source, stats
|
return source, stats
|
||||||
stats.fetched = 1
|
stats.fetched = 1
|
||||||
stats.created, stats.updated, stats.unchanged, stats.deleted = import_ics_subscription(
|
stats.created, stats.updated, stats.unchanged, stats.deleted = import_ics_subscription(
|
||||||
@@ -919,19 +1012,10 @@ def sync_ics_source(
|
|||||||
metadata["last_modified"] = response_headers["last-modified"]
|
metadata["last_modified"] = response_headers["last-modified"]
|
||||||
source.metadata_ = metadata
|
source.metadata_ = metadata
|
||||||
source.ctag = response_headers.get("etag") or source.ctag
|
source.ctag = response_headers.get("etag") or source.ctag
|
||||||
source.last_synced_at = utcnow()
|
_finalize_sync_success(session, source=source, stats=stats, mark_calendar=mark_calendar_sync_source)
|
||||||
source.last_status = "ok"
|
|
||||||
source.last_error = None
|
|
||||||
schedule_next_caldav_sync(source)
|
|
||||||
mark_calendar_sync_source(source.calendar, source)
|
|
||||||
session.flush()
|
|
||||||
return source, stats
|
return source, stats
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
source.last_attempt_at = utcnow()
|
_finalize_sync_error(session, source=source, error=exc)
|
||||||
source.last_status = "error"
|
|
||||||
source.last_error = str(exc)
|
|
||||||
schedule_next_caldav_sync(source)
|
|
||||||
session.flush()
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -1331,24 +1415,49 @@ def soft_delete_source_href(session: Session, *, source: CalendarSyncSource, hre
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
def soft_delete_unseen_source_events(session: Session, *, source: CalendarSyncSource, seen_hrefs: set[str]) -> int:
|
def soft_delete_unseen_source_events(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
seen_hrefs: set[str],
|
||||||
|
source_kind: str | None = None,
|
||||||
|
href_prefix: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
batch_size: int = SOURCE_EVENT_CLEANUP_BATCH_SIZE,
|
||||||
|
) -> int:
|
||||||
deleted_at = utcnow()
|
deleted_at = utcnow()
|
||||||
count = 0
|
count = 0
|
||||||
for event in (
|
last_id: str | None = None
|
||||||
session.query(CalendarEvent)
|
effective_source_kind = source_kind or source.source_kind
|
||||||
.filter(
|
effective_batch_size = max(1, batch_size)
|
||||||
|
while True:
|
||||||
|
id_query = session.query(CalendarEvent.id).filter(
|
||||||
CalendarEvent.tenant_id == source.tenant_id,
|
CalendarEvent.tenant_id == source.tenant_id,
|
||||||
CalendarEvent.calendar_id == source.calendar_id,
|
CalendarEvent.calendar_id == source.calendar_id,
|
||||||
CalendarEvent.source_kind == source.source_kind,
|
CalendarEvent.source_kind == effective_source_kind,
|
||||||
CalendarEvent.deleted_at.is_(None),
|
CalendarEvent.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.all()
|
if href_prefix:
|
||||||
):
|
id_query = id_query.filter(CalendarEvent.source_href.like(f"{href_prefix}%"))
|
||||||
if event.source_href not in seen_hrefs:
|
if last_id:
|
||||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
id_query = id_query.filter(CalendarEvent.id > last_id)
|
||||||
event.deleted_at = deleted_at
|
ids = [row[0] for row in id_query.order_by(CalendarEvent.id.asc()).limit(effective_batch_size).all()]
|
||||||
record_calendar_event_change(session, event=event, operation="deleted", user_id=None, previous=previous)
|
if not ids:
|
||||||
count += 1
|
break
|
||||||
|
last_id = ids[-1]
|
||||||
|
events = (
|
||||||
|
session.query(CalendarEvent)
|
||||||
|
.filter(CalendarEvent.id.in_(ids))
|
||||||
|
.order_by(CalendarEvent.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for event in events:
|
||||||
|
if event.source_href not in seen_hrefs:
|
||||||
|
previous = calendar_event_change_payload(event, prefix="previous_")
|
||||||
|
event.deleted_at = deleted_at
|
||||||
|
record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous)
|
||||||
|
count += 1
|
||||||
|
session.flush()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
@@ -1438,12 +1547,12 @@ def parse_ews_datetime(value: str | None) -> datetime:
|
|||||||
return normalize_datetime(datetime.fromisoformat(value.replace("Z", "+00:00")))
|
return normalize_datetime(datetime.fromisoformat(value.replace("Z", "+00:00")))
|
||||||
|
|
||||||
|
|
||||||
def text_of(item: ET.Element, name: str) -> str | None:
|
def text_of(item: Any, name: str) -> str | None:
|
||||||
child = item.find(f"./{{{EWS_TYPES_NS}}}{name}")
|
child = item.find(f"./{{{EWS_TYPES_NS}}}{name}")
|
||||||
return child.text if child is not None else None
|
return child.text if child is not None else None
|
||||||
|
|
||||||
|
|
||||||
def ews_mailbox_record(mailbox: ET.Element | None) -> dict[str, Any] | None:
|
def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None:
|
||||||
if mailbox is None:
|
if mailbox is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -1453,7 +1562,7 @@ def ews_mailbox_record(mailbox: ET.Element | None) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def ews_attendees(item: ET.Element) -> list[dict[str, Any]]:
|
def ews_attendees(item: Any) -> list[dict[str, Any]]:
|
||||||
attendees: list[dict[str, Any]] = []
|
attendees: list[dict[str, Any]] = []
|
||||||
for role, path in (("required", "RequiredAttendees"), ("optional", "OptionalAttendees")):
|
for role, path in (("required", "RequiredAttendees"), ("optional", "OptionalAttendees")):
|
||||||
for attendee in item.findall(f"./{{{EWS_TYPES_NS}}}{path}/{{{EWS_TYPES_NS}}}Attendee"):
|
for attendee in item.findall(f"./{{{EWS_TYPES_NS}}}{path}/{{{EWS_TYPES_NS}}}Attendee"):
|
||||||
@@ -1467,14 +1576,14 @@ def ews_attendees(item: ET.Element) -> list[dict[str, Any]]:
|
|||||||
return attendees
|
return attendees
|
||||||
|
|
||||||
|
|
||||||
def ews_reminders(item: ET.Element) -> list[dict[str, Any]]:
|
def ews_reminders(item: Any) -> list[dict[str, Any]]:
|
||||||
if text_of(item, "ReminderIsSet") == "true":
|
if text_of(item, "ReminderIsSet") == "true":
|
||||||
minutes = int_or_default(text_of(item, "ReminderMinutesBeforeStart"), 15)
|
minutes = int_or_default(text_of(item, "ReminderMinutesBeforeStart"), 15)
|
||||||
return [{"action": "DISPLAY", "trigger_minutes_before": minutes}]
|
return [{"action": "DISPLAY", "trigger_minutes_before": minutes}]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def element_to_dict(element: ET.Element) -> dict[str, Any]:
|
def element_to_dict(element: Any) -> dict[str, Any]:
|
||||||
tag = element.tag.rsplit("}", 1)[-1]
|
tag = element.tag.rsplit("}", 1)[-1]
|
||||||
children = list(element)
|
children = list(element)
|
||||||
result: dict[str, Any] = {"tag": tag}
|
result: dict[str, Any] = {"tag": tag}
|
||||||
@@ -1507,35 +1616,7 @@ def sync_due_sources(
|
|||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None,
|
client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None,
|
||||||
) -> list[CalendarCalDavDueSyncResult]:
|
) -> list[CalendarCalDavDueSyncResult]:
|
||||||
due_at = normalize_datetime(now or utcnow())
|
return _sync_due_sources(session, source_kinds=SYNC_SOURCE_KINDS, **_due_sync_options(tenant_id, user_id, limit, now, client_factory))
|
||||||
query = session.query(CalendarSyncSource).filter(
|
|
||||||
CalendarSyncSource.source_kind.in_(SYNC_SOURCE_KINDS),
|
|
||||||
CalendarSyncSource.sync_enabled.is_(True),
|
|
||||||
CalendarSyncSource.deleted_at.is_(None),
|
|
||||||
or_(CalendarSyncSource.next_sync_at.is_(None), CalendarSyncSource.next_sync_at <= due_at),
|
|
||||||
)
|
|
||||||
if tenant_id is not None:
|
|
||||||
query = query.filter(CalendarSyncSource.tenant_id == tenant_id)
|
|
||||||
sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all()
|
|
||||||
results: list[CalendarCalDavDueSyncResult] = []
|
|
||||||
for source in sources:
|
|
||||||
try:
|
|
||||||
if source.source_kind == "caldav":
|
|
||||||
client = client_factory(source) if client_factory else None
|
|
||||||
refreshed, stats = sync_caldav_source(
|
|
||||||
session,
|
|
||||||
tenant_id=source.tenant_id,
|
|
||||||
user_id=user_id,
|
|
||||||
source_id=source.id,
|
|
||||||
client=client,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
refreshed, stats = sync_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id)
|
|
||||||
results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats))
|
|
||||||
except Exception as exc:
|
|
||||||
results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc)))
|
|
||||||
session.flush()
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def sync_due_caldav_sources(
|
def sync_due_caldav_sources(
|
||||||
@@ -1546,10 +1627,39 @@ def sync_due_caldav_sources(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None,
|
client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None = None,
|
||||||
|
) -> list[CalendarCalDavDueSyncResult]:
|
||||||
|
return _sync_due_sources(session, source_kinds={"caldav"}, **_due_sync_options(tenant_id, user_id, limit, now, client_factory))
|
||||||
|
|
||||||
|
|
||||||
|
def _due_sync_options(
|
||||||
|
tenant_id: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
limit: int,
|
||||||
|
now: datetime | None,
|
||||||
|
client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"limit": limit,
|
||||||
|
"now": now,
|
||||||
|
"client_factory": client_factory,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_due_sources(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
limit: int,
|
||||||
|
now: datetime | None,
|
||||||
|
source_kinds: set[str],
|
||||||
|
client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None,
|
||||||
) -> list[CalendarCalDavDueSyncResult]:
|
) -> list[CalendarCalDavDueSyncResult]:
|
||||||
due_at = normalize_datetime(now or utcnow())
|
due_at = normalize_datetime(now or utcnow())
|
||||||
query = session.query(CalendarSyncSource).filter(
|
query = session.query(CalendarSyncSource).filter(
|
||||||
CalendarSyncSource.source_kind == "caldav",
|
CalendarSyncSource.source_kind.in_(source_kinds),
|
||||||
CalendarSyncSource.sync_enabled.is_(True),
|
CalendarSyncSource.sync_enabled.is_(True),
|
||||||
CalendarSyncSource.deleted_at.is_(None),
|
CalendarSyncSource.deleted_at.is_(None),
|
||||||
or_(CalendarSyncSource.next_sync_at.is_(None), CalendarSyncSource.next_sync_at <= due_at),
|
or_(CalendarSyncSource.next_sync_at.is_(None), CalendarSyncSource.next_sync_at <= due_at),
|
||||||
@@ -1559,16 +1669,31 @@ def sync_due_caldav_sources(
|
|||||||
sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all()
|
sources = query.order_by(CalendarSyncSource.next_sync_at.asc(), CalendarSyncSource.created_at.asc()).limit(limit).all()
|
||||||
results: list[CalendarCalDavDueSyncResult] = []
|
results: list[CalendarCalDavDueSyncResult] = []
|
||||||
for source in sources:
|
for source in sources:
|
||||||
|
previous_status = source.last_status
|
||||||
try:
|
try:
|
||||||
client = client_factory(source) if client_factory else None
|
refreshed, stats = _sync_due_source(session, source=source, user_id=user_id, client_factory=client_factory)
|
||||||
refreshed, stats = sync_caldav_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id, client=client)
|
|
||||||
results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats))
|
results.append(CalendarCalDavDueSyncResult(source_id=refreshed.id, calendar_id=refreshed.calendar_id, status="ok", stats=stats))
|
||||||
|
_emit_calendar_sync_notification(session, source=refreshed, status="ok", previous_status=previous_status, stats=stats)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc)))
|
results.append(CalendarCalDavDueSyncResult(source_id=source.id, calendar_id=source.calendar_id, status="error", error=str(exc)))
|
||||||
|
_emit_calendar_sync_notification(session, source=source, status="error", previous_status=previous_status, error=str(exc))
|
||||||
session.flush()
|
session.flush()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_due_source(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
source: CalendarSyncSource,
|
||||||
|
user_id: str | None,
|
||||||
|
client_factory: Callable[[CalendarSyncSource], CalDAVClient] | None,
|
||||||
|
) -> tuple[CalendarSyncSource, CalendarCalDavSyncStats]:
|
||||||
|
if source.source_kind == "caldav":
|
||||||
|
client = client_factory(source) if client_factory else None
|
||||||
|
return sync_caldav_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id, client=client)
|
||||||
|
return sync_source(session, tenant_id=source.tenant_id, user_id=user_id, source_id=source.id)
|
||||||
|
|
||||||
|
|
||||||
def schedule_next_caldav_sync(source: CalendarSyncSource, *, now: datetime | None = None) -> None:
|
def schedule_next_caldav_sync(source: CalendarSyncSource, *, now: datetime | None = None) -> None:
|
||||||
if not source.sync_enabled:
|
if not source.sync_enabled:
|
||||||
source.next_sync_at = None
|
source.next_sync_at = None
|
||||||
@@ -1616,24 +1741,14 @@ def apply_caldav_report(
|
|||||||
stats.deleted += deleted
|
stats.deleted += deleted
|
||||||
|
|
||||||
if stats.full_sync:
|
if stats.full_sync:
|
||||||
query = (
|
stats.deleted += soft_delete_unseen_source_events(
|
||||||
session.query(CalendarEvent)
|
session,
|
||||||
.filter(
|
source=source,
|
||||||
CalendarEvent.tenant_id == source.tenant_id,
|
seen_hrefs=seen_hrefs,
|
||||||
CalendarEvent.calendar_id == source.calendar_id,
|
source_kind="caldav",
|
||||||
CalendarEvent.source_kind == "caldav",
|
href_prefix=source.collection_url,
|
||||||
CalendarEvent.deleted_at.is_(None),
|
user_id=user_id,
|
||||||
CalendarEvent.source_href.like(f"{source.collection_url}%"),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
deleted_at = utcnow()
|
|
||||||
for event in query:
|
|
||||||
if event.source_href not in seen_hrefs:
|
|
||||||
previous = calendar_event_change_payload(event, prefix="previous_")
|
|
||||||
event.deleted_at = deleted_at
|
|
||||||
record_calendar_event_change(session, event=event, operation="deleted", user_id=user_id, previous=previous)
|
|
||||||
stats.deleted += 1
|
|
||||||
|
|
||||||
|
|
||||||
def import_caldav_resource(
|
def import_caldav_resource(
|
||||||
@@ -2018,10 +2133,10 @@ def update_event(session: Session, *, tenant_id: str, user_id: str | None, event
|
|||||||
"etag",
|
"etag",
|
||||||
"icalendar",
|
"icalendar",
|
||||||
)
|
)
|
||||||
for field in scalar_fields:
|
for attr in scalar_fields:
|
||||||
value = getattr(payload, field)
|
value = getattr(payload, attr)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
setattr(event, field, value.upper() if field in {"status", "transparency", "classification"} and isinstance(value, str) else value)
|
setattr(event, attr, value.upper() if attr in {"status", "transparency", "classification"} and isinstance(value, str) else value)
|
||||||
if payload.start_at is not None:
|
if payload.start_at is not None:
|
||||||
event.start_at = normalize_datetime(payload.start_at)
|
event.start_at = normalize_datetime(payload.start_at)
|
||||||
if "end_at" in payload.model_fields_set:
|
if "end_at" in payload.model_fields_set:
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -10,7 +9,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
||||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
||||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential
|
||||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
||||||
from govoplan_calendar.backend.service import (
|
from govoplan_calendar.backend.service import (
|
||||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||||
@@ -84,6 +83,14 @@ class FakeCalDAVClient:
|
|||||||
return CalDAVWriteResult(href=href, status=204)
|
return CalDAVWriteResult(href=href, status=204)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeNotificationProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[object] = []
|
||||||
|
|
||||||
|
def enqueue_notification(self, _session, request, **_kwargs) -> None:
|
||||||
|
self.requests.append(request)
|
||||||
|
|
||||||
|
|
||||||
class CalDAVParsingTests(unittest.TestCase):
|
class CalDAVParsingTests(unittest.TestCase):
|
||||||
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
|
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
|
||||||
result = parse_multistatus(
|
result = parse_multistatus(
|
||||||
@@ -216,10 +223,8 @@ END:VCALENDAR</C:calendar-data>
|
|||||||
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
|
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
|
||||||
|
|
||||||
def test_discovery_reports_relative_url_as_caldav_error(self) -> None:
|
def test_discovery_reports_relative_url_as_caldav_error(self) -> None:
|
||||||
client = CalDAVClient(collection_url="/remote.php/dav")
|
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
|
||||||
|
CalDAVClient(collection_url="/remote.php/dav")
|
||||||
with self.assertRaisesRegex(CalDAVError, "unknown url type"):
|
|
||||||
client.discover_calendars()
|
|
||||||
|
|
||||||
|
|
||||||
class CalDAVSyncTests(unittest.TestCase):
|
class CalDAVSyncTests(unittest.TestCase):
|
||||||
@@ -359,6 +364,34 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
next_sync_at = source.next_sync_at if source.next_sync_at.tzinfo else source.next_sync_at.replace(tzinfo=timezone.utc)
|
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))
|
self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
def test_due_sync_emits_recovery_notification_after_previous_error(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.last_status = "error"
|
||||||
|
source.next_sync_at = datetime(2026, 7, 7, 8, 0, tzinfo=timezone.utc)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
provider = FakeNotificationProvider()
|
||||||
|
fake = FakeCalDAVClient(list_result=CalDAVReportResult(sync_token="token-1"))
|
||||||
|
with patch("govoplan_calendar.backend.service.notification_dispatch_provider", return_value=provider):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(results[0].status, "ok")
|
||||||
|
self.assertEqual(len(provider.requests), 1)
|
||||||
|
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
|
||||||
|
|
||||||
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
|
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
|
||||||
session = self.Session()
|
session = self.Session()
|
||||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
|
||||||
from govoplan_calendar.backend.db.models import CalendarEvent
|
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||||
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest
|
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest
|
||||||
from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, sync_source
|
from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, soft_delete_unseen_source_events, sync_source
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.tenancy.scope import create_scope_tables
|
from govoplan_core.tenancy.scope import create_scope_tables
|
||||||
from govoplan_tenancy.backend.db.models import Tenant
|
from govoplan_tenancy.backend.db.models import Tenant
|
||||||
@@ -78,6 +78,55 @@ END:VCALENDAR
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_ics_not_modified_sync_clears_error_and_reschedules(self) -> None:
|
||||||
|
session, calendar = self.session_with_calendar()
|
||||||
|
source = create_sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarSyncSourceCreateRequest(
|
||||||
|
source_kind="ics",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://calendar.example.test/feed.ics",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source.ctag = '"feed-1"'
|
||||||
|
source.last_status = "error"
|
||||||
|
source.last_error = "previous failure"
|
||||||
|
|
||||||
|
with patch("govoplan_calendar.backend.service.http_request", return_value=(304, {}, "")):
|
||||||
|
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
|
||||||
|
|
||||||
|
self.assertEqual(refreshed.last_status, "ok")
|
||||||
|
self.assertIsNone(refreshed.last_error)
|
||||||
|
self.assertIsNotNone(refreshed.last_synced_at)
|
||||||
|
self.assertIsNotNone(refreshed.next_sync_at)
|
||||||
|
self.assertEqual(stats.created, 0)
|
||||||
|
self.assertEqual(stats.updated, 0)
|
||||||
|
self.assertEqual(stats.deleted, 0)
|
||||||
|
|
||||||
|
def test_ics_sync_failure_records_error_and_reschedules(self) -> None:
|
||||||
|
session, calendar = self.session_with_calendar()
|
||||||
|
source = create_sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarSyncSourceCreateRequest(
|
||||||
|
source_kind="ics",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://calendar.example.test/feed.ics",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("govoplan_calendar.backend.service.http_request", side_effect=RuntimeError("network down")):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "network down"):
|
||||||
|
sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
|
||||||
|
|
||||||
|
self.assertEqual(source.last_status, "error")
|
||||||
|
self.assertIn("network down", source.last_error or "")
|
||||||
|
self.assertIsNotNone(source.last_attempt_at)
|
||||||
|
self.assertIsNotNone(source.next_sync_at)
|
||||||
|
|
||||||
def test_graph_sync_imports_delta_events(self) -> None:
|
def test_graph_sync_imports_delta_events(self) -> None:
|
||||||
session, calendar = self.session_with_calendar()
|
session, calendar = self.session_with_calendar()
|
||||||
source = create_sync_source(
|
source = create_sync_source(
|
||||||
@@ -168,6 +217,59 @@ END:VCALENDAR
|
|||||||
self.assertEqual(event.summary, "EWS item")
|
self.assertEqual(event.summary, "EWS item")
|
||||||
self.assertTrue(request.call_args.kwargs["body"].startswith("<?xml"))
|
self.assertTrue(request.call_args.kwargs["body"].startswith("<?xml"))
|
||||||
|
|
||||||
|
def test_full_sync_cleanup_soft_deletes_unseen_events_in_batches(self) -> None:
|
||||||
|
session, calendar = self.session_with_calendar()
|
||||||
|
source = create_sync_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarSyncSourceCreateRequest(
|
||||||
|
source_kind="ics",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://calendar.example.test/events.ics",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for index in range(3):
|
||||||
|
session.add(
|
||||||
|
CalendarEvent(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
uid=f"event-{index}@example.test",
|
||||||
|
recurrence_id=None,
|
||||||
|
summary=f"Event {index}",
|
||||||
|
status="CONFIRMED",
|
||||||
|
transparency="OPAQUE",
|
||||||
|
classification="PUBLIC",
|
||||||
|
start_at=datetime(2026, 7, 8, 9 + index, tzinfo=timezone.utc),
|
||||||
|
all_day=False,
|
||||||
|
attendees=[],
|
||||||
|
categories=[],
|
||||||
|
rdate=[],
|
||||||
|
exdate=[],
|
||||||
|
reminders=[],
|
||||||
|
attachments=[],
|
||||||
|
related_to=[],
|
||||||
|
source_kind="ics",
|
||||||
|
source_href=f"ics-event-{index}",
|
||||||
|
icalendar={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
deleted = soft_delete_unseen_source_events(
|
||||||
|
session,
|
||||||
|
source=source,
|
||||||
|
seen_hrefs={"ics-event-1"},
|
||||||
|
batch_size=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
active_hrefs = {
|
||||||
|
event.source_href
|
||||||
|
for event in session.query(CalendarEvent).filter(CalendarEvent.deleted_at.is_(None)).all()
|
||||||
|
}
|
||||||
|
self.assertEqual(deleted, 2)
|
||||||
|
self.assertEqual(active_hrefs, {"ics-event-1"})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -234,8 +234,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await listCalendars(settings);
|
const [response, sourceResponse] = await Promise.all([
|
||||||
const sourceResponse = await listSyncSources(settings);
|
listCalendars(settings),
|
||||||
|
listSyncSources(settings)
|
||||||
|
]);
|
||||||
setCalendars(response.calendars);
|
setCalendars(response.calendars);
|
||||||
setSyncSources(sourceResponse.sources);
|
setSyncSources(sourceResponse.sources);
|
||||||
const ids = response.calendars.map((calendar) => calendar.id);
|
const ids = response.calendars.map((calendar) => calendar.id);
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
background: var(--panel-header);
|
background: var(--panel-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +121,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-sidebar-heading {
|
.calendar-sidebar-heading {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 13px 14px;
|
padding: 13px 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-sidebar-heading.is-secondary {
|
.calendar-sidebar-heading.is-secondary {
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-list,
|
.calendar-list,
|
||||||
@@ -201,9 +201,9 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border: 1px solid #c9c3b9;
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #d8d3cb;
|
background: var(--calendar-switch-bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .16s ease, border-color .16s ease;
|
transition: background .16s ease, border-color .16s ease;
|
||||||
}
|
}
|
||||||
@@ -217,8 +217,8 @@
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .18);
|
box-shadow: var(--shadow-thumb);
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition: transform .16s ease;
|
transition: transform .16s ease;
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-action .btn {
|
.calendar-create-action .btn {
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
.calendar-agenda-group + .calendar-agenda-group {
|
.calendar-agenda-group + .calendar-agenda-group {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-agenda-group h3 {
|
.calendar-agenda-group h3 {
|
||||||
@@ -346,15 +346,15 @@
|
|||||||
|
|
||||||
.calendar-agenda-item {
|
.calendar-agenda-item {
|
||||||
--calendar-event-color: var(--green);
|
--calendar-event-color: var(--green);
|
||||||
--calendar-event-bg: #edf8f5;
|
--calendar-event-bg: var(--calendar-event-bg-default);
|
||||||
--calendar-event-border: #b9d7d0;
|
--calendar-event-border: var(--calendar-event-border-default);
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
border: 1px solid var(--calendar-event-border);
|
border: 1px solid var(--calendar-event-border);
|
||||||
border-left: 4px solid var(--calendar-event-color, var(--green));
|
border-left: 4px solid var(--calendar-event-color, var(--green));
|
||||||
background: var(--calendar-event-bg);
|
background: var(--calendar-event-bg);
|
||||||
color: #21453e;
|
color: var(--calendar-event-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-agenda-item strong {
|
.calendar-agenda-item strong {
|
||||||
@@ -369,7 +369,7 @@
|
|||||||
.calendar-agenda-item .calendar-event-separator,
|
.calendar-agenda-item .calendar-event-separator,
|
||||||
.calendar-agenda p {
|
.calendar-agenda p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #4d6764;
|
color: var(--calendar-event-muted-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,9 +401,9 @@
|
|||||||
inset: 0 auto auto 0;
|
inset: 0 auto auto 0;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: var(--panel-glass-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-week-rows {
|
.calendar-week-rows {
|
||||||
@@ -439,7 +439,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-bottom: 1px solid var(--line-dark);
|
border-bottom: var(--border-line-dark);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
|
|
||||||
.calendar-week-row {
|
.calendar-week-row {
|
||||||
min-height: 132px;
|
min-height: 132px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-week-rows.is-continuous .calendar-week-row {
|
.calendar-week-rows.is-continuous .calendar-week-row {
|
||||||
@@ -476,22 +476,22 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-even-month {
|
.calendar-day-cell.is-even-month {
|
||||||
background: #ffffff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-odd-month {
|
.calendar-day-cell.is-odd-month {
|
||||||
background: #f8f6f2;
|
background: var(--calendar-grid-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-muted {
|
.calendar-day-cell.is-muted {
|
||||||
color: #8a8278;
|
color: var(--muted);
|
||||||
background: #f1efeb;
|
background: var(--control-gradient-end-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-today {
|
.calendar-day-cell.is-today {
|
||||||
@@ -499,7 +499,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-drop-target {
|
.calendar-day-cell.is-drop-target {
|
||||||
background: #e3f3ef;
|
background: var(--calendar-today-bg);
|
||||||
box-shadow: inset 0 0 0 2px var(--green);
|
box-shadow: inset 0 0 0 2px var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,8 +535,8 @@
|
|||||||
|
|
||||||
.calendar-event-chip {
|
.calendar-event-chip {
|
||||||
--calendar-event-color: var(--green);
|
--calendar-event-color: var(--green);
|
||||||
--calendar-event-bg: #edf8f5;
|
--calendar-event-bg: var(--calendar-event-bg-default);
|
||||||
--calendar-event-border: #b9d7d0;
|
--calendar-event-border: var(--calendar-event-border-default);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -545,7 +545,7 @@
|
|||||||
border-left: 4px solid var(--calendar-event-color);
|
border-left: 4px solid var(--calendar-event-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--calendar-event-bg);
|
background: var(--calendar-event-bg);
|
||||||
color: #21453e;
|
color: var(--calendar-event-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -597,7 +597,7 @@
|
|||||||
.calendar-event-time,
|
.calendar-event-time,
|
||||||
.calendar-event-separator {
|
.calendar-event-separator {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #4d6764;
|
color: var(--calendar-event-muted-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-more-events {
|
.calendar-more-events {
|
||||||
@@ -635,8 +635,8 @@
|
|||||||
.calendar-time-corner,
|
.calendar-time-corner,
|
||||||
.calendar-all-day-label,
|
.calendar-all-day-label,
|
||||||
.calendar-all-day-cell {
|
.calendar-all-day-cell {
|
||||||
border-bottom: 1px solid var(--line-dark);
|
border-bottom: var(--border-line-dark);
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,12 +649,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-header > header.is-today {
|
.calendar-time-header > header.is-today {
|
||||||
background: #e0f0ea;
|
background: var(--calendar-selected-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-header > header.is-weekend,
|
.calendar-time-header > header.is-weekend,
|
||||||
.calendar-all-day-cell.is-weekend {
|
.calendar-all-day-cell.is-weekend {
|
||||||
background: #f1efeb;
|
background: var(--control-gradient-end-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-header > header span {
|
.calendar-time-header > header span {
|
||||||
@@ -665,7 +665,7 @@
|
|||||||
.calendar-all-day-strip {
|
.calendar-all-day-strip {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border-bottom: 1px solid var(--line-dark);
|
border-bottom: var(--border-line-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-all-day-label {
|
.calendar-all-day-label {
|
||||||
@@ -683,7 +683,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-all-day-cell.is-drop-target {
|
.calendar-all-day-cell.is-drop-target {
|
||||||
background: #e3f3ef;
|
background: var(--calendar-today-bg);
|
||||||
box-shadow: inset 0 0 0 2px var(--green);
|
box-shadow: inset 0 0 0 2px var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,8 +712,8 @@
|
|||||||
|
|
||||||
.calendar-hour-label {
|
.calendar-hour-label {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -724,17 +724,17 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 1536px;
|
height: 1536px;
|
||||||
min-height: 1536px;
|
min-height: 1536px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(to bottom, var(--calendar-day-shade), var(--calendar-day-shade)),
|
linear-gradient(to bottom, var(--calendar-day-shade), var(--calendar-day-shade)),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 0,
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 0,
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-start-y),
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-start-y),
|
||||||
transparent var(--calendar-work-start-y),
|
transparent var(--calendar-work-start-y),
|
||||||
transparent var(--calendar-work-end-y),
|
transparent var(--calendar-work-end-y),
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-end-y),
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-end-y),
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 100%
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 100%
|
||||||
),
|
),
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
@@ -746,11 +746,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-time-column.is-weekend {
|
.calendar-day-time-column.is-weekend {
|
||||||
--calendar-day-shade: rgba(241, 239, 235, .74);
|
--calendar-day-shade: var(--calendar-day-shade);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-time-column.is-drop-target {
|
.calendar-day-time-column.is-drop-target {
|
||||||
box-shadow: inset 0 0 0 2px rgba(90, 169, 155, .55);
|
box-shadow: var(--calendar-focus-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-drop-marker {
|
.calendar-time-drop-marker {
|
||||||
@@ -778,10 +778,10 @@
|
|||||||
top: -13px;
|
top: -13px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border: 1px solid #4f9489;
|
border: 1px solid var(--calendar-success-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -799,15 +799,15 @@
|
|||||||
|
|
||||||
.calendar-timed-event {
|
.calendar-timed-event {
|
||||||
--calendar-event-color: var(--green);
|
--calendar-event-color: var(--green);
|
||||||
--calendar-event-bg: #edf8f5;
|
--calendar-event-bg: var(--calendar-event-bg-default);
|
||||||
--calendar-event-border: #b9d7d0;
|
--calendar-event-border: var(--calendar-event-border-default);
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--calendar-event-border);
|
border: 1px solid var(--calendar-event-border);
|
||||||
border-left: 4px solid var(--calendar-event-color);
|
border-left: 4px solid var(--calendar-event-color);
|
||||||
background: var(--calendar-event-bg);
|
background: var(--calendar-event-bg);
|
||||||
color: #21453e;
|
color: var(--calendar-event-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -817,8 +817,8 @@
|
|||||||
.calendar-timed-event.is-linked-hover,
|
.calendar-timed-event.is-linked-hover,
|
||||||
.calendar-timed-overflow:hover,
|
.calendar-timed-overflow:hover,
|
||||||
.calendar-timed-overflow:focus-visible {
|
.calendar-timed-overflow:focus-visible {
|
||||||
border-color: var(--calendar-event-color, #5aa99b);
|
border-color: var(--calendar-event-color, var(--success-border));
|
||||||
background: var(--calendar-event-bg, #e3f3ef);
|
background: var(--calendar-event-bg, var(--calendar-today-bg));
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,7 +837,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-timed-event-content:focus-visible {
|
.calendar-timed-event-content:focus-visible {
|
||||||
outline: 2px solid rgba(90, 169, 155, .35);
|
outline: var(--calendar-focus-outline);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,7 +864,7 @@
|
|||||||
width: 34px;
|
width: 34px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(33, 69, 62, .46);
|
background: var(--calendar-overlay);
|
||||||
content: "";
|
content: "";
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
@@ -890,7 +890,7 @@
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid var(--line-dark);
|
border: var(--border-line-dark);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -963,7 +963,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
@@ -1051,7 +1051,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-vevent-details {
|
.calendar-vevent-details {
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,7 +1059,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-vevent-section:first-of-type {
|
.calendar-vevent-section:first-of-type {
|
||||||
@@ -1085,7 +1085,7 @@
|
|||||||
|
|
||||||
.calendar-sync-status {
|
.calendar-sync-status {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border: 1px solid var(--line-dark);
|
border: var(--border-line-dark);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1094,8 +1094,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-sync-status.is-error {
|
.calendar-sync-status.is-error {
|
||||||
border-color: #f0b8b2;
|
border-color: var(--calendar-danger-border);
|
||||||
background: #fff2f0;
|
background: var(--calendar-danger-bg);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,7 +1115,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
@@ -1150,9 +1150,9 @@
|
|||||||
.calendar-form-error {
|
.calendar-form-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
border: 1px solid #f0b8b2;
|
border: 1px solid var(--calendar-danger-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff2f0;
|
background: var(--calendar-danger-bg);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -1166,13 +1166,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-delete-warning {
|
.calendar-delete-warning {
|
||||||
border: 1px solid #f0b8b2;
|
border: 1px solid var(--calendar-danger-border);
|
||||||
background: #fff2f0;
|
background: var(--calendar-danger-bg);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-form-note {
|
.calendar-form-note {
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -1182,7 +1182,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
@@ -1275,7 +1275,7 @@
|
|||||||
|
|
||||||
.calendar-sidebar {
|
.calendar-sidebar {
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-mode-switch {
|
.calendar-mode-switch {
|
||||||
|
|||||||
Reference in New Issue
Block a user