24 Commits
v0.1.8 ... main

Author SHA1 Message Date
77072d057a Declare calendar route View surface 2026-07-28 21:04:55 +02:00
202eb78ae4 Integrate calendar sources with credential envelopes 2026-07-28 19:33:04 +02:00
bf59cd830c test(calendar): commit retirement before engine inspection 2026-07-22 03:21:20 +02:00
041e2159e7 docs: define CalDAV secret retirement 2026-07-21 15:54:47 +02:00
d64de50802 fix(secrets): redact provider failure details 2026-07-21 15:47:50 +02:00
7843fe88e5 fix(installer): delete provider secrets before retirement 2026-07-21 15:46:24 +02:00
39b199d7e6 fix(secrets): fail closed on connector credential deletion 2026-07-21 15:45:43 +02:00
74495e56cb fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
6ca1816c95 refactor(webui): centralize Calendar icon actions 2026-07-21 12:36:13 +02:00
2d7d105ba3 Use explicit outbox source validation 2026-07-21 12:34:34 +02:00
41b9426670 Refactor calendar event update flow 2026-07-21 12:33:43 +02:00
baf624c7a6 Refactor calendar sync source updates 2026-07-21 12:33:34 +02:00
d10570fc0c Refactor synchronized calendar deletion 2026-07-21 12:33:07 +02:00
30f6d79f9e Refactor calendar outbox delivery paths 2026-07-21 12:30:03 +02:00
760a87ab99 Align Calendar runtime version metadata 2026-07-21 12:13:12 +02:00
7098751f7f Remove caller-managed Calendar secret references 2026-07-21 12:12:35 +02:00
1df2a1a730 Harden Calendar connector trust boundaries 2026-07-21 12:11:58 +02:00
a52e35b52b Make calendar outbox invariants explicit 2026-07-21 03:16:45 +02:00
a36a9e8c19 Confine calendar connectors to configured origins 2026-07-21 03:16:45 +02:00
68b165db7b feat(calendar): expose an accessible calendar picker 2026-07-20 17:01:26 +02:00
2ad6e9d60e feat(calendar): expose loss-safe bulk move modes 2026-07-20 17:01:02 +02:00
a6cd64e3f9 feat(calendar): make external mutations durable 2026-07-20 16:58:45 +02:00
371a00aff5 intermittent commit 2026-07-14 13:22:10 +02:00
c071235ad7 Harden CalDAV sync handling 2026-07-11 18:37:51 +02:00
37 changed files with 8107 additions and 698 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

