24 Commits
Author SHA1 Message Date
zemion 74495e56cb fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
zemion 6ca1816c95 refactor(webui): centralize Calendar icon actions 2026-07-21 12:36:13 +02:00
zemion 2d7d105ba3 Use explicit outbox source validation 2026-07-21 12:34:34 +02:00
zemion 41b9426670 Refactor calendar event update flow 2026-07-21 12:33:43 +02:00
zemion baf624c7a6 Refactor calendar sync source updates 2026-07-21 12:33:34 +02:00
zemion d10570fc0c Refactor synchronized calendar deletion 2026-07-21 12:33:07 +02:00
zemion 30f6d79f9e Refactor calendar outbox delivery paths 2026-07-21 12:30:03 +02:00
zemion 760a87ab99 Align Calendar runtime version metadata 2026-07-21 12:13:12 +02:00
zemion 7098751f7f Remove caller-managed Calendar secret references 2026-07-21 12:12:35 +02:00
zemion 1df2a1a730 Harden Calendar connector trust boundaries 2026-07-21 12:11:58 +02:00
zemion a52e35b52b Make calendar outbox invariants explicit 2026-07-21 03:16:45 +02:00
zemion a36a9e8c19 Confine calendar connectors to configured origins 2026-07-21 03:16:45 +02:00
zemion 68b165db7b feat(calendar): expose an accessible calendar picker 2026-07-20 17:01:26 +02:00
zemion 2ad6e9d60e feat(calendar): expose loss-safe bulk move modes 2026-07-20 17:01:02 +02:00
zemion a6cd64e3f9 feat(calendar): make external mutations durable 2026-07-20 16:58:45 +02:00
zemion 371a00aff5 intermittent commit 2026-07-14 13:22:10 +02:00
zemion c071235ad7 Harden CalDAV sync handling 2026-07-11 18:37:51 +02:00
zemion 9bcf41bb1f Release v0.1.8 2026-07-11 16:49:01 +02:00
zemion 39d2c28ab1 Release v0.1.7 2026-07-11 02:34:56 +02:00
zemion 9fcb812ddb Create scope tables in calendar backend tests 2026-07-11 00:13:13 +02:00
zemion 70bd067001 Add shared GovOPlaN gitignore 2026-07-10 21:57:21 +02:00
zemion 15aa4f3df3 Use capability-based module boundaries 2026-07-10 17:33:53 +02:00
zemion 15d5613f9b chore: sync GovOPlaN module split state 2026-07-10 12:51:18 +02:00
zemion c3c867391c Release v0.1.6 2026-07-07 15:55:37 +02:00
44 changed files with 11896 additions and 1293 deletions
+268
View File
@@ -8,3 +8,271 @@ __pycache__/
node_modules/
dist/
webui/dist/
# GovOPlaN shared ignore rules from govoplan-core
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.calendar-picker-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/
+11 -3
View File
@@ -1,5 +1,9 @@
# govoplan-calendar
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware.
## Ownership
@@ -36,9 +40,13 @@ The first backend implementation stores VEVENT data in two layers:
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from
environment variables with `env:NAME`, stored through a platform secret provider
when one is available, or stored as encrypted calendar-owned credentials.
Credentials can be supplied transiently for manual sync. Persisted API-managed
sources accept only a password or token: Calendar stores an opaque, tenant- and
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.
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
+78
View File
@@ -24,10 +24,88 @@ The first standalone module provides:
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
- 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
- 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
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
## Outbound delivery operations
No event API or cross-module capability performs CalDAV network I/O inside the
caller's transaction. A local mutation instead creates a
`calendar_outbox_operations` row containing the exact desired ICS resource,
target href, expected ETag, conflict-policy snapshot, and idempotency key.
The registered `govoplan.calendar.dispatch_outbox` worker commits an expiring
lease before doing network I/O and commits each outcome independently. If a
worker loses the database connection after a successful remote write, the next
attempt reads the remote object and compares semantic ICS fingerprints. A
matching PUT or an already absent DELETE completes successfully without a blind
duplicate write. Pending updates for one href supersede older never-attempted
rows; attempted predecessors are reconciled first, and updates queued behind an
in-flight write inherit the ETag produced by that write. Delivery and inbound
REPORT application take the same source-row lock,
and only one operation per source is leased at a time. This deliberately trades
per-source throughput for a simple ordering guarantee: a stale inbound report
cannot land after a newer outbound result, and queued leases do not expire while
waiting behind another network request for the same source.
Operators can list, dispatch, retry, reconcile, and discard tenant operations
through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag
conflicts and exhausted retries remain the current local desired state and
shield that resource from inbound overwrite until explicitly resolved.
Discarding is the administrator's accept-remote transition: it is allowed only
for the latest generation, atomically cancels its unresolved predecessor chain,
marks the event projection as discarded, clears the sync token, and schedules a
full inbound reconciliation so an unchanged remote object is not missed.
Disabling outbound delivery or switching to inbound-only is rejected while
unresolved desired state exists; retirement is the explicit exception and
cancels unresolved work. Endpoint/calendar changes also require no active event
bindings or unresolved delivery. Credential rotation does not discard committed
desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
or ETags. Deleting a synchronized collection or retiring its source is a local
unlink: it never deletes the remote collection or its remaining remote events.
The current singular ownership model permits one active sync source per
calendar; multi-source fan-in will require per-event source routing. Celery beat
triggers recovery every minute, while root-transaction after-commit dispatch
provides the normal low-latency path.
### Collection retirement and bulk event moves
Collection deletion accepts two explicit, non-destructive external actions for
`event_action="move"`:
- `detach_keep_remote` applies only when the source collection is synchronized
and the target is local. It moves the local event projections, clears their
sync-owned source kind, href, ETag, and CalDAV projection metadata, retires
the source locally, and cancels undelivered outbox state. An active delivery
lease blocks the transaction. The action never enqueues a remote DELETE, so
the remote collection and remote events remain unchanged.
- `copy_to_remote` applies only when the source is local and the target has an
active, enabled, two-way CalDAV source. It moves the local events and commits
destination PUT desired states in the durable outbox in the same database
transaction. Remote failures therefore remain visible and retryable without
rolling back or hiding the local move.
Local-to-local moves retain their existing behavior and do not accept an
`external_action`. Implicit or mismatched actions, inbound-only destinations,
and synchronized-to-synchronized moves are rejected. `remote_move` is a known
but deliberately unsupported action: implementing it requires a separately
approved saga for destination reconciliation, conditional source deletion,
collision handling, and concurrent-edit policy. The WebUI does not offer that
destructive mode.
Resolved terminal rows (`succeeded`, `superseded`, and `cancelled`) are removed
in bounded batches after `CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS` (90 days by
default; `0` disables cleanup). `conflict` and `dead` rows are never removed by
this cleanup because they still carry unresolved local desired state. Each
periodic or after-commit dispatch also performs one bounded cleanup batch for
the same tenant scope.
## Integration Points
### Scheduling
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.5",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,8 +19,8 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
+4 -3
View File
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-calendar"
version = "0.1.5"
version = "0.1.8"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.5",
"govoplan-access>=0.1.5",
"govoplan-core>=0.1.9",
"govoplan-access>=0.1.8",
"defusedxml>=0.7,<1",
"icalendar>=7.2",
"python-dateutil>=2.9",
]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.4"
__version__ = "0.1.8"
+372 -15
View File
@@ -1,12 +1,20 @@
from __future__ import annotations
import base64
import posixpath
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Mapping, Protocol
from xml.etree import ElementTree
from typing import Any, Mapping, Protocol
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):
@@ -21,6 +29,10 @@ class CalDAVPreconditionFailed(CalDAVError):
pass
class CalDAVNotFound(CalDAVError):
pass
class CalDAVTransport(Protocol):
def __call__(self, method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
...
@@ -48,6 +60,41 @@ class CalDAVWriteResult:
status: int = 0
@dataclass(frozen=True, slots=True)
class CalDAVDiscoveryCalendar:
collection_url: str
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
@dataclass(frozen=True, slots=True)
class _DAVDiscoveryResponse:
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
is_calendar: bool = False
principal_hrefs: tuple[str, ...] = ()
calendar_home_set_hrefs: 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:
def __init__(
self,
@@ -78,6 +125,96 @@ class CalDAVClient:
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
return parse_multistatus(payload)
def discover_calendars(self) -> list[CalDAVDiscoveryCalendar]:
start_url = self.collection_url
calendars: dict[str, CalDAVDiscoveryCalendar] = {}
home_urls: list[str] = []
principal_urls: list[str] = []
visited_urls: set[tuple[str, str]] = set()
errors: list[str] = []
def add_home_href(base_url: str, href: str) -> None:
url = ensure_collection_url(absolute_dav_url(base_url, href))
if url not in home_urls:
home_urls.append(url)
def add_principal_href(base_url: str, href: str) -> None:
url = ensure_collection_url(absolute_dav_url(base_url, href))
if url not in principal_urls:
principal_urls.append(url)
def add_calendar(base_url: str, response: _DAVDiscoveryResponse) -> None:
if not response.is_calendar:
return
if response.supported_components and "VEVENT" not in response.supported_components:
return
url = ensure_collection_url(absolute_dav_url(base_url, response.href or base_url))
calendars[url] = CalDAVDiscoveryCalendar(
collection_url=url,
href=response.href,
display_name=response.display_name,
color=response.color,
ctag=response.ctag,
sync_token=response.sync_token,
)
def propfind(url: str, depth: str) -> list[_DAVDiscoveryResponse]:
key = (url, depth)
if key in visited_urls:
return []
visited_urls.add(key)
return self.propfind_discovery(url, depth=depth)
try:
for response in propfind(start_url, "0"):
add_calendar(start_url, response)
for href in response.calendar_home_set_hrefs:
add_home_href(start_url, href)
for href in response.principal_hrefs:
add_principal_href(start_url, href)
except CalDAVError as exc:
errors.append(str(exc))
for principal_url in principal_urls[:6]:
try:
for response in propfind(principal_url, "0"):
for href in response.calendar_home_set_hrefs:
add_home_href(principal_url, href)
except CalDAVError as exc:
errors.append(str(exc))
if not home_urls:
home_urls.append(start_url)
for home_url in home_urls:
try:
for response in propfind(home_url, "1"):
add_calendar(home_url, response)
except CalDAVError as exc:
errors.append(str(exc))
if not calendars and errors:
raise CalDAVError(f"CalDAV discovery did not find any calendar collections: {errors[0]}")
return sorted(calendars.values(), key=lambda item: ((item.display_name or item.collection_url).lower(), item.collection_url))
def propfind_discovery(self, url: str, *, depth: str) -> list[_DAVDiscoveryResponse]:
body = b"""<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<D:prop>
<D:displayname/>
<D:current-user-principal/>
<D:principal-URL/>
<D:resourcetype/>
<D:sync-token/>
<C:calendar-home-set/>
<C:supported-calendar-component-set/>
<CS:getctag/>
<ICAL:calendar-color/>
</D:prop>
</D:propfind>"""
payload = self.request("PROPFIND", ensure_collection_url(url), body=body, depth=depth, expected={207})
return parse_discovery_multistatus(payload)
def list_objects(self) -> CalDAVReportResult:
body = b"""<?xml version="1.0" encoding="utf-8"?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
@@ -111,8 +248,23 @@ class CalDAVClient:
return parse_multistatus(payload)
def fetch_object(self, href: str) -> str:
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
return payload.decode("utf-8")
return self.fetch_object_state(href).calendar_data or ""
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:
headers = {"Content-Type": "text/calendar; charset=utf-8"}
@@ -149,7 +301,21 @@ class CalDAVClient:
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str:
return urllib.parse.urljoin(self.collection_url, href)
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:
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
@@ -182,6 +348,8 @@ class CalDAVClient:
headers.update(dict(extra_headers))
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
if status not in expected:
if status == 404:
raise CalDAVNotFound(f"{method} {url} returned HTTP {status}")
if status == 412:
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
raise CalDAVError(f"{method} {url} returned HTTP {status}")
@@ -189,20 +357,40 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
url = validate_http_url(url)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.status, dict(response.headers.items()), response.read()
url = validate_outbound_http_url(url, label="CalDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
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:
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:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except (OutboundHttpError, ValueError) as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
try:
root = ElementTree.fromstring(payload)
except ElementTree.ParseError as exc:
root = SafeElementTree.fromstring(payload)
except SafeElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
objects: list[CalDAVObject] = []
sync_token = first_child_text(root, "sync-token")
@@ -232,8 +420,169 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult:
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
try:
root = SafeElementTree.fromstring(payload)
except SafeElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
return [
parsed
for response in child_elements(root, "response")
if (parsed := _parse_discovery_response(response)) is not None
]
def _parse_discovery_response(response: Any) -> _DAVDiscoveryResponse | None:
href = first_child_text(response, "href")
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
component_name = (component.attrib.get("name") or "").strip().upper()
if component_name:
names.append(component_name)
return names
def dedupe_tuple(values: list[str]) -> tuple[str, ...]:
return tuple(dict.fromkeys(values))
def ensure_collection_url(value: str) -> str:
return value if value.endswith("/") else f"{value}/"
value = value.strip()
if value and "://" not in value and not value.startswith("/"):
value = f"https://{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:
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:
@@ -253,23 +602,31 @@ def xml_escape(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
def first_child(element: Any, name: str) -> Any | None:
for child in element:
if local_name(child.tag) == name:
return child
return None
def first_child_text(element: ElementTree.Element, name: str) -> str | None:
def first_child_text(element: Any, name: str) -> str | None:
found = first_child(element, name)
if found is None or found.text is None:
return None
return found.text.strip()
def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]:
def child_elements(element: Any, name: str) -> list[Any]:
return [child for child in element if local_name(child.tag) == name]
def nested_href_texts(element: Any) -> list[str]:
hrefs: list[str] = []
for child in element.iter():
if local_name(child.tag) == "href" and child.text and child.text.strip():
hrefs.append(child.text.strip())
return hrefs
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
@@ -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,
)
+75 -10
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
@@ -29,7 +29,7 @@ class CalendarCollection(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@@ -39,7 +39,7 @@ class CalendarCollection(Base, TimestampMixin):
owner_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
visibility: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@@ -56,7 +56,7 @@ class CalendarEvent(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
@@ -86,8 +86,8 @@ class CalendarEvent(Base, TimestampMixin):
etag: Mapped[str | None] = mapped_column(String(255), index=True)
icalendar: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
raw_ics: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
@@ -110,7 +110,7 @@ class CalendarSyncSource(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
source_kind: Mapped[str] = mapped_column(String(30), default="caldav", nullable=False, index=True)
collection_url: Mapped[str] = mapped_column(String(1000), nullable=False)
@@ -142,13 +142,78 @@ class CalendarSyncCredential(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
credential_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
label: Mapped[str | None] = mapped_column(String(255))
secret_encrypted: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
__all__ = ["CalendarCollection", "CalendarEvent", "CalendarSyncCredential", "CalendarSyncSource", "new_uuid"]
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",
]
+39 -6
View File
@@ -92,6 +92,8 @@ def parse_vevent_component(calendar: Calendar, event: Event, *, raw_ics: str) ->
properties = component_property_records(event)
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
standard = standard_property_index(properties)
if dtend is None and duration_seconds is not None:
standard["uses_duration"] = True
return {
"uid": uid,
@@ -151,32 +153,58 @@ def events_to_ics(events: list[Any]) -> str:
def event_to_component(event: Any) -> 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("dtstamp", datetime.now(timezone.utc))
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
if event.end_at is not None:
if event_uses_duration(event):
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
elif event.end_at is not None:
component.add("dtend", event_temporal_value(event.end_at, all_day=event.all_day, timezone_id=event.timezone))
elif getattr(event, "duration_seconds", None) is not None:
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
if getattr(event, "recurrence_id", None):
add_recurrence_id(component, str(event.recurrence_id))
def add_event_status_fields(component: Event, event: Any) -> None:
component.add("summary", event.summary)
component.add("sequence", int(event.sequence or 0))
component.add("status", event.status)
component.add("transp", event.transparency)
component.add("class", event.classification)
def add_event_optional_text_fields(component: Event, event: Any) -> None:
if event.description:
component.add("description", event.description)
if event.location:
component.add("location", event.location)
if event.categories:
component.add("categories", list(event.categories))
def add_event_recurrence_and_parties(component: Event, event: Any) -> None:
if event.rrule:
component.add("rrule", normalized_rrule_for_export(event.rrule))
if event.organizer:
component.add("organizer", party_for_export(event.organizer))
for attendee in event.attendees or []:
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 []:
add_raw_property(component, "RDATE", record)
for record in getattr(event, "exdate", None) or []:
@@ -186,11 +214,6 @@ def event_to_component(event: Any) -> Event:
for record in getattr(event, "related_to", None) or []:
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]]:
"""Expand an event's recurrence primitives within a range.
@@ -474,6 +497,16 @@ def metadata_value(event: Any, key: str) -> str | None:
return str(value) if value else None
def event_uses_duration(event: Any) -> bool:
if getattr(event, "duration_seconds", None) is None:
return False
raw_metadata = getattr(event, "icalendar", None)
if not isinstance(raw_metadata, dict):
return False
standard = raw_metadata.get("standard")
return isinstance(standard, dict) and standard.get("uses_duration") is True
def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime:
value = normalize_datetime(value)
if all_day:
+69 -9
View File
@@ -3,8 +3,24 @@ from __future__ import annotations
from pathlib import Path
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.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.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.core.modules import (
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
@@ -27,11 +43,11 @@ PERMISSIONS = (
_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: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: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: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 = (
@@ -43,11 +59,11 @@ ROLE_TEMPLATES = (
"calendar:calendar:read",
"calendar:calendar:write",
"calendar:event:read",
"calendar:event:write",
CALENDAR_EVENT_WRITE_SCOPE,
"calendar:event:delete",
"calendar:event:import",
"calendar:event:export",
"calendar:availability:read",
CALENDAR_AVAILABILITY_READ_SCOPE,
),
),
RoleTemplate(
@@ -58,20 +74,32 @@ ROLE_TEMPLATES = (
"calendar:calendar:read",
"calendar:event:read",
"calendar:event:export",
"calendar:availability:read",
CALENDAR_AVAILABILITY_READ_SCOPE,
),
),
)
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 {
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.status.in_(("pending", "retry", "in_progress")),
)
.count(),
}
@@ -84,16 +112,46 @@ def _calendar_router(context: ModuleContext):
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(
id="calendar",
name="Calendar",
version="0.1.5",
dependencies=("access",),
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
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,
route_factory=_calendar_router,
role_templates=ROLE_TEMPLATES,
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),),
frontend=FrontendModule(
module_id="calendar",
@@ -108,6 +166,7 @@ manifest = ModuleManifest(
retirement_provider=drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
@@ -118,6 +177,7 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
@@ -1,7 +1,7 @@
"""calendar collections and VEVENT storage
Revision ID: 7c8d9e0f1a2b
Revises: 1b2c3d4e5f70
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-07 00:00:00.000000
"""
from __future__ import annotations
@@ -11,14 +11,19 @@ import sqlalchemy as sa
revision = "7c8d9e0f1a2b"
down_revision = "1b2c3d4e5f70"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def _scope_fk_target(tables: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
scope_fk_target = _scope_fk_target(tables)
if "calendar_collections" not in tables:
op.create_table(
"calendar_collections",
@@ -38,8 +43,8 @@ def upgrade() -> None:
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_collections_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_collections_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_collections_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_calendar_collections_tenant_id_scopes"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_collections")),
)
op.create_index(op.f("ix_calendar_collections_tenant_id"), "calendar_collections", ["tenant_id"], unique=False)
@@ -100,9 +105,9 @@ def upgrade() -> None:
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_events_calendar_id_calendar_collections"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_events_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["users.id"], name=op.f("fk_calendar_events_updated_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_events_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_calendar_events_tenant_id_scopes"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["updated_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_events_updated_by_user_id_users"), ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_events")),
sa.UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
)
@@ -16,11 +16,16 @@ branch_labels = None
depends_on = None
def _scope_fk_target(tables: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "calendar_sync_sources" in tables:
return
scope_fk_target = _scope_fk_target(tables)
op.create_table(
"calendar_sync_sources",
sa.Column("id", sa.String(length=36), nullable=False),
@@ -42,7 +47,7 @@ def upgrade() -> None:
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["calendar_id"], ["calendar_collections.id"], name=op.f("fk_calendar_sync_sources_calendar_id_calendar_collections"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_sources_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_calendar_sync_sources_tenant_id_scopes"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_sources")),
)
for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"):
@@ -16,10 +16,15 @@ branch_labels = None
depends_on = None
def _scope_fk_target(tables: set[str]) -> str:
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
scope_fk_target = _scope_fk_target(tables)
if "calendar_sync_sources" in tables:
columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")}
add_column_if_missing(columns, "sync_enabled", sa.Column("sync_enabled", sa.Boolean(), server_default=sa.true(), nullable=False))
@@ -47,8 +52,8 @@ def upgrade() -> None:
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name=op.f("fk_calendar_sync_credentials_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name=op.f("fk_calendar_sync_credentials_tenant_id_tenants"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], name=op.f("fk_calendar_sync_credentials_created_by_user_id_users"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_calendar_sync_credentials_tenant_id_scopes"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_credentials")),
)
for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"):
@@ -0,0 +1 @@
"""Calendar Alembic revisions."""
@@ -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()
@@ -0,0 +1,174 @@
"""v0.1.7 calendar baseline
Revision ID: 9e0f1a2b3c4d
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '9e0f1a2b3c4d'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('calendar_collections',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('timezone', sa.String(length=100), nullable=False),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('owner_type', sa.String(length=20), nullable=False),
sa.Column('owner_id', sa.String(length=36), nullable=True),
sa.Column('visibility', sa.String(length=20), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_collections_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_collections_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_collections'))
)
op.create_index(op.f('ix_calendar_collections_created_by_user_id'), 'calendar_collections', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_deleted_at'), 'calendar_collections', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_collections_is_default'), 'calendar_collections', ['is_default'], unique=False)
op.create_index('ix_calendar_collections_owner', 'calendar_collections', ['tenant_id', 'owner_type', 'owner_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_owner_id'), 'calendar_collections', ['owner_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_owner_type'), 'calendar_collections', ['owner_type'], unique=False)
op.create_index(op.f('ix_calendar_collections_tenant_id'), 'calendar_collections', ['tenant_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_visibility'), 'calendar_collections', ['visibility'], unique=False)
op.create_index('uq_calendar_collections_active_slug', 'calendar_collections', ['tenant_id', 'slug'], unique=True, sqlite_where=sa.text('deleted_at IS NULL'), postgresql_where=sa.text('deleted_at IS NULL'))
op.create_table('calendar_sync_credentials',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('credential_kind', sa.String(length=30), nullable=False),
sa.Column('label', sa.String(length=255), nullable=True),
sa.Column('secret_encrypted', sa.Text(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_sync_credentials_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_sync_credentials_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_credentials'))
)
op.create_index(op.f('ix_calendar_sync_credentials_created_by_user_id'), 'calendar_sync_credentials', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_credential_kind'), 'calendar_sync_credentials', ['credential_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_deleted_at'), 'calendar_sync_credentials', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_tenant_id'), 'calendar_sync_credentials', ['tenant_id'], unique=False)
op.create_index('ix_calendar_sync_credentials_tenant_kind', 'calendar_sync_credentials', ['tenant_id', 'credential_kind'], unique=False)
op.create_table('calendar_events',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('calendar_id', sa.String(length=36), nullable=False),
sa.Column('uid', sa.String(length=255), nullable=False),
sa.Column('recurrence_id', sa.String(length=255), nullable=True),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('summary', sa.String(length=500), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('location', sa.String(length=500), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('transparency', sa.String(length=20), nullable=False),
sa.Column('classification', sa.String(length=20), nullable=False),
sa.Column('start_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('timezone', sa.String(length=100), nullable=True),
sa.Column('organizer', sa.JSON(), nullable=True),
sa.Column('attendees', sa.JSON(), nullable=False),
sa.Column('categories', sa.JSON(), nullable=False),
sa.Column('rrule', sa.JSON(), nullable=True),
sa.Column('rdate', sa.JSON(), nullable=False),
sa.Column('exdate', sa.JSON(), nullable=False),
sa.Column('reminders', sa.JSON(), nullable=False),
sa.Column('attachments', sa.JSON(), nullable=False),
sa.Column('related_to', sa.JSON(), nullable=False),
sa.Column('source_kind', sa.String(length=30), nullable=False),
sa.Column('source_href', sa.String(length=1000), nullable=True),
sa.Column('etag', sa.String(length=255), nullable=True),
sa.Column('icalendar', sa.JSON(), nullable=False),
sa.Column('raw_ics', sa.Text(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['calendar_id'], ['calendar_collections.id'], name=op.f('fk_calendar_events_calendar_id_calendar_collections'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_events_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_events_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_events_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_events')),
sa.UniqueConstraint('calendar_id', 'uid', 'recurrence_id', name='uq_calendar_events_calendar_uid_recurrence')
)
op.create_index(op.f('ix_calendar_events_all_day'), 'calendar_events', ['all_day'], unique=False)
op.create_index(op.f('ix_calendar_events_calendar_id'), 'calendar_events', ['calendar_id'], unique=False)
op.create_index(op.f('ix_calendar_events_created_by_user_id'), 'calendar_events', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_events_deleted_at'), 'calendar_events', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_events_end_at'), 'calendar_events', ['end_at'], unique=False)
op.create_index(op.f('ix_calendar_events_etag'), 'calendar_events', ['etag'], unique=False)
op.create_index('ix_calendar_events_range', 'calendar_events', ['tenant_id', 'calendar_id', 'start_at', 'end_at'], unique=False)
op.create_index(op.f('ix_calendar_events_recurrence_id'), 'calendar_events', ['recurrence_id'], unique=False)
op.create_index(op.f('ix_calendar_events_source_kind'), 'calendar_events', ['source_kind'], unique=False)
op.create_index(op.f('ix_calendar_events_start_at'), 'calendar_events', ['start_at'], unique=False)
op.create_index(op.f('ix_calendar_events_status'), 'calendar_events', ['status'], unique=False)
op.create_index(op.f('ix_calendar_events_tenant_id'), 'calendar_events', ['tenant_id'], unique=False)
op.create_index('ix_calendar_events_tenant_status', 'calendar_events', ['tenant_id', 'status'], unique=False)
op.create_index('ix_calendar_events_tenant_uid', 'calendar_events', ['tenant_id', 'uid'], unique=False)
op.create_index(op.f('ix_calendar_events_uid'), 'calendar_events', ['uid'], unique=False)
op.create_index(op.f('ix_calendar_events_updated_by_user_id'), 'calendar_events', ['updated_by_user_id'], unique=False)
op.create_table('calendar_sync_sources',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('calendar_id', sa.String(length=36), nullable=False),
sa.Column('source_kind', sa.String(length=30), nullable=False),
sa.Column('collection_url', sa.String(length=1000), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('auth_type', sa.String(length=30), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('credential_ref', sa.String(length=255), nullable=True),
sa.Column('sync_enabled', sa.Boolean(), nullable=False),
sa.Column('sync_interval_seconds', sa.Integer(), nullable=False),
sa.Column('sync_direction', sa.String(length=30), nullable=False),
sa.Column('conflict_policy', sa.String(length=30), nullable=False),
sa.Column('sync_token', sa.Text(), nullable=True),
sa.Column('ctag', sa.String(length=255), nullable=True),
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_sync_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_status', sa.String(length=30), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['calendar_id'], ['calendar_collections.id'], name=op.f('fk_calendar_sync_sources_calendar_id_calendar_collections'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_sync_sources_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_sources'))
)
op.create_index('ix_calendar_sync_sources_calendar', 'calendar_sync_sources', ['tenant_id', 'calendar_id', 'source_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_calendar_id'), 'calendar_sync_sources', ['calendar_id'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_deleted_at'), 'calendar_sync_sources', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_next_sync_at'), 'calendar_sync_sources', ['next_sync_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_source_kind'), 'calendar_sync_sources', ['source_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_sync_enabled'), 'calendar_sync_sources', ['sync_enabled'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_tenant_id'), 'calendar_sync_sources', ['tenant_id'], unique=False)
op.create_index('uq_calendar_sync_sources_active_url', 'calendar_sync_sources', ['tenant_id', 'source_kind', 'collection_url'], unique=True, sqlite_where=sa.text('deleted_at IS NULL'), postgresql_where=sa.text('deleted_at IS NULL'))
def downgrade() -> None:
op.drop_table('calendar_sync_sources')
op.drop_table('calendar_events')
op.drop_table('calendar_sync_credentials')
op.drop_table('calendar_collections')
@@ -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
+482 -18
View File
@@ -5,10 +5,16 @@ from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
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_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
from govoplan_calendar.backend.caldav import CalDAVError
from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavDiscoveryResponse,
CalendarCalDavDueSyncItemResponse,
CalendarCalDavDueSyncResponse,
CalendarCalDavSourceCreateRequest,
@@ -23,36 +29,66 @@ from govoplan_calendar.backend.schemas import (
CalendarCollectionResponse,
CalendarCollectionUpdateRequest,
CalendarEventCreateRequest,
CalendarEventDeltaResponse,
CalendarEventListResponse,
CalendarEventResponse,
CalendarEventUpdateRequest,
CalendarFreeBusyRequest,
CalendarFreeBusyResponse,
CalendarIcsImportRequest,
CalendarOutboxDispatchResponse,
CalendarOutboxOperationListResponse,
CalendarOutboxOperationResponse,
CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest,
CalendarSyncSourceListResponse,
CalendarSyncSourceResponse,
CalendarSyncSourceSyncRequest,
CalendarSyncSourceSyncResponse,
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 (
CALENDAR_EVENTS_COLLECTION,
CALENDAR_EVENT_RESOURCE,
CALENDAR_MODULE_ID,
CalendarError,
caldav_source_response,
caldav_sync_response,
calendar_response,
create_calendar,
create_caldav_source,
create_sync_source,
create_event,
delete_calendar,
delete_caldav_source,
delete_sync_source,
delete_event,
discover_caldav_calendars,
event_response,
get_caldav_source,
get_event,
import_ics_event,
list_freebusy,
list_caldav_sources,
list_calendars,
list_events,
list_sync_sources,
normalize_datetime,
sync_due_sources,
sync_due_caldav_sources,
sync_caldav_source,
sync_source,
update_calendar,
update_caldav_source,
update_sync_source,
update_event,
)
from govoplan_core.db.session import get_session
@@ -78,17 +114,142 @@ def _event_response(event) -> CalendarEventResponse:
return CalendarEventResponse.model_validate(event_response(event))
def _calendar_event_watermark(session: Session, tenant_id: str) -> str:
return encode_sequence_watermark(
max_sequence_id(session, tenant_id=tenant_id, module_id=CALENDAR_MODULE_ID, collections=(CALENDAR_EVENTS_COLLECTION,))
)
def _parse_payload_datetime(value) -> datetime | None:
if not isinstance(value, str) or not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
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_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
if event_start is None:
return False
if start_at is not None and event_end is not None and event_end < normalize_datetime(start_at):
return False
if end_at is not None and event_start > normalize_datetime(end_at):
return False
return True
def _event_payload_matches_window(entry, *, calendar_id: str | None, start_at: datetime | None, end_at: datetime | None) -> bool:
payload = entry.payload or {}
current_calendar = payload.get("calendar_id")
previous_calendar = payload.get("previous_calendar_id")
current_calendar_matches = calendar_id is None or current_calendar == calendar_id
previous_calendar_matches = calendar_id is None or previous_calendar == calendar_id
return (
current_calendar_matches and _event_interval_overlaps(payload, prefix="", start_at=start_at, end_at=end_at)
) or (
previous_calendar_matches and _event_interval_overlaps(payload, prefix="previous_", start_at=start_at, end_at=end_at)
)
def _visible_events_for_delta(
session: Session,
*,
tenant_id: str,
event_ids: list[str],
calendar_id: str | None,
start_at: datetime | None,
end_at: datetime | None,
) -> list[CalendarEvent]:
if not event_ids:
return []
query = session.query(CalendarEvent).filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.id.in_(event_ids),
CalendarEvent.deleted_at.is_(None),
)
if calendar_id:
query = query.filter(CalendarEvent.calendar_id == calendar_id)
if start_at is not None:
normalized_start = normalize_datetime(start_at)
query = query.filter((CalendarEvent.end_at.is_(None)) | (CalendarEvent.end_at >= normalized_start))
if end_at is not None:
query = query.filter(CalendarEvent.start_at <= normalize_datetime(end_at))
return query.order_by(CalendarEvent.start_at.asc(), CalendarEvent.summary.asc(), CalendarEvent.id.asc()).all()
def _full_event_delta_response(
session: Session,
*,
tenant_id: str,
calendar_id: str | None,
start_at: datetime | None,
end_at: datetime | None,
) -> CalendarEventDeltaResponse:
events = list_events(session, tenant_id=tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
return CalendarEventDeltaResponse(
events=[_event_response(event) for event in events],
deleted=[],
watermark=_calendar_event_watermark(session, tenant_id),
has_more=False,
full=True,
)
def _event_delta_entries(session: Session, *, tenant_id: str, since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=tenant_id,
module_id=CALENDAR_MODULE_ID,
collections=(CALENDAR_EVENTS_COLLECTION,),
):
return None, False
entries_plus_one = sequence_entries_since(
session,
since=since_sequence,
tenant_id=tenant_id,
module_id=CALENDAR_MODULE_ID,
collections=(CALENDAR_EVENTS_COLLECTION,),
limit=limit + 1,
)
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _caldav_source_response(source) -> CalendarCalDavSourceResponse:
return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source))
def _sync_source_response(source) -> CalendarSyncSourceResponse:
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)
def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_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()
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
@@ -107,7 +268,7 @@ def api_create_calendar(
return _calendar_response(calendar)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.patch("/calendars/{calendar_id}", response_model=CalendarCollectionResponse)
@@ -142,7 +303,129 @@ def api_delete_calendar(
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/sync-sources", response_model=CalendarSyncSourceListResponse)
def api_list_sync_sources(
calendar_id: str | None = None,
source_kind: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:read")
sources = list_sync_sources(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, source_kind=source_kind)
return CalendarSyncSourceListResponse(sources=[_sync_source_response(source) for source in sources])
@router.post("/sync-sources", response_model=CalendarSyncSourceResponse, status_code=status.HTTP_201_CREATED)
def api_create_sync_source(
payload: CalendarSyncSourceCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = create_sync_source(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit()
session.refresh(source)
return _sync_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.patch("/sync-sources/{source_id}", response_model=CalendarSyncSourceResponse)
def api_update_sync_source(
source_id: str,
payload: CalendarSyncSourceUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
session.commit()
session.refresh(source)
return _sync_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.delete("/sync-sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_sync_source(
source_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
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)
def api_sync_source(
source_id: str,
payload: CalendarSyncSourceSyncRequest | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
payload = payload or CalendarSyncSourceSyncRequest()
try:
source, stats = sync_source(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
source_id=source_id,
password=payload.password,
bearer_token=payload.bearer_token,
force_full=payload.force_full,
)
session.commit()
session.refresh(source)
return CalendarSyncSourceSyncResponse.model_validate(caldav_sync_response(source, stats))
except (CalendarError, CalDAVError, ICalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/sync-sources/sync-due", response_model=CalendarSyncDueSyncResponse)
def api_sync_due_sources(
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit()
return CalendarSyncDueSyncResponse(
results=[
CalendarSyncDueSyncItemResponse(
source_id=item.source_id,
calendar_id=item.calendar_id,
status=item.status,
error=item.error,
created=item.stats.created if item.stats else 0,
updated=item.stats.updated if item.stats else 0,
deleted=item.stats.deleted if item.stats else 0,
unchanged=item.stats.unchanged if item.stats else 0,
fetched=item.stats.fetched if item.stats else 0,
)
for item in results
]
)
@router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse)
@@ -156,6 +439,20 @@ def api_list_caldav_sources(
return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in sources])
@router.post("/caldav/discover", response_model=CalendarCalDavDiscoveryResponse)
def api_discover_caldav_calendars(
payload: CalendarCalDavDiscoveryRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload)
return CalendarCalDavDiscoveryResponse(calendars=calendars)
except (CalendarError, CalDAVError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
def api_create_caldav_source(
payload: CalendarCalDavSourceCreateRequest,
@@ -170,7 +467,7 @@ def api_create_caldav_source(
return _caldav_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse)
@@ -188,7 +485,7 @@ def api_update_caldav_source(
return _caldav_source_response(source)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -204,7 +501,12 @@ def api_delete_caldav_source(
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
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)
@@ -231,7 +533,7 @@ def api_sync_caldav_source(
return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats))
except (CalendarError, CalDAVError, ICalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse)
@@ -261,6 +563,117 @@ def api_sync_due_caldav_sources(
)
@router.get("/caldav/outbox", response_model=CalendarOutboxOperationListResponse)
def api_list_caldav_outbox(
operation_status: str | None = Query(default=None, alias="status"),
source_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
operations = list_calendar_outbox_operations(
session,
tenant_id=principal.tenant_id,
status=operation_status,
source_id=source_id,
limit=limit,
)
return CalendarOutboxOperationListResponse(
operations=[_outbox_operation_response(operation) for operation in operations]
)
@router.post("/caldav/outbox/dispatch", response_model=CalendarOutboxDispatchResponse)
def api_dispatch_caldav_outbox(
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarOutboxDispatchResponse.model_validate(
dispatch_calendar_outbox(
session,
tenant_id=principal.tenant_id,
limit=limit,
)
)
@router.post(
"/caldav/outbox/{operation_id}/retry",
response_model=CalendarOutboxOperationResponse,
)
def api_retry_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = retry_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/reconcile",
response_model=CalendarOutboxOperationResponse,
)
def api_reconcile_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = reconcile_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except (ValueError, CalDAVError, CalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/discard",
response_model=CalendarOutboxOperationResponse,
)
def api_discard_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
"""Abandon local desired state and allow the next sync to accept remote."""
_require_scope(principal, "calendar:calendar:admin")
try:
operation = discard_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.get("/events", response_model=CalendarEventListResponse)
def api_list_events(
calendar_id: str | None = None,
@@ -274,13 +687,64 @@ def api_list_events(
return CalendarEventListResponse(events=[_event_response(event) for event in events])
@router.get("/events/delta", response_model=CalendarEventDeltaResponse)
def api_list_events_delta(
calendar_id: str | None = None,
start_at: datetime | None = Query(default=None),
end_at: datetime | None = Query(default=None),
since: str | None = None,
limit: int = Query(default=500, ge=1, le=1000),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
if since is None:
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
entries, has_more = _event_delta_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit)
if entries is None:
return _full_event_delta_response(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
scoped_entries = [
entry
for entry in entries
if entry.resource_type == CALENDAR_EVENT_RESOURCE and _event_payload_matches_window(entry, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
]
changed_ids = list(dict.fromkeys(entry.resource_id for entry in scoped_entries if entry.operation != "deleted"))
visible_events = _visible_events_for_delta(
session,
tenant_id=principal.tenant_id,
event_ids=changed_ids,
calendar_id=calendar_id,
start_at=start_at,
end_at=end_at,
)
visible_ids = {event.id for event in visible_events}
deleted = [
DeltaDeletedItem(
id=entry.resource_id,
resource_type=entry.resource_type,
revision=encode_sequence_watermark(entry.id),
deleted_at=entry.created_at if entry.operation == "deleted" else None,
)
for entry in scoped_entries
if entry.resource_id not in visible_ids
]
watermark = encode_sequence_watermark(entries[-1].id) if has_more and entries else _calendar_event_watermark(session, principal.tenant_id)
return CalendarEventDeltaResponse(
events=[_event_response(event) for event in visible_events],
deleted=deleted,
watermark=watermark,
has_more=has_more,
full=False,
)
@router.post("/events", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
def api_create_event(
payload: CalendarEventCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit()
@@ -288,7 +752,7 @@ def api_create_event(
return _event_response(event)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/events/{event_id}", response_model=CalendarEventResponse)
@@ -311,7 +775,7 @@ def api_update_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
session.commit()
@@ -319,7 +783,7 @@ def api_update_event(
return _event_response(event)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -328,9 +792,9 @@ def api_delete_event(
principal: ApiPrincipal = Depends(get_api_principal),
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:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id)
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
@@ -362,7 +826,7 @@ def api_import_ics_event(
return _event_response(event)
except (CalendarError, ICalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/events/{event_id}/ics")
@@ -393,7 +857,7 @@ def api_freebusy(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:availability:read")
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
busy = list_freebusy(
session,
@@ -404,4 +868,4 @@ def api_freebusy(
)
return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy)
except CalendarError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
+6 -32
View File
@@ -1,36 +1,10 @@
from __future__ import annotations
from typing import Any
from govoplan_core.core.runtime import ModuleRuntimeState
_runtime_registry: object | None = None
_runtime_settings: object | None = None
_runtime = ModuleRuntimeState("Calendar")
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_registry, _runtime_settings
if registry is not None:
_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()
configure_runtime = _runtime.configure_runtime
get_registry = _runtime.get_registry
get_settings = _runtime.get_settings
settings = _runtime.settings
+126 -7
View File
@@ -5,12 +5,20 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from govoplan_core.api.v1.schemas import DeltaDeletedItem
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
CalendarDeleteEventAction = Literal["delete", "move"]
CalendarBulkMoveExternalAction = Literal[
"detach_keep_remote",
"copy_to_remote",
"remote_move",
]
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
CalendarSyncDirection = Literal["inbound", "two_way"]
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
@@ -47,6 +55,7 @@ class CalendarCollectionDeleteRequest(BaseModel):
event_action: CalendarDeleteEventAction = "delete"
target_calendar_id: str | None = None
make_target_default: bool = False
external_action: CalendarBulkMoveExternalAction | None = None
class CalendarCollectionResponse(BaseModel):
@@ -70,9 +79,10 @@ class CalendarCollectionListResponse(BaseModel):
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
class CalendarCalDavSourceCreateRequest(BaseModel):
class CalendarSyncSourceCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_kind: CalendarSyncSourceKind = "caldav"
calendar_id: str
collection_url: str = Field(min_length=1, max_length=1000)
display_name: str | None = Field(default=None, max_length=255)
@@ -88,7 +98,11 @@ class CalendarCalDavSourceCreateRequest(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarCalDavSourceUpdateRequest(BaseModel):
class CalendarCalDavSourceCreateRequest(CalendarSyncSourceCreateRequest):
source_kind: Literal["caldav"] = "caldav"
class CalendarSyncSourceUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
calendar_id: str | None = None
@@ -108,7 +122,11 @@ class CalendarCalDavSourceUpdateRequest(BaseModel):
metadata: dict[str, Any] | None = None
class CalendarCalDavSourceResponse(BaseModel):
class CalendarCalDavSourceUpdateRequest(CalendarSyncSourceUpdateRequest):
pass
class CalendarSyncSourceResponse(BaseModel):
id: str
tenant_id: str
calendar_id: str
@@ -135,11 +153,44 @@ class CalendarCalDavSourceResponse(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class CalendarCalDavSourceResponse(CalendarSyncSourceResponse):
pass
class CalendarSyncSourceListResponse(BaseModel):
sources: list[CalendarSyncSourceResponse] = Field(default_factory=list)
class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCalDavSyncRequest(BaseModel):
class CalendarCalDavDiscoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
url: str = Field(min_length=1, max_length=1000)
source_id: str | None = None
auth_type: CalendarSyncAuthType | None = None
username: str | None = Field(default=None, max_length=255)
credential_ref: str | None = Field(default=None, max_length=255)
password: SecretStr | None = None
bearer_token: SecretStr | None = None
class CalendarCalDavDiscoveryCalendarResponse(BaseModel):
collection_url: str
href: str
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
class CalendarCalDavDiscoveryResponse(BaseModel):
calendars: list[CalendarCalDavDiscoveryCalendarResponse] = Field(default_factory=list)
class CalendarSyncSourceSyncRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
password: str | None = None
@@ -147,8 +198,12 @@ class CalendarCalDavSyncRequest(BaseModel):
force_full: bool = False
class CalendarCalDavSyncResponse(BaseModel):
source: CalendarCalDavSourceResponse
class CalendarCalDavSyncRequest(CalendarSyncSourceSyncRequest):
pass
class CalendarSyncSourceSyncResponse(BaseModel):
source: CalendarSyncSourceResponse
created: int = 0
updated: int = 0
deleted: int = 0
@@ -161,7 +216,11 @@ class CalendarCalDavSyncResponse(BaseModel):
errors: list[str] = Field(default_factory=list)
class CalendarCalDavDueSyncItemResponse(BaseModel):
class CalendarCalDavSyncResponse(CalendarSyncSourceSyncResponse):
source: CalendarCalDavSourceResponse
class CalendarSyncDueSyncItemResponse(BaseModel):
source_id: str
calendar_id: str
status: str
@@ -173,10 +232,62 @@ class CalendarCalDavDueSyncItemResponse(BaseModel):
fetched: int = 0
class CalendarCalDavDueSyncItemResponse(CalendarSyncDueSyncItemResponse):
pass
class CalendarSyncDueSyncResponse(BaseModel):
results: list[CalendarSyncDueSyncItemResponse] = Field(default_factory=list)
class CalendarCalDavDueSyncResponse(BaseModel):
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):
model_config = ConfigDict(extra="forbid")
@@ -283,6 +394,14 @@ class CalendarEventListResponse(BaseModel):
events: list[CalendarEventResponse] = Field(default_factory=list)
class CalendarEventDeltaResponse(BaseModel):
events: list[CalendarEventResponse] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class CalendarFreeBusyRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -5,11 +5,11 @@ from celery import shared_task
@shared_task(name="govoplan_calendar.sync_due_caldav_sources")
def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]:
from govoplan_calendar.backend.service import sync_due_caldav_sources
from govoplan_calendar.backend.service import sync_due_sources
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
results = sync_due_caldav_sources(session, tenant_id=tenant_id, limit=limit)
results = sync_due_sources(session, tenant_id=tenant_id, limit=limit)
session.commit()
return [
{
+530 -23
View File
@@ -2,30 +2,45 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_calendar.backend.caldav import CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceUpdateRequest,
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError,
caldav_client_for_source,
caldav_source_response,
create_calendar,
create_caldav_source,
create_event,
delete_calendar,
delete_event,
discover_caldav_calendars,
list_caldav_sources,
list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source,
sync_due_caldav_sources,
update_caldav_source,
update_event,
)
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
@@ -36,12 +51,14 @@ class FakeCalDAVClient:
list_result: CalDAVReportResult | None = None,
sync_result: CalDAVReportResult | None = None,
objects: dict[str, str] | None = None,
fetch_not_found: set[str] | None = None,
put_etags: list[str | None] | None = None,
fail_precondition: bool = False,
) -> None:
self.list_result = list_result or CalDAVReportResult()
self.sync_result = sync_result
self.objects = objects or {}
self.fetch_not_found = fetch_not_found or set()
self.put_etags = put_etags or ['"etag-written"']
self.fail_precondition = fail_precondition
self.fetched: list[str] = []
@@ -61,6 +78,8 @@ class FakeCalDAVClient:
def fetch_object(self, href: str) -> str:
self.fetched.append(href)
if href in self.fetch_not_found:
raise CalDAVNotFound(f"GET {href} returned HTTP 404")
return self.objects[href]
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
@@ -77,6 +96,14 @@ class FakeCalDAVClient:
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):
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
result = parse_multistatus(
@@ -116,18 +143,136 @@ END:VCALENDAR</C:calendar-data>
self.assertIn("BEGIN:VEVENT", result.objects[0].calendar_data or "")
self.assertTrue(result.objects[1].deleted)
def test_discovery_resolves_nextcloud_dav_root_to_calendar_collections(self) -> None:
requests: list[tuple[str, str, str | None]] = []
def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int):
requests.append((method, url, headers.get("Authorization")))
if url == "https://cloud.example.test/remote.php/dav/":
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/remote.php/dav/</D:href>
<D:propstat>
<D:prop>
<D:current-user-principal><D:href>/remote.php/dav/principals/users/ada/</D:href></D:current-user-principal>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
if url == "https://cloud.example.test/remote.php/dav/principals/users/ada/":
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/remote.php/dav/principals/users/ada/</D:href>
<D:propstat>
<D:prop>
<C:calendar-home-set><D:href>/remote.php/dav/calendars/ada/</D:href></C:calendar-home-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
if url == "https://cloud.example.test/remote.php/dav/calendars/ada/":
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<D:response>
<D:href>/remote.php/dav/calendars/ada/</D:href>
<D:propstat>
<D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
<D:response>
<D:href>/remote.php/dav/calendars/ada/personal/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Personal</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
<ICAL:calendar-color>#44aa99ff</ICAL:calendar-color>
<CS:getctag>ctag-1</CS:getctag>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
raise AssertionError(f"Unexpected {method} {url}")
client = CalDAVClient(collection_url="https://cloud.example.test/remote.php/dav", username="ada", password="secret", transport=transport)
calendars = client.discover_calendars()
self.assertEqual(len(calendars), 1)
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
self.assertEqual(calendars[0].display_name, "Personal")
self.assertEqual(calendars[0].color, "#44aa99ff")
self.assertEqual(requests[0][2], "Basic YWRhOnNlY3JldA==")
def test_discovery_accepts_host_path_url_without_scheme(self) -> None:
requested_urls: list[str] = []
def transport(method: str, url: str, headers: dict[str, str], body: bytes | None, timeout: int):
requested_urls.append(url)
return 207, {}, b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/remote.php/dav/calendars/ada/personal/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Personal</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
client = CalDAVClient(collection_url="cloud.example.test/remote.php/dav", transport=transport)
calendars = client.discover_calendars()
self.assertEqual(requested_urls[0], "https://cloud.example.test/remote.php/dav/")
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:
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
CalDAVClient(collection_url="/remote.php/dav")
def test_object_url_rejects_off_origin_and_cross_collection_hrefs(self) -> None:
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):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
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:
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"))
@@ -153,8 +298,276 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
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)
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, [])
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref])
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:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
session.commit()
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(source.deleted_at)
self.assertIsNotNone(credential.deleted_at)
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None:
session = self.session()
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_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=old_calendar.id, collection_url="https://dav.example.test/cal"),
)
old_calendar.deleted_at = datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc)
new_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="New"))
session.commit()
new_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=new_calendar.id, collection_url="https://dav.example.test/cal"),
)
session.commit()
self.assertIsNotNone(old_source.deleted_at)
self.assertEqual(new_source.calendar_id, new_calendar.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:
session = self.session()
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"))
second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second"))
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=first.id, collection_url="https://dav.example.test/cal"),
)
session.commit()
with self.assertRaisesRegex(CalendarError, "already linked"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=second.id, collection_url="https://dav.example.test/cal"),
)
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"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
@@ -180,15 +593,42 @@ class CalDAVSyncTests(unittest.TestCase):
next_sync_at = source.next_sync_at if source.next_sync_at.tzinfo else source.next_sync_at.replace(tzinfo=timezone.utc)
self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc))
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
session = self.Session()
def test_due_sync_emits_recovery_notification_after_previous_error(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"),
)
source.last_status = "error"
source.next_sync_at = datetime(2026, 7, 7, 8, 0, tzinfo=timezone.utc)
session.commit()
provider = FakeNotificationProvider()
fake = FakeCalDAVClient(list_result=CalDAVReportResult(sync_token="token-1"))
with patch("govoplan_calendar.backend.service.notification_dispatch_provider", return_value=provider):
results = sync_due_caldav_sources(
session,
tenant_id="tenant-1",
now=datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc),
client_factory=lambda _source: fake,
)
self.assertEqual(results[0].status, "ok")
self.assertEqual(len(provider.requests), 1)
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
fake = FakeCalDAVClient(put_etags=['"etag-1"'])
event = create_event(
session,
tenant_id="tenant-1",
@@ -202,18 +642,22 @@ class CalDAVSyncTests(unittest.TestCase):
),
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
self.assertEqual(fake.puts, [])
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.assertEqual(fake.puts[1]["etag"], '"etag-1"')
self.assertFalse(fake.puts[1]["create"])
self.assertIn("SUMMARY:Updated", fake.puts[0]["ics"])
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 "")
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"))
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"))
@@ -221,13 +665,25 @@ class CalDAVSyncTests(unittest.TestCase):
session.add(event)
session.commit()
fake = FakeCalDAVClient(fail_precondition=True)
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
with self.assertRaisesRegex(CalendarError, "changed remotely"):
fake = FakeCalDAVClient(
fail_precondition=True,
objects={event.source_href: recurring_resource()},
)
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:
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"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -238,9 +694,9 @@ class CalDAVSyncTests(unittest.TestCase):
session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-2"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
delete_event(session, tenant_id="tenant-1", event_id=override.id)
session.commit()
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1)
self.assertEqual(fake.deletes, [])
@@ -250,7 +706,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(override.deleted_at)
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"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Local"))
create_event(
@@ -292,7 +748,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc))
def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None:
session = self.Session()
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(
@@ -333,7 +789,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(calendar.metadata_["source_kind"], "caldav")
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")
session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
@@ -381,6 +837,57 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(source.sync_token, "token-2")
self.assertIsNotNone(event.deleted_at)
def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None:
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"),
)
source.sync_token = "token-1"
event = CalendarEvent(
tenant_id="tenant-1",
calendar_id=calendar.id,
uid="event-1@example.test",
recurrence_id=None,
summary="Remote",
status="CONFIRMED",
transparency="OPAQUE",
classification="PUBLIC",
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
all_day=False,
attendees=[],
categories=[],
rdate=[],
exdate=[],
reminders=[],
attachments=[],
related_to=[],
source_kind="caldav",
source_href="https://dav.example.test/cal/event-1.ics",
icalendar={},
metadata_={"caldav": {"source_id": source.id}},
)
session.add(event)
session.commit()
client = FakeCalDAVClient(
sync_result=CalDAVReportResult(objects=[CalDAVObject(href="/cal/event-1.ics", etag='"gone"', calendar_data=None)], sync_token="token-2"),
fetch_not_found={"/cal/event-1.ics"},
)
source, stats = sync_caldav_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id, client=client)
session.commit()
self.assertTrue(stats.used_sync_token)
self.assertEqual(stats.deleted, 1)
self.assertEqual(source.last_status, "ok")
self.assertEqual(source.sync_token, "token-2")
self.assertIsNotNone(event.deleted_at)
def recurring_resource() -> str:
return """BEGIN:VCALENDAR
+142
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()
+27
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()
+90
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()
+33
View File
@@ -92,6 +92,7 @@ END:VCALENDAR
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
self.assertEqual(event["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
self.assertEqual(event["duration_seconds"], 5400)
self.assertTrue(event["icalendar"]["standard"]["uses_duration"])
self.assertEqual(event["description"], "Line one\nLine two")
self.assertEqual(event["location"], "Room; 1")
self.assertEqual(event["timezone"], "Europe/Berlin")
@@ -164,6 +165,38 @@ END:VCALENDAR
self.assertIn("BEGIN:VALARM", ics)
self.assertIn("TRIGGER:-PT15M", ics)
def test_generated_ics_preserves_duration_when_source_used_duration(self) -> None:
event = SimpleNamespace(
uid="event-duration@example.test",
start_at=datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc),
duration_seconds=5400,
all_day=False,
timezone="Europe/Berlin",
summary="Duration",
sequence=0,
status="CONFIRMED",
transparency="OPAQUE",
classification="PUBLIC",
description=None,
location=None,
categories=[],
rrule=None,
organizer=None,
attendees=[],
rdate=[],
exdate=[],
reminders=[],
attachments=[],
related_to=[],
icalendar={"standard": {"uses_duration": True}},
)
ics = event_to_ics(event)
self.assertIn("DURATION:PT1H30M", ics)
self.assertNotIn("DTEND", ics)
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
parsed = parse_vevent(
"""BEGIN:VCALENDAR
+1819
View File
File diff suppressed because it is too large Load Diff
+62 -1
View File
@@ -6,7 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
from govoplan_calendar.backend.service import delete_calendar, event_response
from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response
class FakeSession:
@@ -65,6 +65,67 @@ class CalendarServiceResponseTests(unittest.TestCase):
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):
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
session = FakeSession()
+348
View File
@@ -0,0 +1,348 @@
from __future__ import annotations
import json
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest
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.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
class CalendarSyncSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session_with_calendar(self):
session = self.Session()
self.sessions.append(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"))
session.flush()
return session, calendar
def test_ics_subscription_sync_imports_events_and_blocks_local_mutation(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",
sync_direction="two_way",
),
)
ics = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:ics-1@example.test
DTSTART:20260708T090000Z
DTEND:20260708T100000Z
SUMMARY:ICS item
END:VEVENT
END:VCALENDAR
"""
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {"etag": '"feed-1"'}, ics)):
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(refreshed.sync_direction, "inbound")
self.assertEqual(stats.created, 1)
self.assertEqual(event.source_kind, "ics")
self.assertEqual(event.summary, "ICS item")
with self.assertRaisesRegex(CalendarError, "inbound-only"):
create_event(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=calendar.id,
summary="Local edit",
start_at=datetime(2026, 7, 8, 12, tzinfo=timezone.utc),
),
)
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:
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": [
{
"id": "graph-event-1",
"iCalUId": "graph-uid-1",
"subject": "Graph item",
"start": {"dateTime": "2026-07-08T09:00:00", "timeZone": "UTC"},
"end": {"dateTime": "2026-07-08T10:00:00", "timeZone": "UTC"},
"@odata.etag": "graph-etag-1",
}
],
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendar/events/delta?$deltatoken=next",
}
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, json.dumps(payload))) as request:
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(stats.created, 1)
self.assertEqual(refreshed.sync_token, payload["@odata.deltaLink"])
self.assertEqual(event.source_kind, "graph")
self.assertEqual(event.source_href, "graph-event-1")
self.assertEqual(event.summary, "Graph item")
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:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ews",
calendar_id=calendar.id,
collection_url="https://exchange.example.test/EWS/Exchange.asmx",
auth_type="basic",
username="ada",
password="secret",
),
)
response_xml = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
<s:Body>
<m:FindItemResponse>
<m:ResponseMessages>
<m:FindItemResponseMessage ResponseClass="Success">
<m:RootFolder>
<t:Items>
<t:CalendarItem>
<t:ItemId Id="ews-event-1" ChangeKey="ews-etag-1" />
<t:Subject>EWS item</t:Subject>
<t:Start>2026-07-08T09:00:00Z</t:Start>
<t:End>2026-07-08T10:00:00Z</t:End>
<t:IsAllDayEvent>false</t:IsAllDayEvent>
<t:UID>ews-uid-1</t:UID>
</t:CalendarItem>
</t:Items>
</m:RootFolder>
</m:FindItemResponseMessage>
</m:ResponseMessages>
</m:FindItemResponse>
</s:Body>
</s:Envelope>"""
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, response_xml)) as request:
_refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(stats.created, 1)
self.assertEqual(event.source_kind, "ews")
self.assertEqual(event.source_href, "ews-event-1")
self.assertEqual(event.summary, "EWS item")
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__":
unittest.main()
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.5",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,9 +13,12 @@
},
"./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": {
"@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
+196 -4
View File
@@ -55,6 +55,114 @@ export type CalendarEvent = {
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
export type CalendarEventListResponse = { events: CalendarEvent[] };
export type CalendarDeltaDeletedItem = { id: string; resource_type?: string | null; revision?: string | null; deleted_at?: string | null };
export type CalendarEventDeltaResponse = {
events: CalendarEvent[];
deleted: CalendarDeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type CalendarCalDavAuthType = "none" | "basic" | "bearer";
export type CalendarCalDavSyncDirection = "inbound" | "two_way";
export type CalendarCalDavConflictPolicy = "etag" | "overwrite";
export type CalendarSyncSourceKind = "caldav" | "ics" | "webcal" | "graph" | "ews";
export type CalendarSyncSource = {
id: string;
tenant_id: string;
calendar_id: string;
source_kind: CalendarSyncSourceKind;
collection_url: string;
display_name?: string | null;
auth_type: CalendarCalDavAuthType;
username?: string | null;
has_credential: boolean;
sync_enabled: boolean;
sync_interval_seconds: number;
sync_direction: CalendarCalDavSyncDirection;
conflict_policy: CalendarCalDavConflictPolicy;
sync_token?: string | null;
ctag?: string | null;
last_attempt_at?: string | null;
last_synced_at?: string | null;
next_sync_at?: string | null;
last_status?: string | null;
last_error?: string | null;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown> | null;
};
export type CalendarCalDavSource = CalendarSyncSource;
export type CalendarSyncSourceListResponse = { sources: CalendarSyncSource[] };
export type CalendarCalDavSourceListResponse = { sources: CalendarCalDavSource[] };
export type CalendarCalDavDiscoveryCandidate = {
collection_url: string;
href: string;
display_name?: string | null;
color?: string | null;
ctag?: string | null;
sync_token?: string | null;
};
export type CalendarCalDavDiscoveryPayload = {
url: string;
source_id?: string | null;
auth_type?: CalendarCalDavAuthType | null;
username?: string | null;
password?: string | null;
bearer_token?: string | null;
};
export type CalendarCalDavDiscoveryResponse = { calendars: CalendarCalDavDiscoveryCandidate[] };
export type CalendarSyncSourceCreatePayload = {
source_kind?: CalendarSyncSourceKind;
calendar_id: string;
collection_url: string;
display_name?: string | null;
auth_type?: CalendarCalDavAuthType;
username?: string | null;
password?: string | null;
bearer_token?: string | null;
sync_enabled?: boolean;
sync_interval_seconds?: number;
sync_direction?: CalendarCalDavSyncDirection;
conflict_policy?: CalendarCalDavConflictPolicy;
metadata?: Record<string, unknown>;
};
export type CalendarCalDavSourceCreatePayload = CalendarSyncSourceCreatePayload;
export type CalendarCalDavSourceUpdatePayload = Partial<CalendarCalDavSourceCreatePayload>;
export type CalendarSyncSourceUpdatePayload = Partial<CalendarSyncSourceCreatePayload>;
export type CalendarSyncSourceSyncPayload = {
password?: string | null;
bearer_token?: string | null;
force_full?: boolean;
};
export type CalendarCalDavSyncPayload = CalendarSyncSourceSyncPayload;
export type CalendarSyncSourceSyncResponse = {
source: CalendarSyncSource;
created: number;
updated: number;
deleted: number;
unchanged: number;
fetched: number;
full_sync: boolean;
used_sync_token: boolean;
sync_token?: string | null;
ctag?: string | null;
errors: string[];
};
export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse;
export type CalendarCollectionCreatePayload = {
name: string;
@@ -71,25 +179,44 @@ export type CalendarCollectionCreatePayload = {
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
export type CalendarDeleteEventAction = "delete" | "move";
export type CalendarBulkMoveExternalAction = "detach_keep_remote" | "copy_to_remote" | "remote_move";
export type CalendarCollectionDeletePayload = {
event_action?: CalendarDeleteEventAction;
target_calendar_id?: string | null;
make_target_default?: boolean;
external_action?: CalendarBulkMoveExternalAction | null;
};
export type CalendarEventCreatePayload = {
calendar_id?: string | null;
uid?: string | null;
recurrence_id?: string | null;
sequence?: number;
summary: string;
description?: string | null;
location?: string | null;
start_at: string;
end_at?: string | null;
all_day?: boolean;
timezone?: string | null;
status?: string;
transparency?: string;
classification?: string;
start_at: string;
end_at?: string | null;
duration_seconds?: number | null;
all_day?: boolean;
timezone?: string | null;
organizer?: Record<string, unknown> | null;
attendees?: Record<string, unknown>[];
categories?: string[];
rrule?: Record<string, unknown> | null;
rdate?: Record<string, unknown>[];
exdate?: Record<string, unknown>[];
reminders?: Record<string, unknown>[];
attachments?: Record<string, unknown>[];
related_to?: Record<string, unknown>[];
source_kind?: string;
source_href?: string | null;
etag?: string | null;
icalendar?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
export type CalendarEventUpdatePayload = Partial<CalendarEventCreatePayload>;
@@ -110,6 +237,57 @@ export function deleteCalendar(settings: ApiSettings, calendarId: string, payloa
return apiFetch<void>(settings, `/api/v1/calendar/calendars/${calendarId}`, { method: "DELETE", body: JSON.stringify(payload) });
}
export function listSyncSources(settings: ApiSettings, params: { calendar_id?: string; source_kind?: CalendarSyncSourceKind } = {}): Promise<CalendarSyncSourceListResponse> {
const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
if (params.source_kind) search.set("source_kind", params.source_kind);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarSyncSourceListResponse>(settings, `/api/v1/calendar/sync-sources${suffix}`);
}
export function listCalDavSources(settings: ApiSettings, params: { calendar_id?: string } = {}): Promise<CalendarCalDavSourceListResponse> {
const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarCalDavSourceListResponse>(settings, `/api/v1/calendar/caldav/sources${suffix}`);
}
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
return apiFetch<CalendarCalDavDiscoveryResponse>(settings, "/api/v1/calendar/caldav/discover", { method: "POST", body: JSON.stringify(payload) });
}
export function createSyncSource(settings: ApiSettings, payload: CalendarSyncSourceCreatePayload): Promise<CalendarSyncSource> {
return apiFetch<CalendarSyncSource>(settings, "/api/v1/calendar/sync-sources", { method: "POST", body: JSON.stringify(payload) });
}
export function createCalDavSource(settings: ApiSettings, payload: CalendarCalDavSourceCreatePayload): Promise<CalendarCalDavSource> {
return apiFetch<CalendarCalDavSource>(settings, "/api/v1/calendar/caldav/sources", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSyncSource(settings: ApiSettings, sourceId: string, payload: CalendarSyncSourceUpdatePayload): Promise<CalendarSyncSource> {
return apiFetch<CalendarSyncSource>(settings, `/api/v1/calendar/sync-sources/${sourceId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function updateCalDavSource(settings: ApiSettings, sourceId: string, payload: CalendarCalDavSourceUpdatePayload): Promise<CalendarCalDavSource> {
return apiFetch<CalendarCalDavSource>(settings, `/api/v1/calendar/caldav/sources/${sourceId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteSyncSource(settings: ApiSettings, sourceId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/calendar/sync-sources/${sourceId}`, { method: "DELETE" });
}
export function deleteCalDavSource(settings: ApiSettings, sourceId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/calendar/caldav/sources/${sourceId}`, { method: "DELETE" });
}
export function syncSyncSource(settings: ApiSettings, sourceId: string, payload: CalendarSyncSourceSyncPayload = {}): Promise<CalendarSyncSourceSyncResponse> {
return apiFetch<CalendarSyncSourceSyncResponse>(settings, `/api/v1/calendar/sync-sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
}
export function syncCalDavSource(settings: ApiSettings, sourceId: string, payload: CalendarCalDavSyncPayload = {}): Promise<CalendarCalDavSyncResponse> {
return apiFetch<CalendarCalDavSyncResponse>(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
}
export function listCalendarEvents(
settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
@@ -122,6 +300,20 @@ export function listCalendarEvents(
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
}
export function listCalendarEventsDelta(
settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string; since?: string | null; limit?: number } = {}
): Promise<CalendarEventDeltaResponse> {
const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
if (params.start_at) search.set("start_at", params.start_at);
if (params.end_at) search.set("end_at", params.end_at);
if (params.since) search.set("since", params.since);
if (params.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarEventDeltaResponse>(settings, `/api/v1/calendar/events/delta${suffix}`);
}
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
}
File diff suppressed because it is too large Load Diff
@@ -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>
);
}
@@ -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;
}
+380
View File
@@ -0,0 +1,380 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...",
"i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar",
"i18n:govoplan-calendar.add.61cc55aa": "Add",
"i18n:govoplan-calendar.advanced.4d064726": "Advanced",
"i18n:govoplan-calendar.agenda.891e9d6d": "Agenda",
"i18n:govoplan-calendar.all_day.11457433": "All day",
"i18n:govoplan-calendar.attachments_json.d91abf3c": "Attachments JSON",
"i18n:govoplan-calendar.attachments.6771ade6": "Attachments",
"i18n:govoplan-calendar.attendees_json.aeb487bb": "Attendees JSON",
"i18n:govoplan-calendar.attendees.a45a0962": "Attendees",
"i18n:govoplan-calendar.authentication.ee1acfa5": "Authentication",
"i18n:govoplan-calendar.automatic_sync.084644b2": "Automatic sync",
"i18n:govoplan-calendar.basic.aa2c96da": "Basic",
"i18n:govoplan-calendar.bearer_token.ffa64bcf": "Bearer token",
"i18n:govoplan-calendar.caldav_source.459ed16a": "CalDAV source",
"i18n:govoplan-calendar.caldav.64f9720e": "CalDAV",
"i18n:govoplan-calendar.calendar_controls.974f4fa1": "Calendar controls",
"i18n:govoplan-calendar.calendar_navigation.7ba43cd2": "Calendar navigation",
"i18n:govoplan-calendar.calendar_request_failed.ae8a54f4": "Calendar request failed.",
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"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.cancel.77dfd213": "Cancel",
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
"i18n:govoplan-calendar.categories.6ccb6007": "Categories",
"i18n:govoplan-calendar.change_end_time.db66ef4b": "Change end time",
"i18n:govoplan-calendar.change_start_time.06eea3d3": "Change start time",
"i18n:govoplan-calendar.class.41ff354b": "Class",
"i18n:govoplan-calendar.color.1d0c8304": "Color",
"i18n:govoplan-calendar.confidential.84c9cc88": "CONFIDENTIAL",
"i18n:govoplan-calendar.configured.668c5fff": "Configured",
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Day",
"i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287": "Delete events with this calendar",
"i18n:govoplan-calendar.delete.f6fdbe48": "Delete",
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
"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.discover.4827ea22": "Discover",
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
"i18n:govoplan-calendar.display_name.c7874aaa": "Display name",
"i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7": "Duration must be greater than zero.",
"i18n:govoplan-calendar.duration_seconds.19d42eeb": "Duration seconds",
"i18n:govoplan-calendar.duration.1370004d": "Duration",
"i18n:govoplan-calendar.edit_calendar.a47a2a7a": "Edit calendar",
"i18n:govoplan-calendar.edit_event.a7028454": "Edit event",
"i18n:govoplan-calendar.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831": "End date and time must be after start date and time.",
"i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865": "End date must be on or after start date.",
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
"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.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.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_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.exchange_web_services_source.53caabf3": "Exchange Web Services source",
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
"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.9a7405eb": "Graph",
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
"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.local_calendar.ed3f72f8": "Local calendar",
"i18n:govoplan-calendar.local.dc99d54d": "Local",
"i18n:govoplan-calendar.location.d219c681": "Location",
"i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b": "Make target calendar the default",
"i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa": "Managing sync sources requires calendar administration rights.",
"i18n:govoplan-calendar.metadata_json.b0e4c283": "Metadata JSON",
"i18n:govoplan-calendar.metadata.251edc0e": "Metadata",
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
"i18n:govoplan-calendar.mon.24b2a099": "Mon",
"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.name.709a2322": "Name",
"i18n:govoplan-calendar.never.80c3052d": "Never",
"i18n:govoplan-calendar.new_event.2ef3795c": "New event",
"i18n:govoplan-calendar.new.6403f2b7": "New",
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
"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_calendars_are_available.711d2cf3": "No calendars are available.",
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
"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.not_configured.811931bb": "Not configured",
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
"i18n:govoplan-calendar.participants.cd56e083": "Participants",
"i18n:govoplan-calendar.password.8be3c943": "Password",
"i18n:govoplan-calendar.previous.50f94286": "Previous",
"i18n:govoplan-calendar.private.b0b7ba46": "PRIVATE",
"i18n:govoplan-calendar.public.d1785ca2": "PUBLIC",
"i18n:govoplan-calendar.raw.da433cd4": "Raw",
"i18n:govoplan-calendar.rdate_json.5b51fca4": "RDATE JSON",
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Recurrence",
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
"i18n:govoplan-calendar.related.917df91e": "Related",
"i18n:govoplan-calendar.reminders_json.ca25e08f": "Reminders JSON",
"i18n:govoplan-calendar.reminders.ae8c3939": "Reminders",
"i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1": "Remove local events with this calendar",
"i18n:govoplan-calendar.remove.e963907d": "Remove",
"i18n:govoplan-calendar.removing.b7d2c38b": "Removing",
"i18n:govoplan-calendar.replace_password.3f912c9c": "Replace password",
"i18n:govoplan-calendar.replace_token.bbeee6a9": "Replace token",
"i18n:govoplan-calendar.require_matching_etag.0ab1ffe1": "Require matching ETag",
"i18n:govoplan-calendar.rrule.c7b2f8a3": "RRULE",
"i18n:govoplan-calendar.sat.6b782d41": "Sat",
"i18n:govoplan-calendar.save.efc007a3": "Save",
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
"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.source_href.819fa147": "Source href",
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
"i18n:govoplan-calendar.source.6da13add": "Source",
"i18n:govoplan-calendar.start_date.ff99f5b5": "Start date",
"i18n:govoplan-calendar.start_time.88d8206d": "Start time",
"i18n:govoplan-calendar.state.a7250206": "State",
"i18n:govoplan-calendar.status.bae7d5be": "Status",
"i18n:govoplan-calendar.subscription_url.b8b6a1a0": "Subscription URL",
"i18n:govoplan-calendar.sun.48c98cab": "Sun",
"i18n:govoplan-calendar.sync_now.2b7d938e": "Sync now",
"i18n:govoplan-calendar.sync_value.72e4ba66": "Sync {value0}",
"i18n:govoplan-calendar.syncing_value.ca80b487": "Syncing {value0}",
"i18n:govoplan-calendar.syncing.4ae6fa22": "Syncing",
"i18n:govoplan-calendar.syncing.e5c7727a": "Syncing...",
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
"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.timezone.d1f7dc89": "Timezone",
"i18n:govoplan-calendar.title.768e0c1c": "Title",
"i18n:govoplan-calendar.today.24345a14": "Today",
"i18n:govoplan-calendar.transparency.7cb338a9": "Transparency",
"i18n:govoplan-calendar.transparent.ea4efcae": "TRANSPARENT",
"i18n:govoplan-calendar.tue.529541bb": "Tue",
"i18n:govoplan-calendar.two_way.ee50a3e6": "Two-way",
"i18n:govoplan-calendar.uid.d946adf5": "UID",
"i18n:govoplan-calendar.username.84c29015": "Username",
"i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1": "{value0} this calendar will delete {value1} {value2}.",
"i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1": "{value0} this calendar will move {value1} {value2} to {value3}.",
"i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e": "{value0} this calendar will remove {value1} local {value2} from GovOPlaN.",
"i18n:govoplan-calendar.vevent.9cf5be75": "VEVENT",
"i18n:govoplan-calendar.wed.23408b19": "Wed",
"i18n:govoplan-calendar.week.f82be68a": "Week",
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
"i18n:govoplan-calendar.working.049ac820": "Working...",
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
},
"de": {
"i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...",
"i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar",
"i18n:govoplan-calendar.add.61cc55aa": "Hinzufügen",
"i18n:govoplan-calendar.advanced.4d064726": "Advanced",
"i18n:govoplan-calendar.agenda.891e9d6d": "Agenda",
"i18n:govoplan-calendar.all_day.11457433": "Ganztägig",
"i18n:govoplan-calendar.attachments_json.d91abf3c": "Attachments JSON",
"i18n:govoplan-calendar.attachments.6771ade6": "Attachments",
"i18n:govoplan-calendar.attendees_json.aeb487bb": "Attendees JSON",
"i18n:govoplan-calendar.attendees.a45a0962": "Attendees",
"i18n:govoplan-calendar.authentication.ee1acfa5": "Authentication",
"i18n:govoplan-calendar.automatic_sync.084644b2": "Automatic sync",
"i18n:govoplan-calendar.basic.aa2c96da": "Basic",
"i18n:govoplan-calendar.bearer_token.ffa64bcf": "Bearer token",
"i18n:govoplan-calendar.caldav_source.459ed16a": "CalDAV source",
"i18n:govoplan-calendar.caldav.64f9720e": "CalDAV",
"i18n:govoplan-calendar.calendar_controls.974f4fa1": "Calendar controls",
"i18n:govoplan-calendar.calendar_navigation.7ba43cd2": "Calendar navigation",
"i18n:govoplan-calendar.calendar_request_failed.ae8a54f4": "Calendar request failed.",
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"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.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
"i18n:govoplan-calendar.categories.6ccb6007": "Categories",
"i18n:govoplan-calendar.change_end_time.db66ef4b": "Change end time",
"i18n:govoplan-calendar.change_start_time.06eea3d3": "Change start time",
"i18n:govoplan-calendar.class.41ff354b": "Class",
"i18n:govoplan-calendar.color.1d0c8304": "Color",
"i18n:govoplan-calendar.confidential.84c9cc88": "CONFIDENTIAL",
"i18n:govoplan-calendar.configured.668c5fff": "Configured",
"i18n:govoplan-calendar.confirm_delete.c9f2829e": "Confirm delete",
"i18n:govoplan-calendar.confirmed.0542404a": "CONFIRMED",
"i18n:govoplan-calendar.conflict_policy.5810e150": "Conflict policy",
"i18n:govoplan-calendar.continuous.04f2ccda": "Continuous",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Tag",
"i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287": "Delete events with this calendar",
"i18n:govoplan-calendar.delete.f6fdbe48": "Löschen",
"i18n:govoplan-calendar.deleting.2cda36c9": "Deleting",
"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.discover.4827ea22": "Discover",
"i18n:govoplan-calendar.discovering.1884f689": "Discovering...",
"i18n:govoplan-calendar.display_name.c7874aaa": "Anzeigename",
"i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7": "Duration must be greater than zero.",
"i18n:govoplan-calendar.duration_seconds.19d42eeb": "Duration seconds",
"i18n:govoplan-calendar.duration.1370004d": "Duration",
"i18n:govoplan-calendar.edit_calendar.a47a2a7a": "Edit calendar",
"i18n:govoplan-calendar.edit_event.a7028454": "Termin bearbeiten",
"i18n:govoplan-calendar.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831": "End date and time must be after start date and time.",
"i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865": "End date must be on or after start date.",
"i18n:govoplan-calendar.end_date.89d10cd6": "End date",
"i18n:govoplan-calendar.end_mode.5a06de37": "End mode",
"i18n:govoplan-calendar.end_time.cd7800da": "End time",
"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.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.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_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.exchange_web_services_source.53caabf3": "Exchange Web Services source",
"i18n:govoplan-calendar.exchange.5b13eac7": "Exchange",
"i18n:govoplan-calendar.exdate_json.7d0c538d": "EXDATE JSON",
"i18n:govoplan-calendar.fri.bbd6e32e": "Fri",
"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.9a7405eb": "Graph",
"i18n:govoplan-calendar.icalendar_json.fb6cc33e": "iCalendar JSON",
"i18n:govoplan-calendar.icalendar.f388476d": "iCalendar",
"i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63": "ICS/webcal subscription",
"i18n:govoplan-calendar.ics_webcal.9c55b570": "ICS/webcal",
"i18n:govoplan-calendar.identity.7e5a975b": "Identity",
"i18n:govoplan-calendar.inbound_only.bf4269b0": "Inbound only",
"i18n:govoplan-calendar.interval.011efcd5": "Interval",
"i18n:govoplan-calendar.last_attempt.82aee111": "Last attempt",
"i18n:govoplan-calendar.last_sync.ef0ef267": "Last sync",
"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.local_calendar.ed3f72f8": "Local calendar",
"i18n:govoplan-calendar.local.dc99d54d": "Local",
"i18n:govoplan-calendar.location.d219c681": "Ort",
"i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b": "Make target calendar the default",
"i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa": "Managing sync sources requires calendar administration rights.",
"i18n:govoplan-calendar.metadata_json.b0e4c283": "Metadata JSON",
"i18n:govoplan-calendar.metadata.251edc0e": "Metadata",
"i18n:govoplan-calendar.microsoft_graph_source.ec4f1383": "Microsoft Graph source",
"i18n:govoplan-calendar.mon.24b2a099": "Mon",
"i18n:govoplan-calendar.month.082bc378": "Monat",
"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.never.80c3052d": "Never",
"i18n:govoplan-calendar.new_event.2ef3795c": "New event",
"i18n:govoplan-calendar.new.6403f2b7": "New",
"i18n:govoplan-calendar.next_sync.88c7af72": "Next sync",
"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_calendars_are_available.711d2cf3": "Es sind keine Kalender verfügbar.",
"i18n:govoplan-calendar.no_calendars.3a7e4a7a": "No calendars",
"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.not_configured.811931bb": "Nicht konfiguriert",
"i18n:govoplan-calendar.not_scheduled.9c367369": "Not scheduled",
"i18n:govoplan-calendar.not_synced.4c205136": "Not synced",
"i18n:govoplan-calendar.opaque.3e1d0194": "OPAQUE",
"i18n:govoplan-calendar.organizer_json.3add6f9f": "Organizer JSON",
"i18n:govoplan-calendar.organizer.debd1720": "Organizer",
"i18n:govoplan-calendar.overwrite_remote.39625e32": "Overwrite remote",
"i18n:govoplan-calendar.participants.cd56e083": "Participants",
"i18n:govoplan-calendar.password.8be3c943": "Passwort",
"i18n:govoplan-calendar.previous.50f94286": "Previous",
"i18n:govoplan-calendar.private.b0b7ba46": "PRIVATE",
"i18n:govoplan-calendar.public.d1785ca2": "PUBLIC",
"i18n:govoplan-calendar.raw.da433cd4": "Raw",
"i18n:govoplan-calendar.rdate_json.5b51fca4": "RDATE JSON",
"i18n:govoplan-calendar.recurrence_id.e0b780ba": "Recurrence ID",
"i18n:govoplan-calendar.recurrence.f7ad40f5": "Wiederholung",
"i18n:govoplan-calendar.refresh.56e3badc": "Refresh",
"i18n:govoplan-calendar.related_to_json.2d4e8f59": "Related-To JSON",
"i18n:govoplan-calendar.related_to.0e7989ff": "Related-To",
"i18n:govoplan-calendar.related.917df91e": "Related",
"i18n:govoplan-calendar.reminders_json.ca25e08f": "Reminders JSON",
"i18n:govoplan-calendar.reminders.ae8c3939": "Reminders",
"i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1": "Remove local events with this calendar",
"i18n:govoplan-calendar.remove.e963907d": "Remove",
"i18n:govoplan-calendar.removing.b7d2c38b": "Removing",
"i18n:govoplan-calendar.replace_password.3f912c9c": "Replace password",
"i18n:govoplan-calendar.replace_token.bbeee6a9": "Replace token",
"i18n:govoplan-calendar.require_matching_etag.0ab1ffe1": "Require matching ETag",
"i18n:govoplan-calendar.rrule.c7b2f8a3": "RRULE",
"i18n:govoplan-calendar.sat.6b782d41": "Sat",
"i18n:govoplan-calendar.save.efc007a3": "Speichern",
"i18n:govoplan-calendar.saving.ae7e8875": "Saving...",
"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.source_href.819fa147": "Source href",
"i18n:govoplan-calendar.source_kind.7eda9bc4": "Source kind",
"i18n:govoplan-calendar.source.6da13add": "Quelle",
"i18n:govoplan-calendar.start_date.ff99f5b5": "Start date",
"i18n:govoplan-calendar.start_time.88d8206d": "Start time",
"i18n:govoplan-calendar.state.a7250206": "State",
"i18n:govoplan-calendar.status.bae7d5be": "Status",
"i18n:govoplan-calendar.subscription_url.b8b6a1a0": "Subscription URL",
"i18n:govoplan-calendar.sun.48c98cab": "Sun",
"i18n:govoplan-calendar.sync_now.2b7d938e": "Sync now",
"i18n:govoplan-calendar.sync_value.72e4ba66": "Sync {value0}",
"i18n:govoplan-calendar.syncing_value.ca80b487": "Syncing {value0}",
"i18n:govoplan-calendar.syncing.4ae6fa22": "Syncing",
"i18n:govoplan-calendar.syncing.e5c7727a": "Syncing...",
"i18n:govoplan-calendar.target_calendar.e533fe1d": "Target calendar",
"i18n:govoplan-calendar.tentative.d19f9022": "TENTATIVE",
"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.timezone.d1f7dc89": "Timezone",
"i18n:govoplan-calendar.title.768e0c1c": "Title",
"i18n:govoplan-calendar.today.24345a14": "Heute",
"i18n:govoplan-calendar.transparency.7cb338a9": "Transparency",
"i18n:govoplan-calendar.transparent.ea4efcae": "TRANSPARENT",
"i18n:govoplan-calendar.tue.529541bb": "Tue",
"i18n:govoplan-calendar.two_way.ee50a3e6": "Two-way",
"i18n:govoplan-calendar.uid.d946adf5": "UID",
"i18n:govoplan-calendar.username.84c29015": "Benutzername",
"i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1": "{value0} this calendar will delete {value1} {value2}.",
"i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1": "{value0} this calendar will move {value1} {value2} to {value3}.",
"i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e": "{value0} this calendar will remove {value1} local {value2} from GovOPlaN.",
"i18n:govoplan-calendar.vevent.9cf5be75": "VEVENT",
"i18n:govoplan-calendar.wed.23408b19": "Wed",
"i18n:govoplan-calendar.week.f82be68a": "Woche",
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
"i18n:govoplan-calendar.working.049ac820": "Working...",
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
}
};
+2
View File
@@ -1,2 +1,4 @@
export { calendarModule as default, calendarModule } from "./module";
export * from "./api/calendar";
export { default as CalendarPicker } from "./features/calendar/CalendarPicker";
export * from "./features/calendar/calendarPickerLogic";
+17 -5
View File
@@ -1,21 +1,33 @@
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 { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
const eventRead = ["calendar:event:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
export const calendarModule: PlatformWebModule = {
id: "calendar",
label: "Calendar",
label: "i18n:govoplan-calendar.calendar.adab5090",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
navItems: [{ to: "/calendar", label: "Calendar", iconName: "calendar", anyOf: eventRead, order: 55 }],
translations,
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
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
}
};
export default calendarModule;
+303 -174
View File
@@ -17,13 +17,18 @@
box-sizing: border-box;
}
.calendar-loading-frame {
min-height: 0;
height: 100%;
}
.calendar-shell {
min-height: 0;
height: 100%;
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 0;
background: var(--panel);
}
@@ -46,7 +51,7 @@
align-items: center;
gap: 14px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
background: var(--panel-header);
}
@@ -84,76 +89,15 @@
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 {
flex: 0 1 520px;
display: inline-grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
width: min(520px, 100%);
max-width: 520px;
overflow: hidden;
border: 1px solid #c9c3b9;
border-radius: 8px;
background: #c9c3b9;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
}
.calendar-mode-tab {
.calendar-mode-switch .segmented-control-option {
min-height: 34px;
border: 0;
border-radius: 0;
background: linear-gradient(#ffffff, #f1efeb);
color: #4f4a43;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 800;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
transition: background .18s ease, box-shadow .18s ease, color .18s ease;
}
.calendar-mode-tab + .calendar-mode-tab {
border-left: 1px solid #c9c3b9;
}
.calendar-mode-tab:first-child {
border-radius: 7px 0 0 7px;
}
.calendar-mode-tab:last-child {
border-radius: 0 7px 7px 0;
}
.calendar-mode-tab:hover,
.calendar-mode-tab:focus-visible {
background: linear-gradient(#ffffff, #e8e5df);
color: #4f4a43;
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
}
.calendar-mode-tab:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22);
outline-offset: -1px;
}
.calendar-mode-tab.is-active {
background: var(--line);
color: var(--text-strong);
box-shadow: inset 0 2px 5px rgba(0,0,0,.12), inset 0 -1px 0 rgba(255,255,255,.45);
}
.calendar-sidebar {
@@ -162,14 +106,14 @@
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--line);
border-right: var(--border-line);
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
}
.calendar-sidebar-heading {
flex: 0 0 auto;
padding: 13px 14px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
color: var(--muted);
font-size: 12px;
font-weight: 800;
@@ -178,7 +122,7 @@
}
.calendar-sidebar-heading.is-secondary {
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.calendar-list,
@@ -216,7 +160,7 @@
.calendar-list-row {
min-width: 0;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 30px;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 4px;
min-height: 36px;
@@ -230,6 +174,10 @@
background: var(--panel-soft);
}
.calendar-list-row.is-syncing {
background: var(--panel-soft);
}
.calendar-visibility-switch {
--calendar-list-color: var(--green);
width: 34px;
@@ -238,9 +186,9 @@
display: inline-flex;
align-items: center;
padding: 2px;
border: 1px solid #c9c3b9;
border: 1px solid var(--control-border);
border-radius: 999px;
background: #d8d3cb;
background: var(--calendar-switch-bg);
cursor: pointer;
transition: background .16s ease, border-color .16s ease;
}
@@ -254,8 +202,8 @@
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, .18);
background: var(--surface);
box-shadow: var(--shadow-thumb);
transform: translateX(0);
transition: transform .16s ease;
}
@@ -286,64 +234,36 @@
outline: none;
}
.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-list-actions {
width: auto;
}
.calendar-row-icon-button.btn:hover,
.calendar-row-icon-button.btn:focus-visible {
border-color: var(--line-dark);
background: var(--surface);
opacity: 1;
.calendar-sync-spin {
animation: calendar-sync-spin .85s linear infinite;
}
.calendar-create-form input[type="color"] {
width: 24px;
min-width: 24px;
height: 24px;
padding: 0;
overflow: hidden;
border: 1px solid var(--line-dark);
border-radius: 4px;
background: transparent;
cursor: pointer;
@keyframes calendar-sync-spin {
to {
transform: rotate(360deg);
}
}
.calendar-create-form input:disabled {
opacity: .55;
cursor: not-allowed;
@media (prefers-reduced-motion: reduce) {
.calendar-sync-spin {
animation: none;
}
}
.calendar-create-form {
.calendar-create-action {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 28px 36px;
align-items: center;
gap: 6px;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.calendar-create-form input[type="text"],
.calendar-create-form input:not([type]) {
min-width: 0;
height: 34px;
border: 1px solid var(--line-dark);
border-radius: 4px;
background: var(--surface);
color: var(--text);
font: inherit;
padding: 6px 8px;
.calendar-create-action .btn {
width: 100%;
justify-content: center;
}
.calendar-agenda {
@@ -363,7 +283,7 @@
.calendar-agenda-group + .calendar-agenda-group {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.calendar-agenda-group h3 {
@@ -378,15 +298,15 @@
.calendar-agenda-item {
--calendar-event-color: var(--green);
--calendar-event-bg: #edf8f5;
--calendar-event-border: #b9d7d0;
--calendar-event-bg: var(--calendar-event-bg-default);
--calendar-event-border: var(--calendar-event-border-default);
display: block;
min-height: 28px;
padding: 5px 7px;
border: 1px solid var(--calendar-event-border);
border-left: 4px solid var(--calendar-event-color, var(--green));
background: var(--calendar-event-bg);
color: #21453e;
color: var(--calendar-event-text);
}
.calendar-agenda-item strong {
@@ -401,7 +321,7 @@
.calendar-agenda-item .calendar-event-separator,
.calendar-agenda p {
margin: 0;
color: #4d6764;
color: var(--calendar-event-muted-text);
font-size: 12px;
}
@@ -433,9 +353,9 @@
inset: 0 auto auto 0;
z-index: 4;
padding: 10px;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
border-right: var(--border-line);
border-bottom: var(--border-line);
background: var(--panel-glass-bright);
}
.calendar-week-rows {
@@ -471,7 +391,7 @@
top: 0;
z-index: 2;
flex: 0 0 auto;
border-bottom: 1px solid var(--line-dark);
border-bottom: var(--border-line-dark);
background: var(--panel);
}
@@ -491,7 +411,7 @@
.calendar-week-row {
min-height: 132px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.calendar-week-rows.is-continuous .calendar-week-row {
@@ -508,22 +428,22 @@
flex-direction: column;
gap: 6px;
padding: 8px;
border-right: 1px solid var(--line);
border-right: var(--border-line);
background: var(--surface);
overflow: hidden;
}
.calendar-day-cell.is-even-month {
background: #ffffff;
background: var(--surface);
}
.calendar-day-cell.is-odd-month {
background: #f8f6f2;
background: var(--calendar-grid-bg);
}
.calendar-day-cell.is-muted {
color: #8a8278;
background: #f1efeb;
color: var(--muted);
background: var(--control-gradient-end-muted);
}
.calendar-day-cell.is-today {
@@ -531,7 +451,7 @@
}
.calendar-day-cell.is-drop-target {
background: #e3f3ef;
background: var(--calendar-today-bg);
box-shadow: inset 0 0 0 2px var(--green);
}
@@ -567,8 +487,8 @@
.calendar-event-chip {
--calendar-event-color: var(--green);
--calendar-event-bg: #edf8f5;
--calendar-event-border: #b9d7d0;
--calendar-event-bg: var(--calendar-event-bg-default);
--calendar-event-border: var(--calendar-event-border-default);
min-width: 0;
min-height: 26px;
display: block;
@@ -577,7 +497,7 @@
border-left: 4px solid var(--calendar-event-color);
border-radius: 4px;
background: var(--calendar-event-bg);
color: #21453e;
color: var(--calendar-event-text);
cursor: pointer;
font: inherit;
font-size: 12px;
@@ -629,7 +549,7 @@
.calendar-event-time,
.calendar-event-separator {
flex: 0 0 auto;
color: #4d6764;
color: var(--calendar-event-muted-text);
}
.calendar-more-events {
@@ -667,8 +587,8 @@
.calendar-time-corner,
.calendar-all-day-label,
.calendar-all-day-cell {
border-bottom: 1px solid var(--line-dark);
border-right: 1px solid var(--line);
border-bottom: var(--border-line-dark);
border-right: var(--border-line);
background: var(--panel);
}
@@ -681,12 +601,12 @@
}
.calendar-time-header > header.is-today {
background: #e0f0ea;
background: var(--calendar-selected-bg);
}
.calendar-time-header > header.is-weekend,
.calendar-all-day-cell.is-weekend {
background: #f1efeb;
background: var(--control-gradient-end-muted);
}
.calendar-time-header > header span {
@@ -697,7 +617,7 @@
.calendar-all-day-strip {
flex: 0 0 auto;
min-height: 42px;
border-bottom: 1px solid var(--line-dark);
border-bottom: var(--border-line-dark);
}
.calendar-all-day-label {
@@ -715,7 +635,7 @@
}
.calendar-all-day-cell.is-drop-target {
background: #e3f3ef;
background: var(--calendar-today-bg);
box-shadow: inset 0 0 0 2px var(--green);
}
@@ -744,8 +664,8 @@
.calendar-hour-label {
min-height: 64px;
border-bottom: 1px solid var(--line);
border-right: 1px solid var(--line);
border-bottom: var(--border-line);
border-right: var(--border-line);
padding: 8px;
color: var(--muted);
font-size: 12px;
@@ -756,17 +676,17 @@
position: relative;
height: 1536px;
min-height: 1536px;
border-right: 1px solid var(--line);
border-right: var(--border-line);
background:
linear-gradient(to bottom, var(--calendar-day-shade), var(--calendar-day-shade)),
linear-gradient(
to bottom,
rgba(241, 239, 235, 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)) 0,
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-end-y),
rgba(241, 239, 235, 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)) var(--calendar-work-end-y),
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 100%
),
repeating-linear-gradient(
to bottom,
@@ -778,11 +698,11 @@
}
.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 {
box-shadow: inset 0 0 0 2px rgba(90, 169, 155, .55);
box-shadow: var(--calendar-focus-inset);
}
.calendar-time-drop-marker {
@@ -810,10 +730,10 @@
top: -13px;
left: 10px;
padding: 2px 6px;
border: 1px solid #4f9489;
border: 1px solid var(--calendar-success-border);
border-radius: 4px;
background: var(--green);
color: #fff;
color: var(--on-accent);
font-size: 11px;
font-weight: 800;
line-height: 1.2;
@@ -831,15 +751,15 @@
.calendar-timed-event {
--calendar-event-color: var(--green);
--calendar-event-bg: #edf8f5;
--calendar-event-border: #b9d7d0;
--calendar-event-bg: var(--calendar-event-bg-default);
--calendar-event-border: var(--calendar-event-border-default);
display: block;
padding: 0;
overflow: hidden;
border: 1px solid var(--calendar-event-border);
border-left: 4px solid var(--calendar-event-color);
background: var(--calendar-event-bg);
color: #21453e;
color: var(--calendar-event-text);
cursor: pointer;
font-size: 12px;
}
@@ -849,8 +769,8 @@
.calendar-timed-event.is-linked-hover,
.calendar-timed-overflow:hover,
.calendar-timed-overflow:focus-visible {
border-color: var(--calendar-event-color, #5aa99b);
background: var(--calendar-event-bg, #e3f3ef);
border-color: var(--calendar-event-color, var(--success-border));
background: var(--calendar-event-bg, var(--calendar-today-bg));
outline: none;
}
@@ -869,7 +789,7 @@
}
.calendar-timed-event-content:focus-visible {
outline: 2px solid rgba(90, 169, 155, .35);
outline: var(--calendar-focus-outline);
outline-offset: -2px;
}
@@ -896,7 +816,7 @@
width: 34px;
height: 2px;
border-radius: 999px;
background: rgba(33, 69, 62, .46);
background: var(--calendar-overlay);
content: "";
opacity: 0;
transform: translateX(-50%);
@@ -922,7 +842,7 @@
height: 28px;
display: grid;
place-items: center;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
background: var(--panel-soft);
color: var(--text-strong);
cursor: pointer;
@@ -931,7 +851,11 @@
}
.calendar-event-dialog {
width: min(560px, 100%);
width: min(680px, 100%);
}
.calendar-vevent-dialog {
width: min(920px, 100%);
}
.calendar-dialog-form {
@@ -974,22 +898,213 @@
.calendar-dialog-name-color-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
grid-template-columns: minmax(0, 1fr) 142px;
gap: 12px;
}
.calendar-dialog-color-label input[type="color"] {
.calendar-source-switch {
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
width: 100%;
}
.calendar-source-switch .segmented-control-option {
min-height: 34px;
}
.calendar-source-pane {
display: grid;
gap: 12px;
padding: 12px;
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
}
.calendar-source-pane h3 {
margin: 0;
color: var(--text-strong);
font-size: 14px;
}
.calendar-source-pane-heading,
.calendar-sync-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.calendar-dialog-grid-two,
.calendar-dialog-grid-three,
.calendar-caldav-setup,
.calendar-sync-settings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.calendar-dialog-grid-three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.calendar-dialog-wide {
grid-column: 1 / -1;
}
.calendar-caldav-setup {
align-items: end;
}
.calendar-discovery-actions {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 3px;
}
.calendar-discovery-actions .btn {
flex: 0 0 auto;
}
.calendar-discovery-actions span {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-discovery-actions.is-readonly {
justify-content: flex-start;
}
.calendar-advanced-settings {
display: grid;
gap: 12px;
}
.calendar-advanced-settings summary {
width: fit-content;
cursor: pointer;
color: var(--text-strong);
font-size: 13px;
font-weight: 800;
}
.calendar-advanced-settings[open] {
gap: 12px;
}
.calendar-advanced-settings[open] summary {
margin-bottom: 12px;
}
.calendar-vevent-details {
border-top: var(--border-line);
padding-top: 4px;
}
.calendar-vevent-section {
display: grid;
gap: 10px;
padding: 12px 0;
border-top: var(--border-line);
}
.calendar-vevent-section:first-of-type {
border-top: 0;
padding-top: 0;
}
.calendar-vevent-section h3 {
margin: 0;
color: var(--text-strong);
font-size: 13px;
}
.calendar-vevent-section textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.45;
}
.calendar-sync-settings {
align-items: end;
}
.calendar-sync-status {
padding: 3px 8px;
border: var(--border-line-dark);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.calendar-sync-status.is-error {
border-color: var(--calendar-danger-border);
background: var(--calendar-danger-bg);
color: var(--red);
}
.calendar-sync-status.is-syncing {
border-color: var(--green);
background: var(--surface);
color: var(--green);
}
.calendar-sync-button.btn.is-syncing,
.calendar-sync-button.btn.is-syncing:disabled {
opacity: 1;
cursor: progress;
}
.calendar-sync-status-panel {
display: grid;
gap: 10px;
padding: 10px;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
}
.calendar-sync-status-panel dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 14px;
margin: 0;
}
.calendar-sync-status-panel dl div {
min-width: 0;
}
.calendar-sync-status-panel dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.calendar-sync-status-panel dd {
margin: 2px 0 0;
overflow: hidden;
color: var(--text);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-form-error {
margin: 0;
padding: 9px 10px;
border: 1px solid #f0b8b2;
border: 1px solid var(--calendar-danger-border);
border-radius: 4px;
background: #fff2f0;
background: var(--calendar-danger-bg);
color: var(--red);
font-size: 13px;
}
@@ -1003,13 +1118,13 @@
}
.calendar-delete-warning {
border: 1px solid #f0b8b2;
background: #fff2f0;
border: 1px solid var(--calendar-danger-border);
background: var(--calendar-danger-bg);
color: var(--red);
}
.calendar-form-note {
border: 1px solid var(--line);
border: var(--border-line);
background: var(--panel-soft);
color: var(--muted);
}
@@ -1019,7 +1134,7 @@
gap: 10px;
margin: 0;
padding: 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
}
@@ -1056,6 +1171,15 @@
color: var(--muted);
}
.calendar-delete-dialog {
width: min(520px, 100%);
}
.calendar-delete-dialog-body {
display: grid;
gap: 12px;
}
.calendar-event-dialog-footer {
justify-content: space-between;
gap: 12px;
@@ -1103,7 +1227,7 @@
.calendar-sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.calendar-mode-switch {
@@ -1114,7 +1238,12 @@
.calendar-dialog-row,
.calendar-dialog-date-row,
.calendar-dialog-name-color-row {
.calendar-dialog-name-color-row,
.calendar-dialog-grid-two,
.calendar-dialog-grid-three,
.calendar-caldav-setup,
.calendar-sync-settings,
.calendar-sync-status-panel dl {
grid-template-columns: 1fr;
}
}
@@ -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.");
+37
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.");
+18
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"
]
}