Compare commits

6 Commits

39 changed files with 5804 additions and 453 deletions

1
.gitignore vendored
View File

@@ -118,6 +118,7 @@ dist
.template-preview-test-build/ .template-preview-test-build/
.import-test-build/ .import-test-build/
webui/.component-test-build/ webui/.component-test-build/
webui/.calendar-picker-test-build/
webui/.module-test-build/ webui/.module-test-build/
webui/.policy-test-build/ webui/.policy-test-build/
webui/.template-preview-test-build/ webui/.template-preview-test-build/

View File

@@ -1,5 +1,9 @@
# govoplan-calendar # govoplan-calendar
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware. GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware.
## Ownership ## Ownership

View File

@@ -24,10 +24,88 @@ The first standalone module provides:
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS - API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks - free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling - calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
state and the exact external resource snapshot commit atomically; workers use
deterministic hrefs, conditional requests, expiring leases, bounded
exponential retry, and semantic GET reconciliation after ambiguous outcomes
- WebUI views: month, week, workweek, day, and continuous week-row scrolling - WebUI views: month, week, workweek, day, and continuous week-row scrolling
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built. The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
## Outbound delivery operations
No event API or cross-module capability performs CalDAV network I/O inside the
caller's transaction. A local mutation instead creates a
`calendar_outbox_operations` row containing the exact desired ICS resource,
target href, expected ETag, conflict-policy snapshot, and idempotency key.
The registered `govoplan.calendar.dispatch_outbox` worker commits an expiring
lease before doing network I/O and commits each outcome independently. If a
worker loses the database connection after a successful remote write, the next
attempt reads the remote object and compares semantic ICS fingerprints. A
matching PUT or an already absent DELETE completes successfully without a blind
duplicate write. Pending updates for one href supersede older never-attempted
rows; attempted predecessors are reconciled first, and updates queued behind an
in-flight write inherit the ETag produced by that write. Delivery and inbound
REPORT application take the same source-row lock,
and only one operation per source is leased at a time. This deliberately trades
per-source throughput for a simple ordering guarantee: a stale inbound report
cannot land after a newer outbound result, and queued leases do not expire while
waiting behind another network request for the same source.
Operators can list, dispatch, retry, reconcile, and discard tenant operations
through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag
conflicts and exhausted retries remain the current local desired state and
shield that resource from inbound overwrite until explicitly resolved.
Discarding is the administrator's accept-remote transition: it is allowed only
for the latest generation, atomically cancels its unresolved predecessor chain,
marks the event projection as discarded, clears the sync token, and schedules a
full inbound reconciliation so an unchanged remote object is not missed.
Disabling outbound delivery or switching to inbound-only is rejected while
unresolved desired state exists; retirement is the explicit exception and
cancels unresolved work. Endpoint/calendar changes also require no active event
bindings or unresolved delivery. Credential rotation does not discard committed
desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
or ETags. Deleting a synchronized collection or retiring its source is a local
unlink: it never deletes the remote collection or its remaining remote events.
The current singular ownership model permits one active sync source per
calendar; multi-source fan-in will require per-event source routing. Celery beat
triggers recovery every minute, while root-transaction after-commit dispatch
provides the normal low-latency path.
### Collection retirement and bulk event moves
Collection deletion accepts two explicit, non-destructive external actions for
`event_action="move"`:
- `detach_keep_remote` applies only when the source collection is synchronized
and the target is local. It moves the local event projections, clears their
sync-owned source kind, href, ETag, and CalDAV projection metadata, retires
the source locally, and cancels undelivered outbox state. An active delivery
lease blocks the transaction. The action never enqueues a remote DELETE, so
the remote collection and remote events remain unchanged.
- `copy_to_remote` applies only when the source is local and the target has an
active, enabled, two-way CalDAV source. It moves the local events and commits
destination PUT desired states in the durable outbox in the same database
transaction. Remote failures therefore remain visible and retryable without
rolling back or hiding the local move.
Local-to-local moves retain their existing behavior and do not accept an
`external_action`. Implicit or mismatched actions, inbound-only destinations,
and synchronized-to-synchronized moves are rejected. `remote_move` is a known
but deliberately unsupported action: implementing it requires a separately
approved saga for destination reconciliation, conditional source deletion,
collision handling, and concurrent-edit policy. The WebUI does not offer that
destructive mode.
Resolved terminal rows (`succeeded`, `superseded`, and `cancelled`) are removed
in bounded batches after `CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS` (90 days by
default; `0` disables cleanup). `conflict` and `dead` rows are never removed by
this cleanup because they still carry unresolved local desired state. Each
periodic or after-commit dispatch also performs one bounded cleanup batch for
the same tenant scope.
## Integration Points ## Integration Points
### Scheduling ### Scheduling

View File

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

View File

@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-calendar" name = "govoplan-calendar"
version = "0.1.7" version = "0.1.8"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration." description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.7", "govoplan-core>=0.1.8",
"govoplan-access>=0.1.7", "govoplan-access>=0.1.8",
"defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",
"python-dateutil>=2.9", "python-dateutil>=2.9",
] ]

View File