@@ -40,9 +40,21 @@ The first backend implementation stores VEVENT data in two layers:
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username, records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy. credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from Credentials can be supplied transiently for manual sync. Persisted API-managed
environment variables with `env:NAME`, stored through a platform secret provider sources accept only a password or token: Calendar stores an opaque, tenant- and
when one is available, or stored as encrypted calendar-owned credentials. source-bound local reference, backed by the platform secret provider when one is
available or by an encrypted calendar-owned credential otherwise. Caller-selected
environment and external-provider references are rejected. Trusted deployment
code may resolve an `env:NAME` reference only through the separate deployment
configuration helper.
Deleting a source or its calendar immediately scrubs Calendar-owned ciphertext
and provider references and emits non-secret audit evidence. If an external
secret provider cannot confirm deletion, the operation fails closed without
retiring the source or cancelling its queued work; retry is idempotent after a
database rollback. Destructive module retirement first deletes and audits all
active and legacy retained provider secrets and stops before table removal on
provider failure.
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection` Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
when a sync token exists. It imports all VEVENT components in a resource and when a sync token exists. It imports all VEVENT components in a resource and

View File

@@ -24,10 +24,94 @@ 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 unlink immediately scrubs Calendar-owned credential ciphertext and external
provider references and audits the deletion. External provider failure blocks
retirement before queued work is changed; a later retry tolerates a provider
secret already removed by an earlier attempt whose database transaction rolled
back. Provider errors and audit details never contain credential values or
secret references.
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

@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"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

@@ -11,7 +11,7 @@ requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.9",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.8",
"defusedxml>=0.7,<1", "defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.4" __version__ = "0.1.8"

View File

@@ -1,14 +1,20 @@
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 from defusedxml import ElementTree as SafeElementTree
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
class CalDAVError(RuntimeError): class CalDAVError(RuntimeError):
@@ -77,6 +83,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,
@@ -230,8 +248,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"}
@@ -268,7 +301,21 @@ 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 = same_origin_dav_url(
self.collection_url,
href,
label="CalDAV object href",
)
collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate)
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)
@@ -310,15 +357,33 @@ 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) url = validate_outbound_http_url(url, label="CalDAV URL")
with urllib.request.urlopen(request, timeout=timeout) as response: request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
return response.status, dict(response.headers.items()), response.read() url,
data=body,
headers=dict(headers),
method=method,
)
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CalDAV response",
)
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read() response_headers = dict(exc.headers.items())
try:
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
except OutboundHttpError as policy_exc:
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
return exc.code, response_headers, payload
except urllib.error.URLError as exc: except urllib.error.URLError as exc:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc: except (OutboundHttpError, ValueError) as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc raise CalDAVError(f"{method} {url} failed: {exc}") from exc
@@ -360,73 +425,164 @@ def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
root = SafeElementTree.fromstring(payload) root = SafeElementTree.fromstring(payload)
except SafeElementTree.ParseError as exc: except SafeElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
responses: list[_DAVDiscoveryResponse] = [] return [
for response in child_elements(root, "response"): parsed
href = first_child_text(response, "href") for response in child_elements(root, "response")
if not href: if (parsed := _parse_discovery_response(response)) is not None
]
def _parse_discovery_response(response: Any) -> _DAVDiscoveryResponse | None:
href = first_child_text(response, "href")
if not href:
return None
draft = _DAVDiscoveryDraft()
for propstat in child_elements(response, "propstat"):
_apply_discovery_propstat(draft, propstat)
return _DAVDiscoveryResponse(
href=href,
display_name=draft.display_name,
color=draft.color,
ctag=draft.ctag,
sync_token=draft.sync_token,
is_calendar=draft.is_calendar,
principal_hrefs=dedupe_tuple(draft.principal_hrefs),
calendar_home_set_hrefs=dedupe_tuple(draft.calendar_home_set_hrefs),
supported_components=dedupe_tuple(draft.supported_components),
)
def _apply_discovery_propstat(draft: _DAVDiscoveryDraft, propstat: Any) -> None:
if not discovery_propstat_is_success(propstat):
return
prop = first_child(propstat, "prop")
if prop is None:
return
for item in prop:
_apply_discovery_property(draft, item)
def discovery_propstat_is_success(propstat: Any) -> bool:
status = first_child_text(propstat, "status") or ""
return not status or " 200 " in status or status.endswith(" 200") or " 207 " in status
def _apply_discovery_property(draft: _DAVDiscoveryDraft, item: Any) -> None:
name = local_name(item.tag)
text = item.text.strip() if item.text else ""
if name == "displayname" and text:
draft.display_name = text or draft.display_name
elif name == "calendar-color" and text:
draft.color = text or draft.color
elif name == "getctag" and text:
draft.ctag = text or draft.ctag
elif name == "sync-token" and text:
draft.sync_token = text or draft.sync_token
elif name == "resourcetype":
draft.is_calendar = draft.is_calendar or discovery_resource_is_calendar(item)
elif name in {"current-user-principal", "principal-URL"}:
draft.principal_hrefs.extend(nested_href_texts(item))
elif name == "calendar-home-set":
draft.calendar_home_set_hrefs.extend(nested_href_texts(item))
elif name == "supported-calendar-component-set":
draft.supported_components.extend(supported_calendar_component_names(item))
def discovery_resource_is_calendar(item: Any) -> bool:
return any(local_name(child.tag) == "calendar" for child in item)
def supported_calendar_component_names(item: Any) -> list[str]:
names: list[str] = []
for component in item:
if local_name(component.tag) != "comp":
continue continue
display_name = None component_name = (component.attrib.get("name") or "").strip().upper()
color = None if component_name:
ctag = None names.append(component_name)
sync_token = None return names
is_calendar = False
principal_hrefs: list[str] = []
calendar_home_set_hrefs: list[str] = [] def dedupe_tuple(values: list[str]) -> tuple[str, ...]:
supported_components: list[str] = [] return tuple(dict.fromkeys(values))
for propstat in child_elements(response, "propstat"):
status = first_child_text(propstat, "status") or ""
if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status:
continue
prop = first_child(propstat, "prop")
if prop is None:
continue
for item in prop:
name = local_name(item.tag)
if name == "displayname" and item.text:
display_name = item.text.strip() or display_name
elif name == "calendar-color" and item.text:
color = item.text.strip() or color
elif name == "getctag" and item.text:
ctag = item.text.strip() or ctag
elif name == "sync-token" and item.text:
sync_token = item.text.strip() or sync_token
elif name == "resourcetype":
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item)
elif name in {"current-user-principal", "principal-URL"}:
principal_hrefs.extend(nested_href_texts(item))
elif name == "calendar-home-set":
calendar_home_set_hrefs.extend(nested_href_texts(item))
elif name == "supported-calendar-component-set":
for component in item:
if local_name(component.tag) == "comp":
component_name = (component.attrib.get("name") or "").strip().upper()
if component_name:
supported_components.append(component_name)
responses.append(
_DAVDiscoveryResponse(
href=href,
display_name=display_name,
color=color,
ctag=ctag,
sync_token=sync_token,
is_calendar=is_calendar,
principal_hrefs=tuple(dict.fromkeys(principal_hrefs)),
calendar_home_set_hrefs=tuple(dict.fromkeys(calendar_home_set_hrefs)),
supported_components=tuple(dict.fromkeys(supported_components)),
)
)
return responses
def ensure_collection_url(value: str) -> str: def ensure_collection_url(value: str) -> str:
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.strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
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")
if parsed.query or parsed.fragment:
raise CalDAVError("CalDAV URL must not include a query or fragment")
_url_origin(parsed)
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:
return urllib.parse.urljoin(ensure_collection_url(base_url), href) return same_origin_dav_url(base_url, href, label="CalDAV discovery href")
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
base = ensure_collection_url(base_url)
candidate = validate_http_url(urllib.parse.urljoin(base, href))
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
raise CalDAVError(f"{label} must use the configured collection origin")
return candidate
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise CalDAVError("CalDAV URL has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
except (CalDAVError, OutboundHttpError):
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None
method = req.get_method()
data = req.data
if code == 303 and method != "HEAD":
method, data = "GET", None
elif code in {301, 302} and method == "POST":
method, data = "GET", None
forwarded_headers = {
key: value
for key, value in req.header_items()
if key.casefold() not in {"host", "content-length"}
}
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
candidate,
data=data,
headers=forwarded_headers,
origin_req_host=req.origin_req_host,
unverifiable=True,
method=method,
)
def strip_weak_etag(value: str | None) -> str | None: def strip_weak_etag(value: str | None) -> str | None:
@@ -446,25 +602,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

@@ -1,14 +1,68 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from pathlib import Path from pathlib import Path
from sqlalchemy import inspect
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,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
_calendar_table_retirement_provider = drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
)
def _calendar_retirement_provider(session: object | None, module_id: str):
plan = _calendar_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Calendar credential retirement.")
if inspect(execute_session.get_bind()).has_table(calendar_models.CalendarSyncCredential.__tablename__):
from govoplan_calendar.backend.service import delete_calendar_credentials_for_retirement
delete_calendar_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Calendar-owned credentials are deleted immediately before tables are dropped; retirement fails if an external secret provider is unavailable.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2) module_id, resource, action = scope.split(":", 2)
return PermissionDefinition( return PermissionDefinition(
@@ -28,11 +82,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 +98,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 +113,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,20 +151,58 @@ 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.8", 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",
package_name="@govoplan/calendar-webui", package_name="@govoplan/calendar-webui",
routes=(
FrontendRoute(
path="/calendar",
component="CalendarPage",
required_any=("calendar:event:read",),
order=55,
),
),
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),),
), ),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
@@ -106,19 +210,14 @@ manifest = ModuleManifest(
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True, retirement_supported=True,
retirement_provider=drop_table_retirement_provider( retirement_provider=_calendar_retirement_provider,
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
),
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.", retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
), ),
uninstall_guard_providers=( uninstall_guard_providers=(
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,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,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
@@ -27,6 +28,8 @@ from govoplan_calendar.backend.schemas import (
CalendarCollectionListResponse, CalendarCollectionListResponse,
CalendarCollectionResponse, CalendarCollectionResponse,
CalendarCollectionUpdateRequest, CalendarCollectionUpdateRequest,
CalendarCredentialEnvelopeListResponse,
CalendarCredentialEnvelopeResponse,
CalendarEventCreateRequest, CalendarEventCreateRequest,
CalendarEventDeltaResponse, CalendarEventDeltaResponse,
CalendarEventListResponse, CalendarEventListResponse,
@@ -35,6 +38,9 @@ from govoplan_calendar.backend.schemas import (
CalendarFreeBusyRequest, CalendarFreeBusyRequest,
CalendarFreeBusyResponse, CalendarFreeBusyResponse,
CalendarIcsImportRequest, CalendarIcsImportRequest,
CalendarOutboxDispatchResponse,
CalendarOutboxOperationListResponse,
CalendarOutboxOperationResponse,
CalendarSyncDueSyncItemResponse, CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse, CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest, CalendarSyncSourceCreateRequest,
@@ -44,11 +50,20 @@ 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,
CALENDAR_MODULE_ID, CALENDAR_MODULE_ID,
CalendarError, CalendarError,
available_calendar_credentials,
caldav_source_response, caldav_source_response,
caldav_sync_response, caldav_sync_response,
calendar_response, calendar_response,
@@ -62,7 +77,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,
@@ -118,6 +132,25 @@ def _parse_payload_datetime(value) -> datetime | None:
return None return None
@router.get("/credentials", response_model=CalendarCredentialEnvelopeListResponse)
def api_list_calendar_credentials(
source_id: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarCredentialEnvelopeListResponse(
credentials=[
CalendarCredentialEnvelopeResponse.model_validate(item)
for item in available_calendar_credentials(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
)
]
)
def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool: def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool:
event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at")) event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at"))
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
@@ -220,13 +253,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])
@@ -275,7 +320,14 @@ def api_delete_calendar(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload) delete_calendar(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
@@ -321,7 +373,14 @@ def api_update_sync_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) source = update_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
session.refresh(source) session.refresh(source)
return _sync_source_response(source) return _sync_source_response(source)
@@ -338,12 +397,23 @@ def api_delete_sync_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id) delete_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
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)
@@ -451,7 +521,14 @@ def api_update_caldav_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload) source = update_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
session.refresh(source) session.refresh(source)
return _caldav_source_response(source) return _caldav_source_response(source)
@@ -468,12 +545,23 @@ def api_delete_caldav_source(
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
try: try:
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id) delete_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit() session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc: except CalendarError as exc:
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 +618,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 +799,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 +830,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 +847,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 +912,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):
@@ -130,6 +136,7 @@ class CalendarSyncSourceResponse(BaseModel):
auth_type: str auth_type: str
username: str | None = None username: str | None = None
credential_ref: str | None = None credential_ref: str | None = None
credential_envelope_id: str | None = None
has_credential: bool = False has_credential: bool = False
sync_enabled: bool sync_enabled: bool
sync_interval_seconds: int sync_interval_seconds: int
@@ -159,6 +166,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list) sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class CalendarCredentialEnvelopeListResponse(BaseModel):
credentials: list[CalendarCredentialEnvelopeResponse] = Field(default_factory=list)
class CalendarCalDavDiscoveryRequest(BaseModel): class CalendarCalDavDiscoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -238,6 +265,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,32 +2,47 @@ 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, inspect
from sqlalchemy.orm import sessionmaker 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.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest from govoplan_calendar.backend.manifest import manifest
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceUpdateRequest,
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import ( from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX, CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError, CalendarError,
caldav_client_for_source, caldav_client_for_source,
caldav_source_response,
create_calendar, create_calendar,
create_caldav_source, create_caldav_source,
create_event, create_event,
delete_caldav_source,
delete_calendar, delete_calendar,
delete_event, delete_event,
discover_caldav_calendars,
list_caldav_sources, list_caldav_sources,
list_freebusy, list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source, sync_caldav_source,
sync_due_caldav_sources, sync_due_caldav_sources,
update_caldav_source,
update_event, update_event,
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.security.credential_envelopes import create_credential_envelope
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
@@ -84,6 +99,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 +239,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):
@@ -228,12 +261,21 @@ class CalDAVSyncTests(unittest.TestCase):
create_scope_tables(self.engine) create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None: def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine) Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session(self):
session = self.Session()
self.sessions.append(session)
return session
def test_source_creation_encrypts_credential_and_resolves_client_secret(self) -> None: def test_source_creation_encrypts_credential_and_resolves_client_secret(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"))
@@ -259,8 +301,261 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada") self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret") self.assertEqual(client.password, "secret")
def test_source_can_resolve_reusable_core_credential(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"),
)
credential = create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared DAV login",
credential_kind="username_password",
public_data={"username": "ada"},
secret_data={"password": "secret"},
allowed_modules=["calendar"],
inherit_to_lower_scopes=True,
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
credential_ref=f"credential-envelope:{credential.id}",
),
)
session.commit()
client = caldav_client_for_source(session, source)
response = caldav_source_response(source)
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
self.assertEqual(response["credential_envelope_id"], credential.id)
self.assertEqual(session.query(CalendarSyncCredential).count(), 0)
def test_api_source_and_discovery_reject_caller_selected_credential_references(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"),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
discover_caldav_calendars(
session,
tenant_id="tenant-1",
payload=CalendarCalDavDiscoveryRequest(
url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="vault:another-tenant",
),
)
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",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(
credential_ref="env:MASTER_KEY_B64",
),
)
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
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"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
self.assertNotEqual(source.credential_ref, provider_ref)
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
self.assertIsNone(caldav_source_response(source)["credential_ref"])
self.assertTrue(caldav_source_response(source)["has_credential"])
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="another-tenant",
source_id=source.id,
credential_ref=source.credential_ref,
)
)
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id="another-source",
credential_ref=source.credential_ref,
)
)
other_calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Other remote"),
)
other_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=other_calendar.id,
collection_url="https://dav.example.test/other-cal",
),
)
other_source.auth_type = "basic"
other_source.username = "mallory"
other_source.credential_ref = source.credential_ref
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, other_source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
self.assertEqual(provider.deleted, [])
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref])
self.assertNotIn(provider_ref, provider.values)
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertNotIn("provider_ref", credential.metadata_ or {})
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["action"], "calendar.sync_credential_deleted")
self.assertEqual(audit_call["object_id"], credential.id)
self.assertEqual(audit_call["details"]["storage_backend"], "external_secret_provider")
self.assertEqual(audit_call["details"]["deletion_reason"], "calendar_deleted")
self.assertNotIn("credential_ref", audit_call["details"])
self.assertNotIn("provider_ref", audit_call["details"])
self.assertNotIn(provider_ref, repr(audit_call["details"]))
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
class Provider:
def __init__(self) -> None:
self.read: list[str] = []
self.deleted: list[str] = []
def read_secret(self, secret_ref: str) -> str | None:
self.read.append(secret_ref)
return "other-tenant-secret"
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
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.auth_type = "bearer"
source.credential_ref = "vault:another-tenant"
session.commit()
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.read, [])
self.assertEqual(provider.deleted, [])
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
self.assertEqual(
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
"trusted-token",
)
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
resolve_trusted_deployment_caldav_credential_ref("vault:token")
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None: def test_delete_calendar_retires_caldav_source_and_credential(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"))
source = create_caldav_source( source = create_caldav_source(
@@ -282,11 +577,319 @@ class CalDAVSyncTests(unittest.TestCase):
credential = session.query(CalendarSyncCredential).one() credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(source.deleted_at) self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at) self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(credential.secret_encrypted)
self.assertEqual(credential.metadata_, {"source_id": source.id})
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), []) self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_external_credential_deletion_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
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"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/provider-down",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"secret provider is unavailable",
):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIn(provider_ref, provider.values)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider), patch.object(
provider,
"delete_secret",
side_effect=RuntimeError(f"provider failed for {provider_ref}"),
):
with self.assertRaises(CalendarError) as raised:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, str(raised.exception))
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertIsNone(credential.deleted_at)
def test_disabling_auth_immediately_scrubs_and_audits_database_credential(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/disable-auth",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(credential.secret_encrypted)
with patch("govoplan_calendar.backend.service.audit_event") as audit:
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(auth_type="none"),
)
session.commit()
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertIsNotNone(credential.deleted_at)
audit.assert_called_once()
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"authentication_disabled",
)
self.assertNotIn("do-not-log-this", repr(audit.call_args))
def test_external_credential_replacement_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
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"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/replacement-provider-down",
auth_type="bearer",
bearer_token="old-secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"cannot be replaced.*provider is unavailable",
):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(bearer_token="new-secret"),
)
session.rollback()
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertEqual(provider.values[provider_ref], "old-secret")
def test_external_credential_delete_is_retryable_after_database_rollback(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deletes: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deletes.append(secret_ref)
if secret_ref not in self.values:
raise KeyError("already deleted")
self.values.pop(secret_ref, None)
provider = Provider()
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"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/retry-delete",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.audit_event"):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, provider.values)
self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(source.credential_ref)
session.rollback()
self.assertIsNone(source.deleted_at)
session.refresh(credential)
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIsNone(resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id=source.id,
credential_ref=source.credential_ref,
))
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.commit()
self.assertEqual(provider.deletes, [provider_ref, provider_ref])
self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at)
self.assertEqual(audit.call_count, 1)
def test_destructive_module_retirement_deletes_external_credentials_before_tables(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
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"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/module-retirement",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(session, "calendar")
assert plan.destroy_data_executor is not None
with patch("govoplan_calendar.backend.service.audit_event") as audit:
plan.destroy_data_executor(session, "calendar")
# Retirement intentionally participates in the caller's database
# transaction. Commit before inspecting through a new Engine
# connection; otherwise SQLite rolls back the uncommitted DDL when
# the temporary inspector connection is returned to the pool.
session.commit()
self.assertEqual(provider.deleted, [provider_ref])
self.assertEqual(provider.values, {})
self.assertEqual(audit.call_count, 1)
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"module_data_retired",
)
self.assertFalse(inspect(self.engine).has_table("calendar_sync_credentials"))
self.assertFalse(inspect(self.engine).has_table("calendar_sync_sources"))
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None: def test_create_source_retires_orphaned_source_for_deleted_calendar(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"))
old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old")) old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old"))
old_source = create_caldav_source( old_source = create_caldav_source(
@@ -312,7 +915,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id]) self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id])
def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None: def test_create_source_reports_active_duplicate_as_calendar_error(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"))
first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First")) first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First"))
second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second")) second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second"))
@@ -333,7 +936,7 @@ class CalDAVSyncTests(unittest.TestCase):
) )
def test_due_sync_runs_due_sources_and_reschedules(self) -> None: def test_due_sync_runs_due_sources_and_reschedules(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"))
source = create_caldav_source( source = create_caldav_source(
@@ -359,40 +962,71 @@ 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 = 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.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", user_id=None,
user_id=None, payload=CalendarEventCreateRequest(
payload=CalendarEventCreateRequest( calendar_id=calendar.id,
calendar_id=calendar.id, uid="event-1@example.test",
uid="event-1@example.test", summary="Planning",
summary="Planning", start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc), end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc), ),
), )
) update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
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:
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"))
@@ -400,13 +1034,25 @@ 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()
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"))
@@ -417,9 +1063,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, [])
@@ -429,7 +1075,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(override.deleted_at) self.assertIsNotNone(override.deleted_at)
def test_freebusy_expands_recurring_events_and_skips_transparent_items(self) -> None: def test_freebusy_expands_recurring_events_and_skips_transparent_items(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="Local")) calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Local"))
create_event( create_event(
@@ -471,7 +1117,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc)) self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc))
def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None: def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None:
session = self.Session() session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant") tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant) session.add(tenant)
calendar = create_calendar( calendar = create_calendar(
@@ -512,7 +1158,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(calendar.metadata_["source_kind"], "caldav") self.assertEqual(calendar.metadata_["source_kind"], "caldav")
def test_sync_token_deletion_soft_deletes_remote_resource_events(self) -> None: def test_sync_token_deletion_soft_deletes_remote_resource_events(self) -> None:
session = self.Session() session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant") tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant) session.add(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"))
@@ -561,7 +1207,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(event.deleted_at) self.assertIsNotNone(event.deleted_at)
def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None: def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None:
session = self.Session() session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant") tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant) session.add(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"))

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from govoplan_calendar.backend.caldav import (
CalDAVClient,
CalDAVError,
absolute_dav_url,
urllib_transport,
)
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CalDAVUrlSecurityTests(unittest.TestCase):
def test_transport_revalidates_dns_at_connection_time(self) -> None:
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
CalDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/calendars/ada/"
self.assertEqual(
absolute_dav_url(base_url, "/principals/users/ada/"),
"https://dav.example.test/principals/users/ada/",
)
with self.assertRaisesRegex(CalDAVError, "configured collection origin"):
absolute_dav_url(base_url, "https://evil.example.test/steal/")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
def test_object_href_rejects_userinfo_query_and_fragment(self) -> None:
client = CalDAVClient(collection_url="https://dav.example.test/calendars/ada")
with self.assertRaisesRegex(CalDAVError, "embedded credentials"):
client.object_url("https://user:secret@dav.example.test/calendars/ada/event.ics")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics?download=1")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics#fragment")
self.assertEqual(
client.object_url("/calendars/ada/event.ics"),
"https://dav.example.test/calendars/ada/event.ics",
)
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen.ics")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as redirect_url:
status, _headers, _body = urllib_transport(
"GET",
f"{redirect_url}/event.ics",
{"Authorization": "Bearer top-secret"},
None,
2,
)
self.assertEqual(status, 302)
self.assertEqual(forwarded_authorization, [])
def test_transport_preserves_same_origin_redirects(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/event.ics":
self.send_response(302)
self.send_header("Location", "/redirected.ics")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"calendar")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = urllib_transport(
"GET",
f"{source_url}/event.ics",
{"Authorization": "Bearer expected"},
None,
2,
)
self.assertEqual(status, 200)
self.assertEqual(body, b"calendar")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
if __name__ == "__main__":
unittest.main()

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

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from govoplan_calendar.backend.service import CalendarError, http_request
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CalendarHttpSecurityTests(unittest.TestCase):
def test_cross_origin_redirect_does_not_forward_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
with self.assertRaisesRegex(CalendarError, "HTTP 302"):
http_request(
f"{source_url}/feed",
headers={"Authorization": "Bearer top-secret"},
timeout=2,
)
self.assertEqual(forwarded_authorization, [])
def test_same_origin_redirect_remains_supported(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/feed":
self.send_response(302)
self.send_header("Location", "/calendar.ics")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"calendar")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = http_request(
f"{source_url}/feed",
headers={"Authorization": "Bearer expected"},
timeout=2,
)
self.assertEqual(status, 200)
self.assertEqual(body, "calendar")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
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
@@ -23,12 +23,17 @@ class CalendarSyncSourceTests(unittest.TestCase):
create_scope_tables(self.engine) create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None: def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine) Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session_with_calendar(self): def session_with_calendar(self):
session = self.Session() session = self.Session()
self.sessions.append(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"))
session.flush() session.flush()
@@ -78,6 +83,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(
@@ -117,6 +171,74 @@ END:VCALENDAR
self.assertEqual(event.summary, "Graph item") self.assertEqual(event.summary, "Graph item")
self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"]) self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"])
def test_graph_sync_rejects_cross_origin_continuation_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.nextLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_graph_sync_rejects_cross_origin_delta_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.deltaLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_ews_sync_imports_calendar_view_items(self) -> None: def test_ews_sync_imports_calendar_view_items(self) -> None:
session, calendar = self.session_with_calendar() session, calendar = self.session_with_calendar()
source = create_sync_source( source = create_sync_source(
@@ -168,6 +290,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

@@ -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.8", "@govoplan/core-webui": "^0.1.9",
"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

@@ -78,7 +78,7 @@ export type CalendarSyncSource = {
display_name?: string | null; display_name?: string | null;
auth_type: CalendarCalDavAuthType; auth_type: CalendarCalDavAuthType;
username?: string | null; username?: string | null;
credential_ref?: string | null; credential_envelope_id?: string | null;
has_credential: boolean; has_credential: boolean;
sync_enabled: boolean; sync_enabled: boolean;
sync_interval_seconds: number; sync_interval_seconds: number;
@@ -143,6 +143,26 @@ export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>; export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>; export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
export type CalendarCredentialEnvelope = {
id: string;
scope_type: string;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data: Record<string, unknown>;
secret_keys: string[];
secret_configured: boolean;
allowed_modules: string[];
inherit_to_lower_scopes: boolean;
is_active: boolean;
revision: string;
};
export type CalendarCredentialEnvelopeListResponse = {
credentials: CalendarCredentialEnvelope[];
};
export type CalendarSyncSourceSyncPayload = { export type CalendarSyncSourceSyncPayload = {
password?: string | null; password?: string | null;
bearer_token?: string | null; bearer_token?: string | null;
@@ -182,10 +202,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 = {
@@ -253,6 +275,13 @@ export function listCalDavSources(settings: ApiSettings, params: { calendar_id?:
return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`); return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`);
} }
export function listCalendarCredentials(settings: ApiSettings, sourceId?: string | null): Promise<CalendarCredentialEnvelopeListResponse> {
const search = new URLSearchParams();
if (sourceId) search.set("source_id", sourceId);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarCredentialEnvelopeListResponse>(settings, `/api/v1/calendar/credentials${suffix}`);
}
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> { export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) }); return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
} }

View File

@@ -11,13 +11,16 @@ import {
"react"; "react";
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { import {
AdminIconButton,
Button, Button,
ColorPickerField, ColorPickerField,
DateField, DateField,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
LoadingFrame, LoadingFrame,
PasswordField,
SegmentedControl, SegmentedControl,
TableActionGroup,
TimeField, TimeField,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
@@ -35,6 +38,7 @@ import {
discoverCalDavCalendars, discoverCalDavCalendars,
listCalendarEvents, listCalendarEvents,
listCalendarEventsDelta, listCalendarEventsDelta,
listCalendarCredentials,
listCalendars, listCalendars,
listSyncSources, listSyncSources,
syncSyncSource, syncSyncSource,
@@ -46,8 +50,10 @@ import {
type CalendarCalDavDiscoveryCandidate, type CalendarCalDavDiscoveryCandidate,
type CalendarCalDavDiscoveryPayload, type CalendarCalDavDiscoveryPayload,
type CalendarCalDavSyncDirection, type CalendarCalDavSyncDirection,
type CalendarBulkMoveExternalAction,
type CalendarCollection, type CalendarCollection,
type CalendarCollectionDeletePayload, type CalendarCollectionDeletePayload,
type CalendarCredentialEnvelope,
type CalendarDeleteEventAction, type CalendarDeleteEventAction,
type CalendarEvent, type CalendarEvent,
type CalendarEventCreatePayload, type CalendarEventCreatePayload,
@@ -83,7 +89,7 @@ type CalendarCalDavFormPayload = {
display_name: string; display_name: string;
auth_type: CalendarCalDavAuthType; auth_type: CalendarCalDavAuthType;
username: string; username: string;
credential_ref: string; credential_envelope_id: string;
password: string; password: string;
bearer_token: string; bearer_token: string;
sync_enabled: boolean; sync_enabled: boolean;
@@ -234,8 +240,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);
@@ -681,31 +689,24 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}> <button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
{calendar.name} {calendar.name}
</button> </button>
<div className="calendar-list-actions"> <TableActionGroup
{source && canSyncCalendars && className="calendar-list-actions"
<Button actions={[
type="button" source && canSyncCalendars && {
className={syncing ? "calendar-row-icon-button is-syncing" : "calendar-row-icon-button"} id: "sync",
title={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })} label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }),
aria-label={syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name })} icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />,
onClick={() => void handleSyncSource(source)} onClick: () => void handleSyncSource(source),
disabled={saving || syncing}> disabled: saving || syncing
},
<RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} /> (canManageCalendars || canDeleteCalendars) && {
</Button> id: "edit",
} label: i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name }),
{(canManageCalendars || canDeleteCalendars) && icon: <Pencil size={15} />,
<Button onClick: () => openCalendarEditor(calendar)
type="button" }
className="calendar-row-icon-button" ]}
title={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })} />
aria-label={i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name })}
onClick={() => openCalendarEditor(calendar)}>
<Pencil size={15} />
</Button>
}
</div>
</div>); </div>);
})} })}
@@ -761,13 +762,19 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<div className="calendar-toolbar-center"> <div className="calendar-toolbar-center">
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2"> <div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.previous.50f94286" aria-label="i18n:govoplan-calendar.previous.50f94286" onClick={() => moveFocus(-1)} disabled={mode === "continuous"}> <AdminIconButton
<ChevronLeft size={18} /> label="i18n:govoplan-calendar.previous.50f94286"
</Button> icon={<ChevronLeft size={18} />}
onClick={() => moveFocus(-1)}
disabled={mode === "continuous"}
/>
<Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button> <Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button>
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.next.bc981983" aria-label="i18n:govoplan-calendar.next.bc981983" onClick={() => moveFocus(1)} disabled={mode === "continuous"}> <AdminIconButton
<ChevronRight size={18} /> label="i18n:govoplan-calendar.next.bc981983"
</Button> icon={<ChevronRight size={18} />}
onClick={() => moveFocus(1)}
disabled={mode === "continuous"}
/>
<div className="calendar-range-label"> <div className="calendar-range-label">
<CalendarDays size={18} aria-hidden="true" /> <CalendarDays size={18} aria-hidden="true" />
<strong>{heading}</strong> <strong>{heading}</strong>
@@ -776,9 +783,11 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
</div> </div>
<div className="calendar-toolbar-right"> <div className="calendar-toolbar-right">
<Button type="button" className="calendar-icon-button" title="i18n:govoplan-calendar.refresh.56e3badc" aria-label="i18n:govoplan-calendar.refresh.56e3badc" onClick={() => void loadEvents()}> <AdminIconButton
<RefreshCw size={18} /> label="i18n:govoplan-calendar.refresh.56e3badc"
</Button> icon={<RefreshCw size={18} />}
onClick={() => void loadEvents()}
/>
{canWrite && {canWrite &&
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}> <Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}>
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7 <Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
@@ -877,6 +886,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
<CalendarCollectionDialog <CalendarCollectionDialog
key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"} key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"}
state={calendarDialog} state={calendarDialog}
settings={settings}
source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null} source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null}
saving={saving} saving={saving}
syncingSourceId={syncingSourceId} syncingSourceId={syncingSourceId}
@@ -895,6 +905,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} />
@@ -1302,6 +1313,7 @@ function CalendarEventChip({
function CalendarCollectionDialog({ function CalendarCollectionDialog({
state, state,
settings,
source, source,
saving, saving,
syncingSourceId, syncingSourceId,
@@ -1328,7 +1340,7 @@ function CalendarCollectionDialog({
}: {state: CalendarCollectionDialogState;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { }: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
const calendar = state.kind === "edit" ? state.calendar : null; const calendar = state.kind === "edit" ? state.calendar : null;
const isEdit = Boolean(calendar); const isEdit = Boolean(calendar);
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local"); const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? source.source_kind : "local");
@@ -1339,7 +1351,9 @@ function CalendarCollectionDialog({
const [displayName, setDisplayName] = useState(source?.display_name ?? ""); const [displayName, setDisplayName] = useState(source?.display_name ?? "");
const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic"); const [authType, setAuthType] = useState<CalendarCalDavAuthType>(source?.auth_type ?? "basic");
const [username, setUsername] = useState(source?.username ?? ""); const [username, setUsername] = useState(source?.username ?? "");
const [credentialRef, setCredentialRef] = useState(source?.credential_ref ?? ""); const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? "");
const [availableCredentials, setAvailableCredentials] = useState<CalendarCredentialEnvelope[]>([]);
const [credentialsError, setCredentialsError] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [bearerToken, setBearerToken] = useState(""); const [bearerToken, setBearerToken] = useState("");
const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true); const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true);
@@ -1357,8 +1371,8 @@ function CalendarCollectionDialog({
const effectiveCollectionUrl = (collectionUrl || davUrl).trim(); const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType; const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType;
const needsSourceSecret = sourceMode !== "local" && ( const needsSourceSecret = sourceMode !== "local" && (
effectiveAuthType === "basic" && !source?.has_credential && !credentialRef.trim() && !password.trim() || effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() ||
effectiveAuthType === "bearer" && !source?.has_credential && !credentialRef.trim() && !bearerToken.trim()); effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim());
const sourceDetailsInvalid = sourceMode !== "local" && ( const sourceDetailsInvalid = sourceMode !== "local" && (
!isExistingSyncSource && !canEditSource || !isExistingSyncSource && !canEditSource ||
@@ -1381,7 +1395,7 @@ function CalendarCollectionDialog({
displayName, displayName,
authType, authType,
username, username,
credentialRef, credentialEnvelopeId,
password, password,
bearerToken, bearerToken,
syncEnabled, syncEnabled,
@@ -1398,6 +1412,28 @@ function CalendarCollectionDialog({
onDiscard: onCancel onDiscard: onCancel
}); });
useEffect(() => {
if (sourceMode === "local" || !canManageSources) {
setAvailableCredentials([]);
setCredentialsError("");
return;
}
let active = true;
listCalendarCredentials(settings, source?.id)
.then((response) => {
if (active) setAvailableCredentials(response.credentials);
})
.catch((err) => {
if (active) {
setAvailableCredentials([]);
setCredentialsError(errorText(err));
}
});
return () => {
active = false;
};
}, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]);
function currentPayload(): CalendarCollectionFormPayload { function currentPayload(): CalendarCollectionFormPayload {
return { return {
sourceMode, sourceMode,
@@ -1409,7 +1445,7 @@ function CalendarCollectionDialog({
display_name: displayName, display_name: displayName,
auth_type: effectiveAuthType, auth_type: effectiveAuthType,
username, username,
credential_ref: credentialRef, credential_envelope_id: credentialEnvelopeId,
password, password,
bearer_token: bearerToken, bearer_token: bearerToken,
sync_enabled: syncEnabled, sync_enabled: syncEnabled,
@@ -1490,11 +1526,11 @@ function CalendarCollectionDialog({
url, url,
source_id: source?.id ?? null, source_id: source?.id ?? null,
auth_type: authType, auth_type: authType,
username: authType === "basic" ? username.trim() || null : null, username: authType === "basic" ? username.trim() || null : null
credential_ref: credentialRef.trim() || null
}; };
if (authType === "basic" && password.trim()) payload.password = password; if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId);
if (authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken; if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password;
if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken;
const response = await onDiscover(payload); const response = await onDiscover(payload);
setDiscoveredCalendars(response.calendars); setDiscoveredCalendars(response.calendars);
if (response.calendars.length > 0) { if (response.calendars.length > 0) {
@@ -1602,22 +1638,49 @@ function CalendarCollectionDialog({
<option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option> <option value="bearer">i18n:govoplan-calendar.bearer_token.ffa64bcf</option>
</select> </select>
</label> </label>
{effectiveAuthType !== "none" &&
<label>
<span>Reusable credential</span>
<select
value={credentialEnvelopeId}
disabled={saving || !canEditSource}
onChange={(event) => {
const credentialId = event.target.value;
setCredentialEnvelopeId(credentialId);
setPassword("");
setBearerToken("");
const credential = availableCredentials.find((item) => item.id === credentialId);
const credentialUsername = credential?.public_data?.username;
if (credentialUsername) setUsername(String(credentialUsername));
}}>
<option value="">{source?.has_credential && !source.credential_envelope_id ? "Keep source-specific credential" : "Enter credentials below"}</option>
{availableCredentials.map((credential) =>
<option key={credential.id} value={credential.id}>
{credential.name}{credential.public_data?.username ? ` (${String(credential.public_data.username)})` : ""}
</option>
)}
</select>
</label>
}
{credentialsError && <p className="calendar-form-error calendar-dialog-wide">{credentialsError}</p>}
{effectiveAuthType === "basic" && {effectiveAuthType === "basic" &&
<> <>
<label> <label>
<span>i18n:govoplan-calendar.username.84c29015</span> <span>i18n:govoplan-calendar.username.84c29015</span>
<input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource} /> <input value={username} onChange={(item) => setUsername(item.target.value)} required={sourceMode !== "local" && effectiveAuthType === "basic"} maxLength={255} disabled={saving || !canEditSource || Boolean(credentialEnvelopeId)} />
</label> </label>
{!credentialEnvelopeId &&
<label> <label>
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span> <span>{source?.has_credential ? "i18n:govoplan-calendar.replace_password.3f912c9c" : "i18n:govoplan-calendar.password.8be3c943"}</span>
<input type="password" value={password} onChange={(item) => setPassword(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" /> <PasswordField value={password} onValueChange={setPassword} disabled={saving || !canEditSource} autoComplete="new-password" />
</label> </label>
}
</> </>
} }
{effectiveAuthType === "bearer" && {effectiveAuthType === "bearer" && !credentialEnvelopeId &&
<label> <label>
<span>{source?.has_credential || credentialRef.trim() ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span> <span>{source?.has_credential ? "i18n:govoplan-calendar.replace_token.bbeee6a9" : "i18n:govoplan-calendar.bearer_token.ffa64bcf"}</span>
<input type="password" value={bearerToken} onChange={(item) => setBearerToken(item.target.value)} disabled={saving || !canEditSource} autoComplete="new-password" /> <PasswordField value={bearerToken} onValueChange={setBearerToken} disabled={saving || !canEditSource} autoComplete="new-password" />
</label> </label>
} }
<div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}> <div className={sourceMode === "caldav" ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
@@ -1649,10 +1712,6 @@ function CalendarCollectionDialog({
<span>i18n:govoplan-calendar.display_name.c7874aaa</span> <span>i18n:govoplan-calendar.display_name.c7874aaa</span>
<input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} /> <input value={displayName} onChange={(item) => setDisplayName(item.target.value)} maxLength={255} disabled={saving || !canEditMutableSourceSettings} />
</label> </label>
<label>
<span>i18n:govoplan-calendar.credential_reference.a7e92de5</span>
<input value={credentialRef} onChange={(item) => setCredentialRef(item.target.value)} placeholder="env:CALDAV_PASSWORD" maxLength={255} disabled={saving || !canEditSource} />
</label>
</div> </div>
<div className="calendar-sync-settings"> <div className="calendar-sync-settings">
<ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} /> <ToggleSwitch label="i18n:govoplan-calendar.automatic_sync.084644b2" checked={syncEnabled} disabled={saving || !canEditMutableSourceSettings} onChange={setSyncEnabled} />
@@ -1682,7 +1741,7 @@ function CalendarCollectionDialog({
<div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div> <div><dt>i18n:govoplan-calendar.last_attempt.82aee111</dt><dd>{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div> <div><dt>i18n:govoplan-calendar.last_sync.ef0ef267</dt><dd>{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}</dd></div>
<div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div> <div><dt>i18n:govoplan-calendar.next_sync.88c7af72</dt><dd>{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}</dd></div>
<div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential || source.credential_ref ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div> <div><dt>i18n:govoplan-calendar.credential.8bede3ea</dt><dd>{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}</dd></div>
</dl> </dl>
{source.last_error && <p className="calendar-form-error">{source.last_error}</p>} {source.last_error && <p className="calendar-form-error">{source.last_error}</p>}
<div className="calendar-sync-actions"> <div className="calendar-sync-actions">
@@ -1700,7 +1759,7 @@ function CalendarCollectionDialog({
</div> </div>
</div> </div>
} }
{sourceMode !== "local" && effectiveAuthType !== "none" && !source?.has_credential && !credentialRef.trim() && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce <code>env:CALENDAR_SYNC_SECRET</code>.</p>} {needsSourceSecret && <p className="calendar-form-note">i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54</p>}
</section> </section>
} }
</form> </form>
@@ -1711,6 +1770,7 @@ function CalendarCollectionDialog({
function CalendarCollectionDeleteDialog({ function CalendarCollectionDeleteDialog({
state, state,
calendars, calendars,
syncSources,
saving, saving,
onCancel, onCancel,
onDelete onDelete
@@ -1720,9 +1780,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 +1796,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 +1854,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" &&
<> <>
@@ -1800,15 +1867,18 @@ function CalendarCollectionDeleteDialog({
</select> </select>
</label> </label>
{calendar.is_default && {calendar.is_default &&
<label className="calendar-checkbox-row"> <ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
<input type="checkbox" checked={makeTargetDefault} onChange={(item) => setMakeTargetDefault(item.target.checked)} />
<span>i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b</span>
</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>);
@@ -2402,15 +2472,19 @@ function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: Calend
display_name: payload.display_name.trim() || null, display_name: payload.display_name.trim() || null,
auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type, auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type,
username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null, username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null,
credential_ref: payload.credential_ref.trim() || null,
sync_enabled: payload.sync_enabled, sync_enabled: payload.sync_enabled,
sync_interval_seconds: Math.max(60, payload.sync_interval_seconds), sync_interval_seconds: Math.max(60, payload.sync_interval_seconds),
sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound", sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound",
conflict_policy: payload.conflict_policy, conflict_policy: payload.conflict_policy,
metadata: {} metadata: {}
}; };
if (result.auth_type === "basic" && payload.password.trim()) result.password = payload.password; if (payload.credential_envelope_id) {
if (result.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token; result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (result.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (result.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result; return result;
} }
@@ -2418,14 +2492,22 @@ function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload):
const result: CalendarSyncSourceUpdatePayload = { const result: CalendarSyncSourceUpdatePayload = {
collection_url: payload.collection_url.trim(), collection_url: payload.collection_url.trim(),
auth_type: payload.auth_type, auth_type: payload.auth_type,
username: payload.auth_type === "basic" ? payload.username.trim() || null : null, username: payload.auth_type === "basic" ? payload.username.trim() || null : null
credential_ref: payload.credential_ref.trim() || null
}; };
if (payload.auth_type === "basic" && payload.password.trim()) result.password = payload.password; if (payload.credential_envelope_id) {
if (payload.auth_type === "bearer" && payload.bearer_token.trim()) result.bearer_token = payload.bearer_token; result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id);
} else if (payload.auth_type === "basic" && payload.password.trim()) {
result.password = payload.password;
} else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) {
result.bearer_token = payload.bearer_token;
}
return result; return result;
} }
function credentialEnvelopeRef(credentialId: string): string {
return `credential-envelope:${credentialId}`;
}
function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind { function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind {
if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal"; if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal";
return sourceMode === "local" ? "caldav" : sourceMode; return sourceMode === "local" ? "caldav" : sourceMode;
@@ -2471,6 +2553,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",
@@ -38,7 +39,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Day", "i18n:govoplan-calendar.day.987b9ced": "Day",
@@ -46,6 +46,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...",
@@ -61,18 +62,21 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Enter a password or token for this source.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"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 +89,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 +101,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 +109,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 +149,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 +168,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 +212,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",
@@ -215,7 +227,6 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED", "i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy", "i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous", "i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential", "i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL", "i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Tag", "i18n:govoplan-calendar.day.987b9ced": "Tag",
@@ -223,6 +234,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...",
@@ -238,18 +250,21 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.end_date.89d10cd6": "End date", "i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode", "i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time", "i18n:govoplan-calendar.end_time.cd7800da": "End time",
"i18n:govoplan-calendar.enter_a_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as", "i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Geben Sie ein Passwort oder Token für diese Quelle ein.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag", "i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.event": "event", "i18n:govoplan-calendar.event": "event",
"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 +277,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 +289,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 +337,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 +356,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);
} }
@@ -89,21 +89,6 @@
gap: 6px; gap: 6px;
} }
.calendar-icon-button.btn {
width: 36px;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calendar-icon-button.btn:disabled {
opacity: .42;
cursor: default;
}
.calendar-mode-switch { .calendar-mode-switch {
flex: 0 1 520px; flex: 0 1 520px;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -121,14 +106,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 +122,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 +186,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 +202,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;
} }
@@ -250,40 +235,7 @@
} }
.calendar-list-actions { .calendar-list-actions {
display: flex; width: auto;
align-items: center;
justify-content: flex-end;
gap: 2px;
min-width: 28px;
}
.calendar-row-icon-button.btn {
width: 28px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-color: transparent;
background: transparent;
opacity: .72;
}
.calendar-row-icon-button.btn:hover,
.calendar-row-icon-button.btn:focus-visible {
border-color: var(--line-dark);
background: var(--surface);
opacity: 1;
}
.calendar-row-icon-button.btn.is-syncing,
.calendar-row-icon-button.btn.is-syncing:disabled {
border-color: var(--green);
background: var(--surface);
color: var(--green);
opacity: 1;
cursor: progress;
} }
.calendar-sync-spin { .calendar-sync-spin {
@@ -306,7 +258,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 +283,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 +298,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 +321,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 +353,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 +391,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 +411,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 +428,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 +451,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 +487,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 +497,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 +549,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 +587,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 +601,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 +617,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 +635,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 +664,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 +676,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 +698,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 +730,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 +751,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 +769,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 +789,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 +816,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 +842,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 +915,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 +1003,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 +1011,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 +1037,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 +1046,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 +1067,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 +1102,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 +1118,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 +1134,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 +1227,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"
]
}