@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import posixpath
import urllib.error 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
class CalDAVError(RuntimeError): class CalDAVError(RuntimeError):
@@ -75,6 +77,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,
@@ -228,8 +242,23 @@ class CalDAVClient:
return parse_multistatus(payload) return parse_multistatus(payload)
def fetch_object(self, href: str) -> str: def fetch_object(self, href: str) -> str:
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200}) return self.fetch_object_state(href).calendar_data or ""
return payload.decode("utf-8")
def fetch_object_state(self, href: str) -> CalDAVObject:
"""Fetch a resource together with its ETag for outbox reconciliation."""
_status, headers, payload = self.request_raw(
"GET",
self.object_url(href),
body=None,
depth=None,
expected={200},
)
return CalDAVObject(
href=href,
etag=response_etag(headers),
calendar_data=payload.decode("utf-8"),
)
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult: 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"} headers = {"Content-Type": "text/calendar; charset=utf-8"}
@@ -266,7 +295,32 @@ class CalDAVClient:
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status) return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str: def object_url(self, href: str) -> str:
return urllib.parse.urljoin(self.collection_url, href) candidate = urllib.parse.urljoin(self.collection_url, href)
collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate)
if (
candidate_parts.username
or candidate_parts.password
or (
candidate_parts.scheme.lower(),
candidate_parts.hostname,
candidate_parts.port,
)
!= (
collection_parts.scheme.lower(),
collection_parts.hostname,
collection_parts.port,
)
):
raise CalDAVError("CalDAV object href must use the configured collection origin")
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
raise CalDAVError("CalDAV object href must remain inside the configured collection path")
if candidate_parts.query or candidate_parts.fragment:
raise CalDAVError("CalDAV object href must not contain a query or fragment")
return urllib.parse.urlunparse(candidate_parts)
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes: 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) _status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
@@ -308,9 +362,10 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]: def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
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: 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()
@@ -322,8 +377,8 @@ def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: by
def parse_multistatus(payload: bytes) -> CalDAVReportResult: def parse_multistatus(payload: bytes) -> CalDAVReportResult:
try: try:
root = ElementTree.fromstring(payload) root = SafeElementTree.fromstring(payload)
except ElementTree.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
objects: list[CalDAVObject] = [] objects: list[CalDAVObject] = []
sync_token = first_child_text(root, "sync-token") sync_token = first_child_text(root, "sync-token")
@@ -355,72 +410,106 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult:
def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]: def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
try: try:
root = ElementTree.fromstring(payload) root = SafeElementTree.fromstring(payload)
except ElementTree.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
for response in child_elements(root, "response")
if (parsed := _parse_discovery_response(response)) is not None
]
def _parse_discovery_response(response: Any) -> _DAVDiscoveryResponse | None:
href = first_child_text(response, "href") href = first_child_text(response, "href")
if not href: if not href:
continue return None
display_name = None draft = _DAVDiscoveryDraft()
color = None
ctag = None
sync_token = None
is_calendar = False
principal_hrefs: list[str] = []
calendar_home_set_hrefs: list[str] = []
supported_components: list[str] = []
for propstat in child_elements(response, "propstat"): for propstat in child_elements(response, "propstat"):
status = first_child_text(propstat, "status") or "" _apply_discovery_propstat(draft, propstat)
if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status: return _DAVDiscoveryResponse(
continue 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") prop = first_child(propstat, "prop")
if prop is None: if prop is None:
continue return
for item in prop: 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) name = local_name(item.tag)
if name == "displayname" and item.text: text = item.text.strip() if item.text else ""
display_name = item.text.strip() or display_name if name == "displayname" and text:
elif name == "calendar-color" and item.text: draft.display_name = text or draft.display_name
color = item.text.strip() or color elif name == "calendar-color" and text:
elif name == "getctag" and item.text: draft.color = text or draft.color
ctag = item.text.strip() or ctag elif name == "getctag" and text:
elif name == "sync-token" and item.text: draft.ctag = text or draft.ctag
sync_token = item.text.strip() or sync_token elif name == "sync-token" and text:
draft.sync_token = text or draft.sync_token
elif name == "resourcetype": elif name == "resourcetype":
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item) draft.is_calendar = draft.is_calendar or discovery_resource_is_calendar(item)
elif name in {"current-user-principal", "principal-URL"}: elif name in {"current-user-principal", "principal-URL"}:
principal_hrefs.extend(nested_href_texts(item)) draft.principal_hrefs.extend(nested_href_texts(item))
elif name == "calendar-home-set": elif name == "calendar-home-set":
calendar_home_set_hrefs.extend(nested_href_texts(item)) draft.calendar_home_set_hrefs.extend(nested_href_texts(item))
elif name == "supported-calendar-component-set": 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: for component in item:
if local_name(component.tag) == "comp": if local_name(component.tag) != "comp":
continue
component_name = (component.attrib.get("name") or "").strip().upper() component_name = (component.attrib.get("name") or "").strip().upper()
if component_name: if component_name:
supported_components.append(component_name) names.append(component_name)
responses.append( return names
_DAVDiscoveryResponse(
href=href,
display_name=display_name, def dedupe_tuple(values: list[str]) -> tuple[str, ...]:
color=color, return tuple(dict.fromkeys(values))
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:
value = value.strip() value = value.strip()
if value and "://" not in value and not value.startswith("/"): if value and "://" not in value and not value.startswith("/"):
value = f"https://{value}" value = f"https://{value}"
return value if value.endswith("/") else f"{value}/" url = validate_http_url(value)
return url if url.endswith("/") else f"{url}/"
def validate_http_url(value: str) -> str:
parsed = urllib.parse.urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise CalDAVError("CalDAV URL must be an absolute HTTP(S) URL")
if parsed.username or parsed.password:
raise CalDAVError("CalDAV URL must not include embedded credentials")
return urllib.parse.urlunparse(parsed)
def absolute_dav_url(base_url: str, href: str) -> str: def absolute_dav_url(base_url: str, href: str) -> str:
@@ -444,25 +533,25 @@ def xml_escape(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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():

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime
from govoplan_core.core.calendar import (
CalendarCapabilityError,
CalendarEventRef,
CalendarEventRequest,
CalendarSchedulingProvider,
)
from govoplan_calendar.backend.schemas import CalendarEventCreateRequest
from govoplan_calendar.backend.service import CalendarError, create_event, list_freebusy
class SqlCalendarSchedulingProvider(CalendarSchedulingProvider):
def list_freebusy(
self,
session: object,
*,
tenant_id: str,
start_at: datetime,
end_at: datetime,
calendar_ids: Sequence[str] | None = None,
) -> tuple[Mapping[str, object], ...]:
try:
blocks = list_freebusy(
session,
tenant_id=tenant_id,
start_at=start_at,
end_at=end_at,
calendar_ids=list(calendar_ids) if calendar_ids is not None else None,
)
except CalendarError as exc:
raise CalendarCapabilityError(str(exc)) from exc
return tuple(blocks)
def create_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarEventRequest,
) -> CalendarEventRef:
try:
payload = CalendarEventCreateRequest(
calendar_id=request.calendar_id,
summary=request.summary,
description=request.description,
location=request.location,
status=request.status,
transparency=request.transparency,
classification=request.classification,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
attendees=[dict(item) for item in request.attendees],
categories=list(request.categories),
related_to=[dict(item) for item in request.related_to],
metadata=dict(request.metadata),
)
except (TypeError, ValueError) as exc:
raise CalendarCapabilityError(str(exc)) from exc
try:
event = create_event(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
except CalendarError as exc:
raise CalendarCapabilityError(str(exc)) from exc
caldav_metadata = (event.metadata_ or {}).get("caldav")
external_state = "local"
outbox_operation_id = None
if isinstance(caldav_metadata, dict):
external_state = str(caldav_metadata.get("external_state") or "queued")
raw_operation_id = caldav_metadata.get("outbox_operation_id")
outbox_operation_id = str(raw_operation_id) if raw_operation_id else None
return CalendarEventRef(
id=event.id,
calendar_id=event.calendar_id,
uid=event.uid,
external_state=external_state,
outbox_operation_id=outbox_operation_id,
)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
@@ -151,4 +151,69 @@ class CalendarSyncCredential(Base, TimestampMixin):
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
__all__ = ["CalendarCollection", "CalendarEvent", "CalendarSyncCredential", "CalendarSyncSource", "new_uuid"] class CalendarOutboxOperation(Base, TimestampMixin):
"""Durable desired state for one external CalDAV resource.
Rows are inserted in the same transaction as the local event mutation. A
dispatcher leases and commits the row before performing network I/O, so a
worker crash can be detected and reconciled without repeating a blind
write.
"""
__tablename__ = "calendar_outbox_operations"
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_calendar_outbox_operations_idempotency_key"),
Index("ix_calendar_outbox_due", "status", "available_at", "created_at"),
Index("ix_calendar_outbox_tenant_status", "tenant_id", "status"),
Index("ix_calendar_outbox_resource", "source_id", "resource_href", "created_at"),
Index("ix_calendar_outbox_lease", "status", "lease_expires_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
source_id: Mapped[str] = mapped_column(
ForeignKey("calendar_sync_sources.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
event_id: Mapped[str | None] = mapped_column(
ForeignKey("calendar_events.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
operation_kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
resource_href: Mapped[str] = mapped_column(String(1000), nullable=False)
payload_ics: Mapped[str | None] = mapped_column(Text)
payload_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
expected_etag: Mapped[str | None] = mapped_column(String(255))
idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="pending", nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, default=8, nullable=False)
available_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
index=True,
)
last_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
lease_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
reconciled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
remote_etag: Mapped[str | None] = mapped_column(String(255))
last_error: Mapped[str | None] = mapped_column(Text)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
source: Mapped[CalendarSyncSource] = relationship()
event: Mapped[CalendarEvent | None] = relationship()
__all__ = [
"CalendarCollection",
"CalendarEvent",
"CalendarOutboxOperation",
"CalendarSyncCredential",
"CalendarSyncSource",
"new_uuid",
]

View File

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

View File

@@ -4,8 +4,23 @@ from pathlib import Path
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.calendar import (
CALENDAR_AVAILABILITY_READ_SCOPE,
CALENDAR_EVENT_WRITE_SCOPE,
CAPABILITY_CALENDAR_OUTBOX,
CAPABILITY_CALENDAR_SCHEDULING,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate from govoplan_core.core.modules import (
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -28,11 +43,11 @@ PERMISSIONS = (
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."), _permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."), _permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."), _permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
_permission("calendar:event:write", "Manage calendar events", "Create and edit calendar events."), _permission(CALENDAR_EVENT_WRITE_SCOPE, "Manage calendar events", "Create and edit calendar events."),
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."), _permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."), _permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."), _permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
_permission("calendar:availability:read", "Read availability", "Read free/busy and availability data for integrations."), _permission(CALENDAR_AVAILABILITY_READ_SCOPE, "Read availability", "Read free/busy and availability data for integrations."),
) )
ROLE_TEMPLATES = ( ROLE_TEMPLATES = (
@@ -44,11 +59,11 @@ ROLE_TEMPLATES = (
"calendar:calendar:read", "calendar:calendar:read",
"calendar:calendar:write", "calendar:calendar:write",
"calendar:event:read", "calendar:event:read",
"calendar:event:write", CALENDAR_EVENT_WRITE_SCOPE,
"calendar:event:delete", "calendar:event:delete",
"calendar:event:import", "calendar:event:import",
"calendar:event:export", "calendar:event:export",
"calendar:availability:read", CALENDAR_AVAILABILITY_READ_SCOPE,
), ),
), ),
RoleTemplate( RoleTemplate(
@@ -59,20 +74,32 @@ ROLE_TEMPLATES = (
"calendar:calendar:read", "calendar:calendar:read",
"calendar:event:read", "calendar:event:read",
"calendar:event:export", "calendar:event:export",
"calendar:availability:read", CALENDAR_AVAILABILITY_READ_SCOPE,
), ),
), ),
) )
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource from govoplan_calendar.backend.db.models import (
CalendarCollection,
CalendarEvent,
CalendarOutboxOperation,
CalendarSyncCredential,
CalendarSyncSource,
)
return { return {
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(), "calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(), "calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(), "calendar_sync_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(), "calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.status.in_(("pending", "retry", "in_progress")),
)
.count(),
} }
@@ -85,16 +112,46 @@ def _calendar_router(context: ModuleContext):
return router return router
def _calendar_scheduling_provider(context: ModuleContext) -> object:
del context
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
return SqlCalendarSchedulingProvider()
def _calendar_outbox_provider(context: ModuleContext) -> object:
from govoplan_calendar.backend.outbox import (
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
SqlCalendarOutboxProvider,
)
return SqlCalendarOutboxProvider(
terminal_retention_days=getattr(
context.settings,
"calendar_outbox_terminal_retention_days",
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
)
)
manifest = ModuleManifest( manifest = ModuleManifest(
id="calendar", id="calendar",
name="Calendar", name="Calendar",
version="0.1.7", version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"), optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
provides_interfaces=(
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
route_factory=_calendar_router, route_factory=_calendar_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
capability_factories={
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
},
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),), nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
frontend=FrontendModule( frontend=FrontendModule(
module_id="calendar", module_id="calendar",
@@ -109,6 +166,7 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider( retirement_provider=drop_table_retirement_provider(
calendar_models.CalendarCollection, calendar_models.CalendarCollection,
calendar_models.CalendarEvent, calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential, calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource, calendar_models.CalendarSyncSource,
label="Calendar", label="Calendar",
@@ -119,6 +177,7 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
calendar_models.CalendarCollection, calendar_models.CalendarCollection,
calendar_models.CalendarEvent, calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential, calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource, calendar_models.CalendarSyncSource,
label="Calendar", label="Calendar",

View File

@@ -0,0 +1 @@
"""Calendar Alembic revisions."""

View File

@@ -0,0 +1,28 @@
"""Add the durable CalDAV desired-state outbox on the development track.
Revision ID: af1b2c3d4e5f
Revises: 9e0f1a2b3c4d
Create Date: 2026-07-20 00:00:00.000000
"""
from __future__ import annotations
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
downgrade as _downgrade,
)
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
upgrade as _upgrade,
)
revision = "af1b2c3d4e5f"
down_revision = "9e0f1a2b3c4d"
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
_upgrade()
def downgrade() -> None:
_downgrade()

View File

@@ -0,0 +1,174 @@
"""v0.1.7 calendar baseline
Revision ID: 9e0f1a2b3c4d
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '9e0f1a2b3c4d'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('calendar_collections',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('timezone', sa.String(length=100), nullable=False),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('owner_type', sa.String(length=20), nullable=False),
sa.Column('owner_id', sa.String(length=36), nullable=True),
sa.Column('visibility', sa.String(length=20), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False),
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'], ['access_users.id'], name=op.f('fk_calendar_collections_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_collections_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_collections'))
)
op.create_index(op.f('ix_calendar_collections_created_by_user_id'), 'calendar_collections', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_deleted_at'), 'calendar_collections', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_collections_is_default'), 'calendar_collections', ['is_default'], unique=False)
op.create_index('ix_calendar_collections_owner', 'calendar_collections', ['tenant_id', 'owner_type', 'owner_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_owner_id'), 'calendar_collections', ['owner_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_owner_type'), 'calendar_collections', ['owner_type'], unique=False)
op.create_index(op.f('ix_calendar_collections_tenant_id'), 'calendar_collections', ['tenant_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_visibility'), 'calendar_collections', ['visibility'], unique=False)
op.create_index('uq_calendar_collections_active_slug', 'calendar_collections', ['tenant_id', 'slug'], unique=True, sqlite_where=sa.text('deleted_at IS NULL'), postgresql_where=sa.text('deleted_at IS NULL'))
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'], ['access_users.id'], name=op.f('fk_calendar_sync_credentials_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_sync_credentials_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_credentials'))
)
op.create_index(op.f('ix_calendar_sync_credentials_created_by_user_id'), 'calendar_sync_credentials', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_credential_kind'), 'calendar_sync_credentials', ['credential_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_deleted_at'), 'calendar_sync_credentials', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_tenant_id'), 'calendar_sync_credentials', ['tenant_id'], unique=False)
op.create_index('ix_calendar_sync_credentials_tenant_kind', 'calendar_sync_credentials', ['tenant_id', 'credential_kind'], unique=False)
op.create_table('calendar_events',
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('uid', sa.String(length=255), nullable=False),
sa.Column('recurrence_id', sa.String(length=255), nullable=True),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('summary', sa.String(length=500), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('location', sa.String(length=500), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('transparency', sa.String(length=20), nullable=False),
sa.Column('classification', sa.String(length=20), nullable=False),
sa.Column('start_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('timezone', sa.String(length=100), nullable=True),
sa.Column('organizer', sa.JSON(), nullable=True),
sa.Column('attendees', sa.JSON(), nullable=False),
sa.Column('categories', sa.JSON(), nullable=False),
sa.Column('rrule', sa.JSON(), nullable=True),
sa.Column('rdate', sa.JSON(), nullable=False),
sa.Column('exdate', sa.JSON(), nullable=False),
sa.Column('reminders', sa.JSON(), nullable=False),
sa.Column('attachments', sa.JSON(), nullable=False),
sa.Column('related_to', sa.JSON(), nullable=False),
sa.Column('source_kind', sa.String(length=30), nullable=False),
sa.Column('source_href', sa.String(length=1000), nullable=True),
sa.Column('etag', sa.String(length=255), nullable=True),
sa.Column('icalendar', sa.JSON(), nullable=False),
sa.Column('raw_ics', sa.Text(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_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(['calendar_id'], ['calendar_collections.id'], name=op.f('fk_calendar_events_calendar_id_calendar_collections'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_events_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_events_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_events_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_events')),
sa.UniqueConstraint('calendar_id', 'uid', 'recurrence_id', name='uq_calendar_events_calendar_uid_recurrence')
)
op.create_index(op.f('ix_calendar_events_all_day'), 'calendar_events', ['all_day'], unique=False)
op.create_index(op.f('ix_calendar_events_calendar_id'), 'calendar_events', ['calendar_id'], unique=False)
op.create_index(op.f('ix_calendar_events_created_by_user_id'), 'calendar_events', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_events_deleted_at'), 'calendar_events', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_events_end_at'), 'calendar_events', ['end_at'], unique=False)
op.create_index(op.f('ix_calendar_events_etag'), 'calendar_events', ['etag'], unique=False)
op.create_index('ix_calendar_events_range', 'calendar_events', ['tenant_id', 'calendar_id', 'start_at', 'end_at'], unique=False)
op.create_index(op.f('ix_calendar_events_recurrence_id'), 'calendar_events', ['recurrence_id'], unique=False)
op.create_index(op.f('ix_calendar_events_source_kind'), 'calendar_events', ['source_kind'], unique=False)
op.create_index(op.f('ix_calendar_events_start_at'), 'calendar_events', ['start_at'], unique=False)
op.create_index(op.f('ix_calendar_events_status'), 'calendar_events', ['status'], unique=False)
op.create_index(op.f('ix_calendar_events_tenant_id'), 'calendar_events', ['tenant_id'], unique=False)
op.create_index('ix_calendar_events_tenant_status', 'calendar_events', ['tenant_id', 'status'], unique=False)
op.create_index('ix_calendar_events_tenant_uid', 'calendar_events', ['tenant_id', 'uid'], unique=False)
op.create_index(op.f('ix_calendar_events_uid'), 'calendar_events', ['uid'], unique=False)
op.create_index(op.f('ix_calendar_events_updated_by_user_id'), 'calendar_events', ['updated_by_user_id'], unique=False)
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_enabled', sa.Boolean(), nullable=False),
sa.Column('sync_interval_seconds', sa.Integer(), nullable=False),
sa.Column('sync_direction', sa.String(length=30), nullable=False),
sa.Column('conflict_policy', sa.String(length=30), nullable=False),
sa.Column('sync_token', sa.Text(), nullable=True),
sa.Column('ctag', sa.String(length=255), nullable=True),
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_sync_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'], ['core_scopes.id'], name=op.f('fk_calendar_sync_sources_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_sources'))
)
op.create_index('ix_calendar_sync_sources_calendar', 'calendar_sync_sources', ['tenant_id', 'calendar_id', 'source_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_calendar_id'), 'calendar_sync_sources', ['calendar_id'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_deleted_at'), 'calendar_sync_sources', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_next_sync_at'), 'calendar_sync_sources', ['next_sync_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_source_kind'), 'calendar_sync_sources', ['source_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_sync_enabled'), 'calendar_sync_sources', ['sync_enabled'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_tenant_id'), 'calendar_sync_sources', ['tenant_id'], 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:
op.drop_table('calendar_sync_sources')
op.drop_table('calendar_events')
op.drop_table('calendar_sync_credentials')
op.drop_table('calendar_collections')

View File

@@ -0,0 +1,114 @@
"""Add the durable CalDAV desired-state outbox.
Revision ID: af1b2c3d4e5f
Revises: 9e0f1a2b3c4d
Create Date: 2026-07-20 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "af1b2c3d4e5f"
down_revision = "9e0f1a2b3c4d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_outbox_operations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=True),
sa.Column("operation_kind", sa.String(length=20), nullable=False),
sa.Column("resource_href", sa.String(length=1000), nullable=False),
sa.Column("payload_ics", sa.Text(), nullable=True),
sa.Column("payload_fingerprint", sa.String(length=64), nullable=True),
sa.Column("expected_etag", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("attempt_count", sa.Integer(), nullable=False),
sa.Column("max_attempts", sa.Integer(), nullable=False),
sa.Column("available_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("lease_token", sa.String(length=36), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reconciled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("remote_etag", sa.String(length=255), nullable=True),
sa.Column("last_error", sa.Text(), 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(
["event_id"],
["calendar_events.id"],
name=op.f("fk_calendar_outbox_operations_event_id_calendar_events"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["source_id"],
["calendar_sync_sources.id"],
name=op.f("fk_calendar_outbox_operations_source_id_calendar_sync_sources"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_calendar_outbox_operations_tenant_id_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_outbox_operations")),
sa.UniqueConstraint(
"idempotency_key",
name="uq_calendar_outbox_operations_idempotency_key",
),
)
op.create_index(
"ix_calendar_outbox_due",
"calendar_outbox_operations",
["status", "available_at", "created_at"],
unique=False,
)
op.create_index(
"ix_calendar_outbox_lease",
"calendar_outbox_operations",
["status", "lease_expires_at"],
unique=False,
)
op.create_index(
"ix_calendar_outbox_resource",
"calendar_outbox_operations",
["source_id", "resource_href", "created_at"],
unique=False,
)
op.create_index(
"ix_calendar_outbox_tenant_status",
"calendar_outbox_operations",
["tenant_id", "status"],
unique=False,
)
for column in (
"available_at",
"event_id",
"lease_expires_at",
"lease_token",
"operation_kind",
"payload_fingerprint",
"source_id",
"status",
"tenant_id",
):
op.create_index(
op.f(f"ix_calendar_outbox_operations_{column}"),
"calendar_outbox_operations",
[column],
unique=False,
)
def downgrade() -> None:
op.drop_table("calendar_outbox_operations")

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
from govoplan_calendar.backend.db.models import CalendarEvent from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
@@ -35,6 +36,9 @@ from govoplan_calendar.backend.schemas import (
CalendarFreeBusyRequest, CalendarFreeBusyRequest,
CalendarFreeBusyResponse, CalendarFreeBusyResponse,
CalendarIcsImportRequest, CalendarIcsImportRequest,
CalendarOutboxDispatchResponse,
CalendarOutboxOperationListResponse,
CalendarOutboxOperationResponse,
CalendarSyncDueSyncItemResponse, CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse, CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest, CalendarSyncSourceCreateRequest,
@@ -44,6 +48,14 @@ from govoplan_calendar.backend.schemas import (
CalendarSyncSourceSyncResponse, CalendarSyncSourceSyncResponse,
CalendarSyncSourceUpdateRequest, CalendarSyncSourceUpdateRequest,
) )
from govoplan_calendar.backend.outbox import (
calendar_outbox_operation_response,
discard_calendar_outbox_operation,
dispatch_calendar_outbox,
list_calendar_outbox_operations,
reconcile_calendar_outbox_operation,
retry_calendar_outbox_operation,
)
from govoplan_calendar.backend.service import ( from govoplan_calendar.backend.service import (
CALENDAR_EVENTS_COLLECTION, CALENDAR_EVENTS_COLLECTION,
CALENDAR_EVENT_RESOURCE, CALENDAR_EVENT_RESOURCE,
@@ -62,7 +74,6 @@ from govoplan_calendar.backend.service import (
delete_event, delete_event,
discover_caldav_calendars, discover_caldav_calendars,
event_response, event_response,
get_caldav_source,
get_event, get_event,
import_ics_event, import_ics_event,
list_freebusy, list_freebusy,
@@ -220,13 +231,25 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source)) return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
def _outbox_operation_response(operation) -> CalendarOutboxOperationResponse:
return CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(operation)
)
@router.get("/calendars", response_model=CalendarCollectionListResponse) @router.get("/calendars", response_model=CalendarCollectionListResponse)
def api_list_calendars( def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:calendar:read") _require_scope(principal, "calendar:calendar:read")
calendars = list_calendars(session, tenant_id=principal.tenant_id, user_id=principal.user.id) calendars = list_calendars(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_admin=principal.has("calendar:calendar:admin"),
)
session.commit() session.commit()
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars]) return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
@@ -343,7 +366,12 @@ def api_delete_sync_source(
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
session.rollback() session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/sync-sources/{source_id}/sync", response_model=CalendarSyncSourceSyncResponse) @router.post("/sync-sources/{source_id}/sync", response_model=CalendarSyncSourceSyncResponse)
@@ -473,7 +501,12 @@ def api_delete_caldav_source(
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
session.rollback() session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse) @router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
@@ -530,6 +563,117 @@ def api_sync_due_caldav_sources(
) )
@router.get("/caldav/outbox", response_model=CalendarOutboxOperationListResponse)
def api_list_caldav_outbox(
operation_status: str | None = Query(default=None, alias="status"),
source_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
operations = list_calendar_outbox_operations(
session,
tenant_id=principal.tenant_id,
status=operation_status,
source_id=source_id,
limit=limit,
)
return CalendarOutboxOperationListResponse(
operations=[_outbox_operation_response(operation) for operation in operations]
)
@router.post("/caldav/outbox/dispatch", response_model=CalendarOutboxDispatchResponse)
def api_dispatch_caldav_outbox(
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarOutboxDispatchResponse.model_validate(
dispatch_calendar_outbox(
session,
tenant_id=principal.tenant_id,
limit=limit,
)
)
@router.post(
"/caldav/outbox/{operation_id}/retry",
response_model=CalendarOutboxOperationResponse,
)
def api_retry_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = retry_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/reconcile",
response_model=CalendarOutboxOperationResponse,
)
def api_reconcile_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = reconcile_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except (ValueError, CalDAVError, CalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/discard",
response_model=CalendarOutboxOperationResponse,
)
def api_discard_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
"""Abandon local desired state and allow the next sync to accept remote."""
_require_scope(principal, "calendar:calendar:admin")
try:
operation = discard_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.get("/events", response_model=CalendarEventListResponse) @router.get("/events", response_model=CalendarEventListResponse)
def api_list_events( def api_list_events(
calendar_id: str | None = None, calendar_id: str | None = None,
@@ -600,7 +744,7 @@ def api_create_event(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:event:write") _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try: try:
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload) event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit() session.commit()
@@ -631,7 +775,7 @@ def api_update_event(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:event:write") _require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try: try:
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload) event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
session.commit() session.commit()
@@ -648,7 +792,7 @@ def api_delete_event(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write") _require_any_scope(principal, "calendar:event:delete", CALENDAR_EVENT_WRITE_SCOPE)
try: try:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id) delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
session.commit() session.commit()
@@ -713,7 +857,7 @@ def api_freebusy(
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:availability:read") _require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try: try:
busy = list_freebusy( busy = list_freebusy(
session, session,

View File

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

View File

@@ -11,6 +11,11 @@ from govoplan_core.api.v1.schemas import DeltaDeletedItem
CalendarOwnerType = Literal["tenant", "user", "group", "resource"] CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
CalendarVisibility = Literal["private", "tenant", "shared", "public"] CalendarVisibility = Literal["private", "tenant", "shared", "public"]
CalendarDeleteEventAction = Literal["delete", "move"] CalendarDeleteEventAction = Literal["delete", "move"]
CalendarBulkMoveExternalAction = Literal[
"detach_keep_remote",
"copy_to_remote",
"remote_move",
]
CalendarSyncAuthType = Literal["none", "basic", "bearer"] CalendarSyncAuthType = Literal["none", "basic", "bearer"]
CalendarSyncDirection = Literal["inbound", "two_way"] CalendarSyncDirection = Literal["inbound", "two_way"]
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"] CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
@@ -50,6 +55,7 @@ class CalendarCollectionDeleteRequest(BaseModel):
event_action: CalendarDeleteEventAction = "delete" event_action: CalendarDeleteEventAction = "delete"
target_calendar_id: str | None = None target_calendar_id: str | None = None
make_target_default: bool = False make_target_default: bool = False
external_action: CalendarBulkMoveExternalAction | None = None
class CalendarCollectionResponse(BaseModel): class CalendarCollectionResponse(BaseModel):
@@ -238,6 +244,50 @@ class CalendarCalDavDueSyncResponse(BaseModel):
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list) results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
class CalendarOutboxOperationResponse(BaseModel):
id: str
tenant_id: str
source_id: str
event_id: str | None = None
operation_kind: str
resource_href: str
payload_fingerprint: str | None = None
expected_etag: str | None = None
idempotency_key: str
status: str
attempt_count: int
max_attempts: int
available_at: datetime
last_attempt_at: datetime | None = None
lease_expires_at: datetime | None = None
completed_at: datetime | None = None
reconciled_at: datetime | None = None
remote_etag: str | None = None
last_error: str | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarOutboxOperationListResponse(BaseModel):
operations: list[CalendarOutboxOperationResponse] = Field(default_factory=list)
class CalendarOutboxDispatchItemResponse(BaseModel):
id: str
status: str
attempt_count: int
last_error: str | None = None
class CalendarOutboxDispatchResponse(BaseModel):
processed: int = 0
succeeded: int = 0
retrying: int = 0
failed: int = 0
operations: list[CalendarOutboxDispatchItemResponse] = Field(default_factory=list)
class CalendarEventCreateRequest(BaseModel): class CalendarEventCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

File diff suppressed because it is too large Load Diff

View File

@@ -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,8 @@ 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, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
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 +84,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 +224,20 @@ 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"): def test_object_url_rejects_off_origin_and_cross_collection_hrefs(self) -> None:
client.discover_calendars() client = CalDAVClient(collection_url="https://dav.example.test/cal")
with self.assertRaisesRegex(CalDAVError, "collection origin"):
client.object_url("https://evil.example.test/steal.ics")
with self.assertRaisesRegex(CalDAVError, "collection path"):
client.object_url("https://dav.example.test/other/steal.ics")
self.assertEqual(
client.object_url("/cal/event.ics"),
"https://dav.example.test/cal/event.ics",
)
class CalDAVSyncTests(unittest.TestCase): class CalDAVSyncTests(unittest.TestCase):
@@ -359,15 +377,42 @@ 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_create_and_update_event_puts_caldav_resource_with_etag(self) -> None: 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_coalesce_to_committed_outbox_state(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"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal")) create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
session.commit() session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"']) fake = FakeCalDAVClient(put_etags=['"etag-1"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
event = create_event( event = create_event(
session, session,
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -381,14 +426,18 @@ class CalDAVSyncTests(unittest.TestCase):
), ),
) )
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated")) update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
self.assertEqual(fake.puts, [])
session.commit() session.commit()
self.assertEqual(len(fake.puts), 2) operations = session.query(CalendarOutboxOperation).all()
self.assertCountEqual([operation.status for operation in operations], ["superseded", "pending"])
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1)
self.assertTrue(fake.puts[0]["create"]) self.assertTrue(fake.puts[0]["create"])
self.assertEqual(fake.puts[1]["etag"], '"etag-1"') self.assertIn("SUMMARY:Updated", fake.puts[0]["ics"])
self.assertFalse(fake.puts[1]["create"])
self.assertEqual(event.source_kind, "caldav") self.assertEqual(event.source_kind, "caldav")
self.assertEqual(event.etag, '"etag-2"') self.assertEqual(event.etag, '"etag-1"')
self.assertIn("SUMMARY:Updated", event.raw_ics or "") self.assertIn("SUMMARY:Updated", event.raw_ics or "")
def test_update_event_reports_remote_etag_conflict(self) -> None: def test_update_event_reports_remote_etag_conflict(self) -> None:
@@ -400,10 +449,22 @@ class CalDAVSyncTests(unittest.TestCase):
session.add(event) session.add(event)
session.commit() session.commit()
fake = FakeCalDAVClient(fail_precondition=True) fake = FakeCalDAVClient(
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake): fail_precondition=True,
with self.assertRaisesRegex(CalendarError, "changed remotely"): objects={event.source_href: recurring_resource()},
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated")) update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
session.commit()
result = dispatch_calendar_outbox(
session,
tenant_id="tenant-1",
client_factory=lambda _session, _source: fake,
)
self.assertEqual(result["failed"], 1)
operation = session.query(CalendarOutboxOperation).one()
self.assertEqual(operation.status, "conflict")
self.assertIn("changed remotely", operation.last_error or "")
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None: def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
session = self.Session() session = self.Session()
@@ -417,9 +478,9 @@ class CalDAVSyncTests(unittest.TestCase):
session.commit() session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-2"']) fake = FakeCalDAVClient(put_etags=['"etag-2"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
delete_event(session, tenant_id="tenant-1", event_id=override.id) delete_event(session, tenant_id="tenant-1", event_id=override.id)
session.commit() session.commit()
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1) self.assertEqual(len(fake.puts), 1)
self.assertEqual(fake.deletes, []) self.assertEqual(fake.deletes, [])

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
from govoplan_core.core.calendar import CalendarCapabilityError, CalendarEventRequest
class CalendarSchedulingCapabilityTests(unittest.TestCase):
def test_event_request_validation_is_exposed_as_capability_error(self) -> None:
provider = SqlCalendarSchedulingProvider()
with self.assertRaises(CalendarCapabilityError):
provider.create_event(
object(),
tenant_id="tenant-1",
user_id="user-1",
request=CalendarEventRequest(
summary="x" * 501,
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
),
)
if __name__ == "__main__":
unittest.main()

1819
tests/test_outbox.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
from govoplan_calendar.backend.service import delete_calendar, event_response from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response
class FakeSession: class FakeSession:
@@ -65,6 +65,67 @@ class CalendarServiceResponseTests(unittest.TestCase):
self.assertEqual(response.start_at.tzinfo, timezone.utc) self.assertEqual(response.start_at.tzinfo, timezone.utc)
class CalendarVisibilityTests(unittest.TestCase):
@staticmethod
def calendar(**overrides):
values = {
"visibility": "private",
"owner_type": "user",
"owner_id": "owner-1",
"created_by_user_id": "creator-1",
}
values.update(overrides)
return SimpleNamespace(**values)
def test_tenant_shared_and_public_calendars_are_visible_to_readers(self) -> None:
for visibility in ("tenant", "shared", "public"):
with self.subTest(visibility=visibility):
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(visibility=visibility),
user_id="reader-1",
)
)
def test_private_calendar_is_visible_to_user_owner_creator_or_group_member(self) -> None:
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(),
user_id="owner-1",
)
)
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(),
user_id="creator-1",
)
)
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(owner_type="group", owner_id="group-1"),
user_id="member-1",
group_ids=("group-1",),
)
)
def test_private_calendar_is_hidden_from_other_readers_but_visible_to_admin(self) -> None:
calendar = self.calendar()
self.assertFalse(
calendar_is_visible_to_principal(
calendar,
user_id="other-1",
group_ids=("group-2",),
)
)
self.assertTrue(
calendar_is_visible_to_principal(
calendar,
user_id="other-1",
can_admin=True,
)
)
class CalendarServiceDeleteTests(unittest.TestCase): class CalendarServiceDeleteTests(unittest.TestCase):
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None: def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
session = FakeSession() session = FakeSession()

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.7", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,8 +13,11 @@
}, },
"./styles/calendar.css": "./src/styles/calendar.css" "./styles/calendar.css": "./src/styles/calendar.css"
}, },
"scripts": {
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.7", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -182,10 +182,12 @@ export type CalendarCollectionCreatePayload = {
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>; export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
export type CalendarDeleteEventAction = "delete" | "move"; export type CalendarDeleteEventAction = "delete" | "move";
export type CalendarBulkMoveExternalAction = "detach_keep_remote" | "copy_to_remote" | "remote_move";
export type CalendarCollectionDeletePayload = { export type CalendarCollectionDeletePayload = {
event_action?: CalendarDeleteEventAction; event_action?: CalendarDeleteEventAction;
target_calendar_id?: string | null; target_calendar_id?: string | null;
make_target_default?: boolean; make_target_default?: boolean;
external_action?: CalendarBulkMoveExternalAction | null;
}; };
export type CalendarEventCreatePayload = { export type CalendarEventCreatePayload = {

View File

@@ -46,6 +46,7 @@ import {
type CalendarCalDavDiscoveryCandidate, type CalendarCalDavDiscoveryCandidate,
type CalendarCalDavDiscoveryPayload, type CalendarCalDavDiscoveryPayload,
type CalendarCalDavSyncDirection, type CalendarCalDavSyncDirection,
type CalendarBulkMoveExternalAction,
type CalendarCollection, type CalendarCollection,
type CalendarCollectionDeletePayload, type CalendarCollectionDeletePayload,
type CalendarDeleteEventAction, type CalendarDeleteEventAction,
@@ -234,8 +235,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);
@@ -895,6 +898,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<CalendarCollectionDeleteDialog <CalendarCollectionDeleteDialog
state={calendarDeleteDialog} state={calendarDeleteDialog}
calendars={calendars} calendars={calendars}
syncSources={syncSources}
saving={saving} saving={saving}
onCancel={() => setCalendarDeleteDialog(null)} onCancel={() => setCalendarDeleteDialog(null)}
onDelete={handleCalendarDelete} /> onDelete={handleCalendarDelete} />
@@ -1711,6 +1715,7 @@ function CalendarCollectionDialog({
function CalendarCollectionDeleteDialog({ function CalendarCollectionDeleteDialog({
state, state,
calendars, calendars,
syncSources,
saving, saving,
onCancel, onCancel,
onDelete onDelete
@@ -1720,9 +1725,13 @@ function CalendarCollectionDeleteDialog({
}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) { }: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise<void>;}) {
const { calendar, eventCount, loadingEventCount } = state; const { calendar, eventCount, loadingEventCount } = state;
const moveTargets = calendars.filter((item) => item.id !== calendar.id); const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source]));
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
const moveTargets = calendars.filter((item) =>
item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null)
);
const firstMoveTargetId = moveTargets[0]?.id || ""; const firstMoveTargetId = moveTargets[0]?.id || "";
const hasEvents = (eventCount ?? 0) > 0; const hasEvents = (eventCount ?? 0) > 0;
const canMoveEvents = hasEvents && moveTargets.length > 0; const canMoveEvents = hasEvents && moveTargets.length > 0;
@@ -1732,12 +1741,15 @@ function CalendarCollectionDeleteDialog({
const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete"; const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete";
const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId; const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId;
const actionLabel = calendarDeleteActionLabel(calendar); const actionLabel = calendarDeleteActionLabel(calendar);
const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null;
const externalAction = calendarBulkMoveExternalAction(source, targetSource);
function confirm() { function confirm() {
void onDelete(calendar, { void onDelete(calendar, {
event_action: effectiveEventAction, event_action: effectiveEventAction,
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null, target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault,
external_action: effectiveEventAction === "move" ? externalAction : null
}); });
} }
@@ -1787,7 +1799,7 @@ function CalendarCollectionDeleteDialog({
checked={eventAction === "move"} checked={eventAction === "move"}
onChange={() => setEventAction("move")} /> onChange={() => setEventAction("move")} />
<span>i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09</span> <span>{calendarBulkMoveOptionLabel(externalAction)}</span>
</label> </label>
{eventAction === "move" && {eventAction === "move" &&
<> <>
@@ -1805,10 +1817,16 @@ function CalendarCollectionDeleteDialog({
<span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span> <span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span>
</label> </label>
} }
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
</> </>
} }
</fieldset> </fieldset>
} }
{!loadingEventCount && hasEvents && moveTargets.length === 0 &&
<p className="calendar-form-note">{source ?
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65" :
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b"}</p>
}
</div> </div>
</Dialog>); </Dialog>);
@@ -2471,6 +2489,39 @@ function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string {
return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287"; return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287";
} }
function calendarMoveTargetIsSupported(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: boolean {
if (source) return targetSource === null;
return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource);
}
function calendarSyncSourceAcceptsCopies(source: CalendarSyncSource): boolean {
return source.source_kind === "caldav" && source.sync_enabled && source.sync_direction === "two_way";
}
function calendarBulkMoveExternalAction(
source: CalendarSyncSource | null,
targetSource: CalendarSyncSource | null)
: CalendarBulkMoveExternalAction | undefined {
if (source && !targetSource) return "detach_keep_remote";
if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote";
return undefined;
}
function calendarBulkMoveOptionLabel(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004";
return "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09";
}
function calendarBulkMoveConsequence(action: CalendarBulkMoveExternalAction | undefined): string {
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b";
if (action === "copy_to_remote") return "i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16";
return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937";
}
function calendarDeleteWarning( function calendarDeleteWarning(
calendar: CalendarCollection, calendar: CalendarCollection,
eventCount: number, eventCount: number,

View File

@@ -0,0 +1,114 @@
import { useEffect, useId, useMemo, useState } from "react";
import {
FormField,
hasScope,
type CalendarPickerProps
} from "@govoplan/core-webui";
import { listCalendars, type CalendarCollection } from "../../api/calendar";
import {
CALENDAR_PICKER_READ_SCOPE,
calendarPickerOptions,
calendarPickerTenantId
} from "./calendarPickerLogic";
const LABEL = "i18n:govoplan-calendar.calendar.adab5090";
const SELECT_CALENDAR = "i18n:govoplan-calendar.select_calendar.f38b5ba2";
const LOADING_CALENDARS = "i18n:govoplan-calendar.loading_calendars.afbb957b";
const CALENDARS_UNAVAILABLE = "i18n:govoplan-calendar.calendars_are_unavailable.f074c862";
const NO_CALENDARS_AVAILABLE = "i18n:govoplan-calendar.no_calendars_are_available.711d2cf3";
const SELECTED_CALENDAR_UNAVAILABLE = "i18n:govoplan-calendar.the_selected_calendar_is_no_longer_available.39f0f1f5";
export default function CalendarPicker({
settings,
auth,
value,
onChange,
id,
name,
label = LABEL,
emptyLabel = SELECT_CALENDAR,
disabled = false,
required = false,
className
}: CalendarPickerProps) {
const generatedId = useId();
const selectId = id ?? `calendar-picker-${generatedId.replace(/:/g, "")}`;
const statusId = `${selectId}-status`;
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const canRead = hasScope(auth, CALENDAR_PICKER_READ_SCOPE);
const tenantId = calendarPickerTenantId(auth);
useEffect(() => {
let cancelled = false;
if (!canRead) {
setCalendars([]);
setLoading(false);
setFailed(true);
return () => {
cancelled = true;
};
}
setLoading(true);
setFailed(false);
listCalendars(settings)
.then((response) => {
if (!cancelled) setCalendars(response.calendars);
})
.catch(() => {
if (!cancelled) {
setCalendars([]);
setFailed(true);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [canRead, settings.accessToken, settings.apiBaseUrl, settings.apiKey, tenantId]);
const options = useMemo(() => calendarPickerOptions(calendars), [calendars]);
const selectedUnavailable = Boolean(value) && !loading && !options.some((calendar) => calendar.id === value);
const status = failed
? CALENDARS_UNAVAILABLE
: !loading && options.length === 0
? NO_CALENDARS_AVAILABLE
: selectedUnavailable
? SELECTED_CALENDAR_UNAVAILABLE
: "";
const placeholder = loading
? LOADING_CALENDARS
: failed
? CALENDARS_UNAVAILABLE
: options.length === 0
? NO_CALENDARS_AVAILABLE
: emptyLabel;
return (
<div className={["calendar-picker", className].filter(Boolean).join(" ")}>
<FormField label={label}>
<select
id={selectId}
name={name}
value={value}
required={required}
disabled={disabled || loading || failed || options.length === 0}
aria-busy={loading || undefined}
aria-describedby={status ? statusId : undefined}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{placeholder}</option>
{selectedUnavailable ? <option value={value} disabled>{SELECTED_CALENDAR_UNAVAILABLE}</option> : null}
{options.map((calendar) => (
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
))}
</select>
</FormField>
{status ? <div id={statusId} className="muted small-note" role={failed ? "alert" : "status"}>{status}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,24 @@
type CalendarPickerAuth = {
tenant: { id: string };
active_tenant?: { id: string };
};
export type CalendarPickerOption = {
id: string;
name: string;
is_default: boolean;
};
export const CALENDAR_PICKER_READ_SCOPE = "calendar:calendar:read";
export function calendarPickerOptions<TCalendar extends CalendarPickerOption>(calendars: TCalendar[]): TCalendar[] {
return [...calendars].sort((left, right) => {
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
const nameDelta = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
return nameDelta !== 0 ? nameDelta : left.id.localeCompare(right.id);
});
}
export function calendarPickerTenantId(auth: CalendarPickerAuth): string {
return (auth.active_tenant ?? auth.tenant).id;
}

View File

@@ -24,6 +24,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type", "i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views", "i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"i18n:govoplan-calendar.calendar.adab5090": "Calendar", "i18n:govoplan-calendar.calendar.adab5090": "Calendar",
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Calendars are unavailable.",
"i18n:govoplan-calendar.calendars.94445018": "Calendars", "i18n:govoplan-calendar.calendars.94445018": "Calendars",
"i18n:govoplan-calendar.cancel.77dfd213": "Cancel", "i18n:govoplan-calendar.cancel.77dfd213": "Cancel",
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED", "i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
@@ -46,6 +47,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.delete.f6fdbe48": "Delete", "i18n:govoplan-calendar.delete.f6fdbe48": "Delete",
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting", "i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
"i18n:govoplan-calendar.description.55f8ebc8": "Description", "i18n:govoplan-calendar.description.55f8ebc8": "Description",
"i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7": "Detach local events and keep remote events",
"i18n:govoplan-calendar.direction.fd8e45ba": "Direction", "i18n:govoplan-calendar.direction.fd8e45ba": "Direction",
"i18n:govoplan-calendar.discover.4827ea22": "Discover", "i18n:govoplan-calendar.discover.4827ea22": "Discover",
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...", "i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
@@ -67,12 +69,15 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
"i18n:govoplan-calendar.events": "events", "i18n:govoplan-calendar.events": "events",
"i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.", "i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.",
"i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937": "Events remain in GovOPlaN; no external calendar is changed.",
"i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint", "i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint",
"i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source", "i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source",
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange", "i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON", "i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri", "i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
"i18n:govoplan-calendar.full_sync.21b89c76": "Full sync", "i18n:govoplan-calendar.full_sync.21b89c76": "Full sync",
"i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16": "GovOPlaN moves the events locally and queues durable CalDAV copies. Delivery failures remain visible and retryable.",
"i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b": "GovOPlaN moves the local event copies to the target calendar and unlinks this source. The remote calendar and its events remain unchanged.",
"i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL", "i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL",
"i18n:govoplan-calendar.graph.9a7405eb": "Graph", "i18n:govoplan-calendar.graph.9a7405eb": "Graph",
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON", "i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
@@ -85,6 +90,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt", "i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync", "i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
"i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...", "i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...",
"i18n:govoplan-calendar.loading_calendars.afbb957b": "Loading calendars...",
"i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...", "i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...",
"i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar", "i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar",
"i18n:govoplan-calendar.local.dc99d54d": "Local", "i18n:govoplan-calendar.local.dc99d54d": "Local",
@@ -96,6 +102,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source", "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
"i18n:govoplan-calendar.mon.24b2a099": "Mon", "i18n:govoplan-calendar.mon.24b2a099": "Mon",
"i18n:govoplan-calendar.month.082bc378": "Month", "i18n:govoplan-calendar.month.082bc378": "Month",
"i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004": "Move and copy events to the external calendar",
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar", "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar",
"i18n:govoplan-calendar.name.709a2322": "Name", "i18n:govoplan-calendar.name.709a2322": "Name",
"i18n:govoplan-calendar.never.80c3052d": "Never", "i18n:govoplan-calendar.never.80c3052d": "Never",
@@ -103,9 +110,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.new.6403f2b7": "New", "i18n:govoplan-calendar.new.6403f2b7": "New",
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync", "i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
"i18n:govoplan-calendar.next.bc981983": "Next", "i18n:govoplan-calendar.next.bc981983": "Next",
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b": "No compatible target calendar is available. Add a local calendar or an active two-way CalDAV calendar.",
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.", "i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
"i18n:govoplan-calendar.no_calendars_are_available.711d2cf3": "No calendars are available.",
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars", "i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
"i18n:govoplan-calendar.no_events.e339ba73": "No events", "i18n:govoplan-calendar.no_events.e339ba73": "No events",
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65": "No local target calendar is available. Add a local calendar to detach these events without changing the remote calendar.",
"i18n:govoplan-calendar.none.6eef6648": "None", "i18n:govoplan-calendar.none.6eef6648": "None",
"i18n:govoplan-calendar.not_configured.811931bb": "Not configured", "i18n:govoplan-calendar.not_configured.811931bb": "Not configured",
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled", "i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
@@ -140,6 +150,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.save.efc007a3": "Save", "i18n:govoplan-calendar.save.efc007a3": "Save",
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...", "i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
"i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence", "i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence",
"i18n:govoplan-calendar.select_calendar.f38b5ba2": "Select calendar",
"i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}", "i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}",
"i18n:govoplan-calendar.source_href.819fa147": "Source href", "i18n:govoplan-calendar.source_href.819fa147": "Source href",
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind", "i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
@@ -158,6 +169,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar", "i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE", "i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
"i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar", "i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar",
"i18n:govoplan-calendar.the_selected_calendar_is_no_longer_available.39f0f1f5": "The selected calendar is no longer available.",
"i18n:govoplan-calendar.thu.3593ccd9": "Thu", "i18n:govoplan-calendar.thu.3593ccd9": "Thu",
"i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone", "i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone",
"i18n:govoplan-calendar.title.768e0c1c": "Title", "i18n:govoplan-calendar.title.768e0c1c": "Title",
@@ -201,6 +213,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type", "i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views", "i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"i18n:govoplan-calendar.calendar.adab5090": "Kalender", "i18n:govoplan-calendar.calendar.adab5090": "Kalender",
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Kalender sind nicht verfügbar.",
"i18n:govoplan-calendar.calendars.94445018": "Calendars", "i18n:govoplan-calendar.calendars.94445018": "Calendars",
"i18n:govoplan-calendar.cancel.77dfd213": "Abbrechen", "i18n:govoplan-calendar.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED", "i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
@@ -223,6 +236,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.delete.f6fdbe48": "Löschen", "i18n:govoplan-calendar.delete.f6fdbe48": "Löschen",
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting", "i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
"i18n:govoplan-calendar.description.55f8ebc8": "Beschreibung", "i18n:govoplan-calendar.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7": "Lokale Termine abtrennen und entfernte Termine beibehalten",
"i18n:govoplan-calendar.direction.fd8e45ba": "Direction", "i18n:govoplan-calendar.direction.fd8e45ba": "Direction",
"i18n:govoplan-calendar.discover.4827ea22": "Discover", "i18n:govoplan-calendar.discover.4827ea22": "Discover",
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...", "i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
@@ -244,12 +258,15 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.", "i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055": "Event count could not be loaded. The backend will still apply the selected delete action.",
"i18n:govoplan-calendar.events": "events", "i18n:govoplan-calendar.events": "events",
"i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.", "i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504": "Events are stored in GovOPlaN and are not synced to an external calendar source.",
"i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937": "Die Termine bleiben in GovOPlaN; kein externer Kalender wird geändert.",
"i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint", "i18n:govoplan-calendar.ews_endpoint.a3273983": "EWS endpoint",
"i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source", "i18n:govoplan-calendar.exchange_web_services_source.53caabf3": "Exchange Web Services source",
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange", "i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON", "i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri", "i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
"i18n:govoplan-calendar.full_sync.21b89c76": "Full sync", "i18n:govoplan-calendar.full_sync.21b89c76": "Full sync",
"i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16": "GovOPlaN verschiebt die Termine lokal und reiht dauerhafte CalDAV-Kopien ein. Zustellfehler bleiben sichtbar und können erneut versucht werden.",
"i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b": "GovOPlaN verschiebt die lokalen Terminkopien in den Zielkalender und trennt diese Quelle. Der entfernte Kalender und seine Termine bleiben unverändert.",
"i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL", "i18n:govoplan-calendar.graph_calendar_url.e59607f3": "Graph calendar URL",
"i18n:govoplan-calendar.graph.9a7405eb": "Graph", "i18n:govoplan-calendar.graph.9a7405eb": "Graph",
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON", "i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
@@ -262,6 +279,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt", "i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync", "i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
"i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...", "i18n:govoplan-calendar.loading_calendar.7eb8f548": "Loading calendar...",
"i18n:govoplan-calendar.loading_calendars.afbb957b": "Kalender werden geladen...",
"i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...", "i18n:govoplan-calendar.loading_event_count.716ad3c2": "Loading event count...",
"i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar", "i18n:govoplan-calendar.local_calendar.ed3f72f8": "Local calendar",
"i18n:govoplan-calendar.local.dc99d54d": "Local", "i18n:govoplan-calendar.local.dc99d54d": "Local",
@@ -273,16 +291,20 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source", "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
"i18n:govoplan-calendar.mon.24b2a099": "Mon", "i18n:govoplan-calendar.mon.24b2a099": "Mon",
"i18n:govoplan-calendar.month.082bc378": "Monat", "i18n:govoplan-calendar.month.082bc378": "Monat",
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Move events to another calendar", "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004": "Termine verschieben und in den externen Kalender kopieren",
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Termine in einen anderen Kalender verschieben",
"i18n:govoplan-calendar.name.709a2322": "Name", "i18n:govoplan-calendar.name.709a2322": "Name",
"i18n:govoplan-calendar.never.80c3052d": "Never", "i18n:govoplan-calendar.never.80c3052d": "Never",
"i18n:govoplan-calendar.new_event.2ef3795c": "New event", "i18n:govoplan-calendar.new_event.2ef3795c": "New event",
"i18n:govoplan-calendar.new.6403f2b7": "New", "i18n:govoplan-calendar.new.6403f2b7": "New",
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync", "i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
"i18n:govoplan-calendar.next.bc981983": "Weiter", "i18n:govoplan-calendar.next.bc981983": "Weiter",
"i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b": "Kein kompatibler Zielkalender ist verfügbar. Fügen Sie einen lokalen Kalender oder einen aktiven CalDAV-Kalender mit bidirektionaler Synchronisierung hinzu.",
"i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.", "i18n:govoplan-calendar.no_calendar_collections_found.6453624a": "No calendar collections found.",
"i18n:govoplan-calendar.no_calendars_are_available.711d2cf3": "Es sind keine Kalender verfügbar.",
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars", "i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
"i18n:govoplan-calendar.no_events.e339ba73": "No events", "i18n:govoplan-calendar.no_events.e339ba73": "No events",
"i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65": "Kein lokaler Zielkalender ist verfügbar. Fügen Sie einen lokalen Kalender hinzu, um diese Termine abzutrennen, ohne den entfernten Kalender zu ändern.",
"i18n:govoplan-calendar.none.6eef6648": "Keine", "i18n:govoplan-calendar.none.6eef6648": "Keine",
"i18n:govoplan-calendar.not_configured.811931bb": "Nicht konfiguriert", "i18n:govoplan-calendar.not_configured.811931bb": "Nicht konfiguriert",
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled", "i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
@@ -317,6 +339,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.save.efc007a3": "Speichern", "i18n:govoplan-calendar.save.efc007a3": "Speichern",
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...", "i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
"i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence", "i18n:govoplan-calendar.sequence.5c8f4e0e": "Sequence",
"i18n:govoplan-calendar.select_calendar.f38b5ba2": "Kalender auswählen",
"i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}", "i18n:govoplan-calendar.show_value.60e2ce8e": "Show {value0}",
"i18n:govoplan-calendar.source_href.819fa147": "Source href", "i18n:govoplan-calendar.source_href.819fa147": "Source href",
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind", "i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
@@ -335,6 +358,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar", "i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE", "i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
"i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar", "i18n:govoplan-calendar.the_selected_calendar.67caa12f": "the selected calendar",
"i18n:govoplan-calendar.the_selected_calendar_is_no_longer_available.39f0f1f5": "Der ausgewählte Kalender ist nicht mehr verfügbar.",
"i18n:govoplan-calendar.thu.3593ccd9": "Thu", "i18n:govoplan-calendar.thu.3593ccd9": "Thu",
"i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone", "i18n:govoplan-calendar.timezone.d1f7dc89": "Timezone",
"i18n:govoplan-calendar.title.768e0c1c": "Title", "i18n:govoplan-calendar.title.768e0c1c": "Title",

View File

@@ -1,2 +1,4 @@
export { calendarModule as default, calendarModule } from "./module"; export { calendarModule as default, calendarModule } from "./module";
export * from "./api/calendar"; export * from "./api/calendar";
export { default as CalendarPicker } from "./features/calendar/CalendarPicker";
export * from "./features/calendar/calendarPickerLogic";

View File

@@ -1,7 +1,8 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import "./styles/calendar.css"; import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage")); const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
@@ -11,6 +12,8 @@ const translations = {
de: generatedTranslations.de de: generatedTranslations.de
}; };
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
export const calendarModule: PlatformWebModule = { export const calendarModule: PlatformWebModule = {
id: "calendar", id: "calendar",
label: "i18n:govoplan-calendar.calendar.adab5090", label: "i18n:govoplan-calendar.calendar.adab5090",
@@ -20,7 +23,10 @@ export const calendarModule: PlatformWebModule = {
translations, translations,
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }], navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
routes: [ routes: [
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }] { path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
uiCapabilities: {
"calendar.picker": calendarPicker
}
}; };

View File

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

View File

@@ -0,0 +1,20 @@
import fs from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const picker = fs.readFileSync(new URL("../src/features/calendar/CalendarPicker.tsx", import.meta.url), "utf8");
const moduleSource = fs.readFileSync(new URL("../src/module.ts", import.meta.url), "utf8");
const coreTypes = fs.readFileSync(new URL("../../../govoplan-core/webui/src/types.ts", import.meta.url), "utf8");
assert(moduleSource.includes('"calendar.picker": calendarPicker'), "Calendar must register the named picker capability");
assert(coreTypes.includes("export type CalendarPickerUiCapability"), "Core must expose the narrow cross-module contract");
assert(picker.includes("listCalendars(settings)"), "Picker must use Calendar's authenticated list API");
assert(picker.includes("hasScope(auth, CALENDAR_PICKER_READ_SCOPE)"), "Picker must avoid loading without read permission");
assert(picker.includes("value={value}"), "Picker must remain controlled by its consumer");
assert(picker.includes("onChange={(event) => onChange(event.target.value)}"), "Picker must return the chosen calendar id");
assert(picker.includes("aria-busy={loading || undefined}"), "Picker must expose loading state accessibly");
assert(picker.includes("aria-describedby={status ? statusId : undefined}"), "Picker must associate availability feedback");
console.log("Calendar picker capability structure checks passed.");

View File

@@ -0,0 +1,37 @@
import {
calendarPickerOptions,
calendarPickerTenantId,
type CalendarPickerOption
} from "../src/features/calendar/calendarPickerLogic";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
function calendar(id: string, name: string, isDefault = false): CalendarPickerOption {
return {
id,
name,
is_default: isDefault
};
}
const input = [
calendar("z", "Zulu"),
calendar("b", "Bravo", true),
calendar("a", "alpha")
];
const ordered = calendarPickerOptions(input);
assert(ordered.map((item) => item.id).join(",") === "b,a,z", "default calendar should lead, followed by names");
assert(input.map((item) => item.id).join(",") === "z,b,a", "picker sorting must not mutate API data");
assert(
calendarPickerTenantId({ tenant: { id: "fallback" }, active_tenant: { id: "active" } }) === "active",
"active tenant should partition picker requests"
);
assert(
calendarPickerTenantId({ tenant: { id: "fallback" } }) === "fallback",
"legacy tenant should remain a supported fallback"
);
console.log("Calendar picker behavior checks passed.");

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".calendar-picker-test-build",
"rootDir": "."
},
"include": [
"tests/calendar-picker.test.ts",
"src/features/calendar/calendarPickerLogic.ts"
]
}