Compare commits
28
Commits
v0.1.5
...
041e2159e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
041e2159e7 | ||
|
|
d64de50802 | ||
|
|
7843fe88e5 | ||
|
|
39b199d7e6 | ||
|
|
74495e56cb | ||
|
|
6ca1816c95 | ||
|
|
2d7d105ba3 | ||
|
|
41b9426670 | ||
|
|
baf624c7a6 | ||
|
|
d10570fc0c | ||
|
|
30f6d79f9e | ||
|
|
760a87ab99 | ||
|
|
7098751f7f | ||
|
|
1df2a1a730 | ||
|
|
a52e35b52b | ||
|
|
a36a9e8c19 | ||
|
|
68b165db7b | ||
|
|
2ad6e9d60e | ||
|
|
a6cd64e3f9 | ||
|
|
371a00aff5 | ||
|
|
c071235ad7 | ||
|
|
9bcf41bb1f | ||
|
|
39d2c28ab1 | ||
|
|
9fcb812ddb | ||
|
|
70bd067001 | ||
|
|
15aa4f3df3 | ||
|
|
15d5613f9b | ||
|
|
c3c867391c |
+268
@@ -8,3 +8,271 @@ __pycache__/
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
webui/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/
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# govoplan-calendar
|
# govoplan-calendar
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (domain).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware.
|
GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
@@ -36,9 +40,21 @@ The first backend implementation stores VEVENT data in two layers:
|
|||||||
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
|
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
|
||||||
records the remote collection URL, sync token, ETag/ctag state, username,
|
records the remote collection URL, sync token, ETag/ctag state, username,
|
||||||
credential reference, sync interval, sync direction, and conflict policy.
|
credential reference, sync interval, sync direction, and conflict policy.
|
||||||
Credentials can be supplied transiently for manual sync, referenced from
|
Credentials can be supplied transiently for manual sync. Persisted API-managed
|
||||||
environment variables with `env:NAME`, stored through a platform secret provider
|
sources accept only a password or token: Calendar stores an opaque, tenant- and
|
||||||
when one is available, or stored as encrypted calendar-owned credentials.
|
source-bound local reference, backed by the platform secret provider when one is
|
||||||
|
available or by an encrypted calendar-owned credential otherwise. Caller-selected
|
||||||
|
environment and external-provider references are rejected. Trusted deployment
|
||||||
|
code may resolve an `env:NAME` reference only through the separate deployment
|
||||||
|
configuration helper.
|
||||||
|
|
||||||
|
Deleting a source or its calendar immediately scrubs Calendar-owned ciphertext
|
||||||
|
and provider references and emits non-secret audit evidence. If an external
|
||||||
|
secret provider cannot confirm deletion, the operation fails closed without
|
||||||
|
retiring the source or cancelling its queued work; retry is idempotent after a
|
||||||
|
database rollback. Destructive module retirement first deletes and audits all
|
||||||
|
active and legacy retained provider secrets and stops before table removal on
|
||||||
|
provider failure.
|
||||||
|
|
||||||
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
|
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
|
||||||
when a sync token exists. It imports all VEVENT components in a resource and
|
when a sync token exists. It imports all VEVENT components in a resource and
|
||||||
|
|||||||
@@ -24,10 +24,94 @@ The first standalone module provides:
|
|||||||
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
- API endpoints for listing calendars, creating/updating/deleting events, importing iCalendar, and exporting event ICS
|
||||||
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
- free/busy API primitive with recurrence expansion for scheduling and appointment conflict checks
|
||||||
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
- calendar-owned CalDAV sync sources with credential references, scheduled due-sync metadata, full/incremental inbound sync, two-way PUT/DELETE writes, and ETag conflict handling
|
||||||
|
- a Calendar-owned durable desired-state outbox for two-way CalDAV writes. Event
|
||||||
|
state and the exact external resource snapshot commit atomically; workers use
|
||||||
|
deterministic hrefs, conditional requests, expiring leases, bounded
|
||||||
|
exponential retry, and semantic GET reconciliation after ambiguous outcomes
|
||||||
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
- WebUI views: month, week, workweek, day, and continuous week-row scrolling
|
||||||
|
|
||||||
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
|
The first implementation is not yet a full CalDAV network server. It is the internal calendar storage, sync, availability, and UI foundation on which Open-Xchange integration, richer recurrence editing, scheduling inbox/outbox behavior, and CalDAV server endpoints can be built.
|
||||||
|
|
||||||
|
## Outbound delivery operations
|
||||||
|
|
||||||
|
No event API or cross-module capability performs CalDAV network I/O inside the
|
||||||
|
caller's transaction. A local mutation instead creates a
|
||||||
|
`calendar_outbox_operations` row containing the exact desired ICS resource,
|
||||||
|
target href, expected ETag, conflict-policy snapshot, and idempotency key.
|
||||||
|
|
||||||
|
The registered `govoplan.calendar.dispatch_outbox` worker commits an expiring
|
||||||
|
lease before doing network I/O and commits each outcome independently. If a
|
||||||
|
worker loses the database connection after a successful remote write, the next
|
||||||
|
attempt reads the remote object and compares semantic ICS fingerprints. A
|
||||||
|
matching PUT or an already absent DELETE completes successfully without a blind
|
||||||
|
duplicate write. Pending updates for one href supersede older never-attempted
|
||||||
|
rows; attempted predecessors are reconciled first, and updates queued behind an
|
||||||
|
in-flight write inherit the ETag produced by that write. Delivery and inbound
|
||||||
|
REPORT application take the same source-row lock,
|
||||||
|
and only one operation per source is leased at a time. This deliberately trades
|
||||||
|
per-source throughput for a simple ordering guarantee: a stale inbound report
|
||||||
|
cannot land after a newer outbound result, and queued leases do not expire while
|
||||||
|
waiting behind another network request for the same source.
|
||||||
|
|
||||||
|
Operators can list, dispatch, retry, reconcile, and discard tenant operations
|
||||||
|
through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag
|
||||||
|
conflicts and exhausted retries remain the current local desired state and
|
||||||
|
shield that resource from inbound overwrite until explicitly resolved.
|
||||||
|
Discarding is the administrator's accept-remote transition: it is allowed only
|
||||||
|
for the latest generation, atomically cancels its unresolved predecessor chain,
|
||||||
|
marks the event projection as discarded, clears the sync token, and schedules a
|
||||||
|
full inbound reconciliation so an unchanged remote object is not missed.
|
||||||
|
|
||||||
|
Disabling outbound delivery or switching to inbound-only is rejected while
|
||||||
|
unresolved desired state exists; retirement is the explicit exception and
|
||||||
|
cancels unresolved work. Endpoint/calendar changes also require no active event
|
||||||
|
bindings or unresolved delivery. Credential rotation does not discard committed
|
||||||
|
desired state. Public event mutation cannot set sync-owned source hrefs, kinds,
|
||||||
|
or ETags. Deleting a synchronized collection or retiring its source is a local
|
||||||
|
unlink: it never deletes the remote collection or its remaining remote events.
|
||||||
|
The unlink immediately scrubs Calendar-owned credential ciphertext and external
|
||||||
|
provider references and audits the deletion. External provider failure blocks
|
||||||
|
retirement before queued work is changed; a later retry tolerates a provider
|
||||||
|
secret already removed by an earlier attempt whose database transaction rolled
|
||||||
|
back. Provider errors and audit details never contain credential values or
|
||||||
|
secret references.
|
||||||
|
The current singular ownership model permits one active sync source per
|
||||||
|
calendar; multi-source fan-in will require per-event source routing. Celery beat
|
||||||
|
triggers recovery every minute, while root-transaction after-commit dispatch
|
||||||
|
provides the normal low-latency path.
|
||||||
|
|
||||||
|
### Collection retirement and bulk event moves
|
||||||
|
|
||||||
|
Collection deletion accepts two explicit, non-destructive external actions for
|
||||||
|
`event_action="move"`:
|
||||||
|
|
||||||
|
- `detach_keep_remote` applies only when the source collection is synchronized
|
||||||
|
and the target is local. It moves the local event projections, clears their
|
||||||
|
sync-owned source kind, href, ETag, and CalDAV projection metadata, retires
|
||||||
|
the source locally, and cancels undelivered outbox state. An active delivery
|
||||||
|
lease blocks the transaction. The action never enqueues a remote DELETE, so
|
||||||
|
the remote collection and remote events remain unchanged.
|
||||||
|
- `copy_to_remote` applies only when the source is local and the target has an
|
||||||
|
active, enabled, two-way CalDAV source. It moves the local events and commits
|
||||||
|
destination PUT desired states in the durable outbox in the same database
|
||||||
|
transaction. Remote failures therefore remain visible and retryable without
|
||||||
|
rolling back or hiding the local move.
|
||||||
|
|
||||||
|
Local-to-local moves retain their existing behavior and do not accept an
|
||||||
|
`external_action`. Implicit or mismatched actions, inbound-only destinations,
|
||||||
|
and synchronized-to-synchronized moves are rejected. `remote_move` is a known
|
||||||
|
but deliberately unsupported action: implementing it requires a separately
|
||||||
|
approved saga for destination reconciliation, conditional source deletion,
|
||||||
|
collision handling, and concurrent-edit policy. The WebUI does not offer that
|
||||||
|
destructive mode.
|
||||||
|
|
||||||
|
Resolved terminal rows (`succeeded`, `superseded`, and `cancelled`) are removed
|
||||||
|
in bounded batches after `CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS` (90 days by
|
||||||
|
default; `0` disables cleanup). `conflict` and `dead` rows are never removed by
|
||||||
|
this cleanup because they still carry unresolved local desired state. Each
|
||||||
|
periodic or after-commit dispatch also performs one bounded cleanup batch for
|
||||||
|
the same tenant scope.
|
||||||
|
|
||||||
## Integration Points
|
## Integration Points
|
||||||
|
|
||||||
### Scheduling
|
### Scheduling
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.5",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.5",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
|||||||
+4
-3
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-calendar"
|
name = "govoplan-calendar"
|
||||||
version = "0.1.5"
|
version = "0.1.8"
|
||||||
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.5",
|
"govoplan-core>=0.1.9",
|
||||||
"govoplan-access>=0.1.5",
|
"govoplan-access>=0.1.8",
|
||||||
|
"defusedxml>=0.7,<1",
|
||||||
"icalendar>=7.2",
|
"icalendar>=7.2",
|
||||||
"python-dateutil>=2.9",
|
"python-dateutil>=2.9",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.8"
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import posixpath
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Mapping, Protocol
|
from typing import Any, Mapping, Protocol
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
from defusedxml import ElementTree as SafeElementTree
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
bounded_response_bytes,
|
||||||
|
build_outbound_http_opener,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalDAVError(RuntimeError):
|
class CalDAVError(RuntimeError):
|
||||||
@@ -21,6 +29,10 @@ class CalDAVPreconditionFailed(CalDAVError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalDAVNotFound(CalDAVError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CalDAVTransport(Protocol):
|
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]:
|
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
|
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:
|
class CalDAVClient:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -78,6 +125,96 @@ class CalDAVClient:
|
|||||||
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
|
payload = self.request("PROPFIND", self.collection_url, body=body, depth="0", expected={207})
|
||||||
return parse_multistatus(payload)
|
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:
|
def list_objects(self) -> CalDAVReportResult:
|
||||||
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
body = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
@@ -111,8 +248,23 @@ class CalDAVClient:
|
|||||||
return parse_multistatus(payload)
|
return parse_multistatus(payload)
|
||||||
|
|
||||||
def fetch_object(self, href: str) -> str:
|
def fetch_object(self, href: str) -> str:
|
||||||
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
|
return self.fetch_object_state(href).calendar_data or ""
|
||||||
return payload.decode("utf-8")
|
|
||||||
|
def fetch_object_state(self, href: str) -> CalDAVObject:
|
||||||
|
"""Fetch a resource together with its ETag for outbox reconciliation."""
|
||||||
|
|
||||||
|
_status, headers, payload = self.request_raw(
|
||||||
|
"GET",
|
||||||
|
self.object_url(href),
|
||||||
|
body=None,
|
||||||
|
depth=None,
|
||||||
|
expected={200},
|
||||||
|
)
|
||||||
|
return CalDAVObject(
|
||||||
|
href=href,
|
||||||
|
etag=response_etag(headers),
|
||||||
|
calendar_data=payload.decode("utf-8"),
|
||||||
|
)
|
||||||
|
|
||||||
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
|
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
|
||||||
headers = {"Content-Type": "text/calendar; charset=utf-8"}
|
headers = {"Content-Type": "text/calendar; charset=utf-8"}
|
||||||
@@ -149,7 +301,21 @@ class CalDAVClient:
|
|||||||
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
|
||||||
|
|
||||||
def object_url(self, href: str) -> str:
|
def object_url(self, href: str) -> str:
|
||||||
return urllib.parse.urljoin(self.collection_url, href)
|
candidate = same_origin_dav_url(
|
||||||
|
self.collection_url,
|
||||||
|
href,
|
||||||
|
label="CalDAV object href",
|
||||||
|
)
|
||||||
|
collection_parts = urllib.parse.urlparse(self.collection_url)
|
||||||
|
candidate_parts = urllib.parse.urlparse(candidate)
|
||||||
|
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
|
||||||
|
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
|
||||||
|
collection_prefix = collection_path.rstrip("/") + "/"
|
||||||
|
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
|
||||||
|
raise CalDAVError("CalDAV object href must remain inside the configured collection path")
|
||||||
|
if candidate_parts.query or candidate_parts.fragment:
|
||||||
|
raise CalDAVError("CalDAV object href must not contain a query or fragment")
|
||||||
|
return urllib.parse.urlunparse(candidate_parts)
|
||||||
|
|
||||||
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
|
||||||
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
|
||||||
@@ -182,6 +348,8 @@ class CalDAVClient:
|
|||||||
headers.update(dict(extra_headers))
|
headers.update(dict(extra_headers))
|
||||||
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
|
status, response_headers, payload = self.transport(method, url, headers, body, self.timeout)
|
||||||
if status not in expected:
|
if status not in expected:
|
||||||
|
if status == 404:
|
||||||
|
raise CalDAVNotFound(f"{method} {url} returned HTTP {status}")
|
||||||
if status == 412:
|
if status == 412:
|
||||||
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
|
raise CalDAVPreconditionFailed(f"{method} {url} failed because the remote resource changed")
|
||||||
raise CalDAVError(f"{method} {url} returned HTTP {status}")
|
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]:
|
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:
|
try:
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
url = validate_outbound_http_url(url, label="CalDAV URL")
|
||||||
return response.status, dict(response.headers.items()), response.read()
|
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:
|
except urllib.error.HTTPError as exc:
|
||||||
return exc.code, dict(exc.headers.items()), exc.read()
|
response_headers = dict(exc.headers.items())
|
||||||
|
try:
|
||||||
|
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
|
||||||
|
except OutboundHttpError as policy_exc:
|
||||||
|
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
|
||||||
|
return exc.code, response_headers, payload
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||||
|
except (OutboundHttpError, ValueError) as exc:
|
||||||
|
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
|
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
|
||||||
try:
|
try:
|
||||||
root = ElementTree.fromstring(payload)
|
root = SafeElementTree.fromstring(payload)
|
||||||
except ElementTree.ParseError as exc:
|
except SafeElementTree.ParseError as exc:
|
||||||
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
|
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
|
||||||
objects: list[CalDAVObject] = []
|
objects: list[CalDAVObject] = []
|
||||||
sync_token = first_child_text(root, "sync-token")
|
sync_token = first_child_text(root, "sync-token")
|
||||||
@@ -232,8 +420,169 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult:
|
|||||||
return CalDAVReportResult(objects=objects, sync_token=sync_token, ctag=ctag)
|
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:
|
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:
|
def strip_weak_etag(value: str | None) -> str | None:
|
||||||
@@ -253,23 +602,31 @@ def xml_escape(value: str) -> str:
|
|||||||
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
|
def first_child(element: Any, name: str) -> Any | None:
|
||||||
for child in element:
|
for child in element:
|
||||||
if local_name(child.tag) == name:
|
if local_name(child.tag) == name:
|
||||||
return child
|
return child
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def first_child_text(element: ElementTree.Element, name: str) -> str | None:
|
def first_child_text(element: Any, name: str) -> str | None:
|
||||||
found = first_child(element, name)
|
found = first_child(element, name)
|
||||||
if found is None or found.text is None:
|
if found is None or found.text is None:
|
||||||
return None
|
return None
|
||||||
return found.text.strip()
|
return found.text.strip()
|
||||||
|
|
||||||
|
|
||||||
def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]:
|
def child_elements(element: Any, name: str) -> list[Any]:
|
||||||
return [child for child in element if local_name(child.tag) == name]
|
return [child for child in element if local_name(child.tag) == name]
|
||||||
|
|
||||||
|
|
||||||
|
def nested_href_texts(element: 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:
|
def local_name(tag: str) -> str:
|
||||||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
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,
|
||||||
|
)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
|
||||||
@@ -29,7 +29,7 @@ class CalendarCollection(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
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)
|
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, 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)
|
etag: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||||
icalendar: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
icalendar: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
raw_ics: Mapped[str | None] = mapped_column(Text)
|
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)
|
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("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)
|
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)
|
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)
|
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)
|
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)
|
source_kind: Mapped[str] = mapped_column(String(30), default="caldav", nullable=False, index=True)
|
||||||
collection_url: Mapped[str] = mapped_column(String(1000), nullable=False)
|
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)
|
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)
|
credential_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||||
label: Mapped[str | None] = mapped_column(String(255))
|
label: Mapped[str | None] = mapped_column(String(255))
|
||||||
secret_encrypted: Mapped[str | None] = mapped_column(Text)
|
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)
|
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)
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ def parse_vevent_component(calendar: Calendar, event: Event, *, raw_ics: str) ->
|
|||||||
properties = component_property_records(event)
|
properties = component_property_records(event)
|
||||||
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
|
alarms = [component_alarm_record(alarm) for alarm in event.subcomponents if alarm.name == "VALARM"]
|
||||||
standard = standard_property_index(properties)
|
standard = standard_property_index(properties)
|
||||||
|
if dtend is None and duration_seconds is not None:
|
||||||
|
standard["uses_duration"] = True
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
@@ -151,32 +153,58 @@ def events_to_ics(events: list[Any]) -> str:
|
|||||||
|
|
||||||
def event_to_component(event: Any) -> Event:
|
def event_to_component(event: Any) -> Event:
|
||||||
component = Event()
|
component = Event()
|
||||||
|
add_event_identity_and_time(component, event)
|
||||||
|
add_event_status_fields(component, event)
|
||||||
|
add_event_optional_text_fields(component, event)
|
||||||
|
add_event_recurrence_and_parties(component, event)
|
||||||
|
add_event_raw_records(component, event)
|
||||||
|
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
|
||||||
|
for alarm in alarms_for_export(event):
|
||||||
|
component.add_component(alarm)
|
||||||
|
return component
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_identity_and_time(component: Event, event: Any) -> None:
|
||||||
component.add("uid", event.uid)
|
component.add("uid", event.uid)
|
||||||
component.add("dtstamp", datetime.now(timezone.utc))
|
component.add("dtstamp", datetime.now(timezone.utc))
|
||||||
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
|
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
|
||||||
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))
|
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:
|
elif getattr(event, "duration_seconds", None) is not None:
|
||||||
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
|
||||||
if getattr(event, "recurrence_id", None):
|
if getattr(event, "recurrence_id", None):
|
||||||
add_recurrence_id(component, str(event.recurrence_id))
|
add_recurrence_id(component, str(event.recurrence_id))
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_status_fields(component: Event, event: Any) -> None:
|
||||||
component.add("summary", event.summary)
|
component.add("summary", event.summary)
|
||||||
component.add("sequence", int(event.sequence or 0))
|
component.add("sequence", int(event.sequence or 0))
|
||||||
component.add("status", event.status)
|
component.add("status", event.status)
|
||||||
component.add("transp", event.transparency)
|
component.add("transp", event.transparency)
|
||||||
component.add("class", event.classification)
|
component.add("class", event.classification)
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_optional_text_fields(component: Event, event: Any) -> None:
|
||||||
if event.description:
|
if event.description:
|
||||||
component.add("description", event.description)
|
component.add("description", event.description)
|
||||||
if event.location:
|
if event.location:
|
||||||
component.add("location", event.location)
|
component.add("location", event.location)
|
||||||
if event.categories:
|
if event.categories:
|
||||||
component.add("categories", list(event.categories))
|
component.add("categories", list(event.categories))
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_recurrence_and_parties(component: Event, event: Any) -> None:
|
||||||
if event.rrule:
|
if event.rrule:
|
||||||
component.add("rrule", normalized_rrule_for_export(event.rrule))
|
component.add("rrule", normalized_rrule_for_export(event.rrule))
|
||||||
if event.organizer:
|
if event.organizer:
|
||||||
component.add("organizer", party_for_export(event.organizer))
|
component.add("organizer", party_for_export(event.organizer))
|
||||||
for attendee in event.attendees or []:
|
for attendee in event.attendees or []:
|
||||||
component.add("attendee", party_for_export(attendee))
|
component.add("attendee", party_for_export(attendee))
|
||||||
|
|
||||||
|
|
||||||
|
def add_event_raw_records(component: Event, event: Any) -> None:
|
||||||
for record in getattr(event, "rdate", None) or []:
|
for record in getattr(event, "rdate", None) or []:
|
||||||
add_raw_property(component, "RDATE", record)
|
add_raw_property(component, "RDATE", record)
|
||||||
for record in getattr(event, "exdate", None) or []:
|
for record in getattr(event, "exdate", None) or []:
|
||||||
@@ -186,11 +214,6 @@ def event_to_component(event: Any) -> Event:
|
|||||||
for record in getattr(event, "related_to", None) or []:
|
for record in getattr(event, "related_to", None) or []:
|
||||||
add_raw_property(component, "RELATED-TO", record)
|
add_raw_property(component, "RELATED-TO", record)
|
||||||
|
|
||||||
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
|
|
||||||
for alarm in alarms_for_export(event):
|
|
||||||
component.add_component(alarm)
|
|
||||||
return component
|
|
||||||
|
|
||||||
|
|
||||||
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
|
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
|
||||||
"""Expand an event's recurrence primitives within a range.
|
"""Expand an event's recurrence primitives within a range.
|
||||||
@@ -474,6 +497,16 @@ def metadata_value(event: Any, key: str) -> str | None:
|
|||||||
return str(value) if value else 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:
|
def event_temporal_value(value: datetime, *, all_day: bool, timezone_id: str | None) -> date | datetime:
|
||||||
value = normalize_datetime(value)
|
value = normalize_datetime(value)
|
||||||
if all_day:
|
if all_day:
|
||||||
|
|||||||
@@ -1,13 +1,67 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
|
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
|
||||||
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
|
from govoplan_core.core.calendar import (
|
||||||
|
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||||
|
CALENDAR_EVENT_WRITE_SCOPE,
|
||||||
|
CAPABILITY_CALENDAR_OUTBOX,
|
||||||
|
CAPABILITY_CALENDAR_SCHEDULING,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
_calendar_table_retirement_provider = drop_table_retirement_provider(
|
||||||
|
calendar_models.CalendarCollection,
|
||||||
|
calendar_models.CalendarEvent,
|
||||||
|
calendar_models.CalendarOutboxOperation,
|
||||||
|
calendar_models.CalendarSyncCredential,
|
||||||
|
calendar_models.CalendarSyncSource,
|
||||||
|
label="Calendar",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_retirement_provider(session: object | None, module_id: str):
|
||||||
|
plan = _calendar_table_retirement_provider(session, module_id)
|
||||||
|
base_executor = plan.destroy_data_executor
|
||||||
|
if base_executor is None:
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def executor(execute_session: object, execute_module_id: str) -> None:
|
||||||
|
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
|
||||||
|
raise RuntimeError("No database session is available for Calendar credential retirement.")
|
||||||
|
if inspect(execute_session.get_bind()).has_table(calendar_models.CalendarSyncCredential.__tablename__):
|
||||||
|
from govoplan_calendar.backend.service import delete_calendar_credentials_for_retirement
|
||||||
|
|
||||||
|
delete_calendar_credentials_for_retirement(execute_session)
|
||||||
|
base_executor(execute_session, execute_module_id)
|
||||||
|
|
||||||
|
return replace(
|
||||||
|
plan,
|
||||||
|
destroy_data_warnings=(
|
||||||
|
*plan.destroy_data_warnings,
|
||||||
|
"Calendar-owned credentials are deleted immediately before tables are dropped; retirement fails if an external secret provider is unavailable.",
|
||||||
|
),
|
||||||
|
destroy_data_executor=executor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -27,11 +81,11 @@ PERMISSIONS = (
|
|||||||
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
|
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
|
||||||
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
|
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
|
||||||
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
|
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
|
||||||
_permission("calendar:event:write", "Manage calendar events", "Create and edit calendar events."),
|
_permission(CALENDAR_EVENT_WRITE_SCOPE, "Manage calendar events", "Create and edit calendar events."),
|
||||||
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
|
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
|
||||||
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
|
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
|
||||||
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
|
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
|
||||||
_permission("calendar:availability:read", "Read availability", "Read free/busy and availability data for integrations."),
|
_permission(CALENDAR_AVAILABILITY_READ_SCOPE, "Read availability", "Read free/busy and availability data for integrations."),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -43,11 +97,11 @@ ROLE_TEMPLATES = (
|
|||||||
"calendar:calendar:read",
|
"calendar:calendar:read",
|
||||||
"calendar:calendar:write",
|
"calendar:calendar:write",
|
||||||
"calendar:event:read",
|
"calendar:event:read",
|
||||||
"calendar:event:write",
|
CALENDAR_EVENT_WRITE_SCOPE,
|
||||||
"calendar:event:delete",
|
"calendar:event:delete",
|
||||||
"calendar:event:import",
|
"calendar:event:import",
|
||||||
"calendar:event:export",
|
"calendar:event:export",
|
||||||
"calendar:availability:read",
|
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
@@ -58,20 +112,32 @@ ROLE_TEMPLATES = (
|
|||||||
"calendar:calendar:read",
|
"calendar:calendar:read",
|
||||||
"calendar:event:read",
|
"calendar:event:read",
|
||||||
"calendar:event:export",
|
"calendar:event:export",
|
||||||
"calendar:availability:read",
|
CALENDAR_AVAILABILITY_READ_SCOPE,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
from govoplan_calendar.backend.db.models import (
|
||||||
|
CalendarCollection,
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarOutboxOperation,
|
||||||
|
CalendarSyncCredential,
|
||||||
|
CalendarSyncSource,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
|
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
|
||||||
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
|
||||||
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
|
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
|
||||||
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
|
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
|
||||||
|
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
|
||||||
|
.filter(
|
||||||
|
CalendarOutboxOperation.tenant_id == tenant_id,
|
||||||
|
CalendarOutboxOperation.status.in_(("pending", "retry", "in_progress")),
|
||||||
|
)
|
||||||
|
.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -84,16 +150,46 @@ def _calendar_router(context: ModuleContext):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_scheduling_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
|
||||||
|
|
||||||
|
return SqlCalendarSchedulingProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_outbox_provider(context: ModuleContext) -> object:
|
||||||
|
from govoplan_calendar.backend.outbox import (
|
||||||
|
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
|
||||||
|
SqlCalendarOutboxProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SqlCalendarOutboxProvider(
|
||||||
|
terminal_retention_days=getattr(
|
||||||
|
context.settings,
|
||||||
|
"calendar_outbox_terminal_retention_days",
|
||||||
|
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="calendar",
|
id="calendar",
|
||||||
name="Calendar",
|
name="Calendar",
|
||||||
version="0.1.5",
|
version="0.1.8",
|
||||||
dependencies=("access",),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
|
||||||
|
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_calendar_router,
|
route_factory=_calendar_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
|
||||||
|
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
|
||||||
|
},
|
||||||
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="calendar",
|
module_id="calendar",
|
||||||
@@ -105,19 +201,14 @@ manifest = ModuleManifest(
|
|||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=_calendar_retirement_provider,
|
||||||
calendar_models.CalendarCollection,
|
|
||||||
calendar_models.CalendarEvent,
|
|
||||||
calendar_models.CalendarSyncCredential,
|
|
||||||
calendar_models.CalendarSyncSource,
|
|
||||||
label="Calendar",
|
|
||||||
),
|
|
||||||
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
|
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
|
||||||
),
|
),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
calendar_models.CalendarCollection,
|
calendar_models.CalendarCollection,
|
||||||
calendar_models.CalendarEvent,
|
calendar_models.CalendarEvent,
|
||||||
|
calendar_models.CalendarOutboxOperation,
|
||||||
calendar_models.CalendarSyncCredential,
|
calendar_models.CalendarSyncCredential,
|
||||||
calendar_models.CalendarSyncSource,
|
calendar_models.CalendarSyncSource,
|
||||||
label="Calendar",
|
label="Calendar",
|
||||||
|
|||||||
+12
-7
@@ -1,7 +1,7 @@
|
|||||||
"""calendar collections and VEVENT storage
|
"""calendar collections and VEVENT storage
|
||||||
|
|
||||||
Revision ID: 7c8d9e0f1a2b
|
Revision ID: 7c8d9e0f1a2b
|
||||||
Revises: 1b2c3d4e5f70
|
Revises: 2e3f4a5b6c7d
|
||||||
Create Date: 2026-07-07 00:00:00.000000
|
Create Date: 2026-07-07 00:00:00.000000
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,14 +11,19 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
|
|
||||||
revision = "7c8d9e0f1a2b"
|
revision = "7c8d9e0f1a2b"
|
||||||
down_revision = "1b2c3d4e5f70"
|
down_revision = "2e3f4a5b6c7d"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = 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:
|
def upgrade() -> None:
|
||||||
inspector = sa.inspect(op.get_bind())
|
inspector = sa.inspect(op.get_bind())
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
|
scope_fk_target = _scope_fk_target(tables)
|
||||||
if "calendar_collections" not in tables:
|
if "calendar_collections" not in tables:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"calendar_collections",
|
"calendar_collections",
|
||||||
@@ -38,8 +43,8 @@ def upgrade() -> None:
|
|||||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_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(["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"], ["tenants.id"], name=op.f("fk_calendar_collections_tenant_id_tenants"), ondelete="CASCADE"),
|
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")),
|
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)
|
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("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_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(["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(["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"], ["tenants.id"], name=op.f("fk_calendar_events_tenant_id_tenants"), ondelete="CASCADE"),
|
sa.ForeignKeyConstraint(["tenant_id"], [scope_fk_target], name=op.f("fk_calendar_events_tenant_id_scopes"), 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(["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.PrimaryKeyConstraint("id", name=op.f("pk_calendar_events")),
|
||||||
sa.UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
|
sa.UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
|
||||||
)
|
)
|
||||||
+6
-1
@@ -16,11 +16,16 @@ branch_labels = None
|
|||||||
depends_on = 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:
|
def upgrade() -> None:
|
||||||
inspector = sa.inspect(op.get_bind())
|
inspector = sa.inspect(op.get_bind())
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
if "calendar_sync_sources" in tables:
|
if "calendar_sync_sources" in tables:
|
||||||
return
|
return
|
||||||
|
scope_fk_target = _scope_fk_target(tables)
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"calendar_sync_sources",
|
"calendar_sync_sources",
|
||||||
sa.Column("id", sa.String(length=36), nullable=False),
|
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("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_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(["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")),
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_sources")),
|
||||||
)
|
)
|
||||||
for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"):
|
for column in ("tenant_id", "calendar_id", "source_kind", "deleted_at"):
|
||||||
+7
-2
@@ -16,10 +16,15 @@ branch_labels = None
|
|||||||
depends_on = 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:
|
def upgrade() -> None:
|
||||||
bind = op.get_bind()
|
bind = op.get_bind()
|
||||||
inspector = sa.inspect(bind)
|
inspector = sa.inspect(bind)
|
||||||
tables = set(inspector.get_table_names())
|
tables = set(inspector.get_table_names())
|
||||||
|
scope_fk_target = _scope_fk_target(tables)
|
||||||
if "calendar_sync_sources" in tables:
|
if "calendar_sync_sources" in tables:
|
||||||
columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")}
|
columns = {column["name"] for column in inspector.get_columns("calendar_sync_sources")}
|
||||||
add_column_if_missing(columns, "sync_enabled", sa.Column("sync_enabled", sa.Boolean(), server_default=sa.true(), nullable=False))
|
add_column_if_missing(columns, "sync_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("metadata", sa.JSON(), nullable=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.Column("updated_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(["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"], ["tenants.id"], name=op.f("fk_calendar_sync_credentials_tenant_id_tenants"), ondelete="CASCADE"),
|
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")),
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_sync_credentials")),
|
||||||
)
|
)
|
||||||
for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"):
|
for column in ("tenant_id", "credential_kind", "created_by_user_id", "deleted_at"):
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Calendar Alembic revisions."""
|
||||||
+28
@@ -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()
|
||||||
+174
@@ -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')
|
||||||
+114
@@ -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
@@ -5,10 +5,16 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response, status
|
||||||
from sqlalchemy.orm import Session
|
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.ical import ICalendarError, event_to_ics, http_last_modified
|
||||||
from govoplan_calendar.backend.caldav import CalDAVError
|
from govoplan_calendar.backend.caldav import CalDAVError
|
||||||
from govoplan_calendar.backend.schemas import (
|
from govoplan_calendar.backend.schemas import (
|
||||||
|
CalendarCalDavDiscoveryRequest,
|
||||||
|
CalendarCalDavDiscoveryResponse,
|
||||||
CalendarCalDavDueSyncItemResponse,
|
CalendarCalDavDueSyncItemResponse,
|
||||||
CalendarCalDavDueSyncResponse,
|
CalendarCalDavDueSyncResponse,
|
||||||
CalendarCalDavSourceCreateRequest,
|
CalendarCalDavSourceCreateRequest,
|
||||||
@@ -23,36 +29,66 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
CalendarCollectionResponse,
|
CalendarCollectionResponse,
|
||||||
CalendarCollectionUpdateRequest,
|
CalendarCollectionUpdateRequest,
|
||||||
CalendarEventCreateRequest,
|
CalendarEventCreateRequest,
|
||||||
|
CalendarEventDeltaResponse,
|
||||||
CalendarEventListResponse,
|
CalendarEventListResponse,
|
||||||
CalendarEventResponse,
|
CalendarEventResponse,
|
||||||
CalendarEventUpdateRequest,
|
CalendarEventUpdateRequest,
|
||||||
CalendarFreeBusyRequest,
|
CalendarFreeBusyRequest,
|
||||||
CalendarFreeBusyResponse,
|
CalendarFreeBusyResponse,
|
||||||
CalendarIcsImportRequest,
|
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 (
|
from govoplan_calendar.backend.service import (
|
||||||
|
CALENDAR_EVENTS_COLLECTION,
|
||||||
|
CALENDAR_EVENT_RESOURCE,
|
||||||
|
CALENDAR_MODULE_ID,
|
||||||
CalendarError,
|
CalendarError,
|
||||||
caldav_source_response,
|
caldav_source_response,
|
||||||
caldav_sync_response,
|
caldav_sync_response,
|
||||||
calendar_response,
|
calendar_response,
|
||||||
create_calendar,
|
create_calendar,
|
||||||
create_caldav_source,
|
create_caldav_source,
|
||||||
|
create_sync_source,
|
||||||
create_event,
|
create_event,
|
||||||
delete_calendar,
|
delete_calendar,
|
||||||
delete_caldav_source,
|
delete_caldav_source,
|
||||||
|
delete_sync_source,
|
||||||
delete_event,
|
delete_event,
|
||||||
|
discover_caldav_calendars,
|
||||||
event_response,
|
event_response,
|
||||||
get_caldav_source,
|
|
||||||
get_event,
|
get_event,
|
||||||
import_ics_event,
|
import_ics_event,
|
||||||
list_freebusy,
|
list_freebusy,
|
||||||
list_caldav_sources,
|
list_caldav_sources,
|
||||||
list_calendars,
|
list_calendars,
|
||||||
list_events,
|
list_events,
|
||||||
|
list_sync_sources,
|
||||||
|
normalize_datetime,
|
||||||
|
sync_due_sources,
|
||||||
sync_due_caldav_sources,
|
sync_due_caldav_sources,
|
||||||
sync_caldav_source,
|
sync_caldav_source,
|
||||||
|
sync_source,
|
||||||
update_calendar,
|
update_calendar,
|
||||||
update_caldav_source,
|
update_caldav_source,
|
||||||
|
update_sync_source,
|
||||||
update_event,
|
update_event,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
@@ -78,17 +114,142 @@ def _event_response(event) -> CalendarEventResponse:
|
|||||||
return CalendarEventResponse.model_validate(event_response(event))
|
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:
|
def _caldav_source_response(source) -> CalendarCalDavSourceResponse:
|
||||||
return CalendarCalDavSourceResponse.model_validate(caldav_source_response(source))
|
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)
|
@router.get("/calendars", response_model=CalendarCollectionListResponse)
|
||||||
def api_list_calendars(
|
def api_list_calendars(
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:calendar:read")
|
_require_scope(principal, "calendar:calendar:read")
|
||||||
calendars = list_calendars(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
|
calendars = list_calendars(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
can_admin=principal.has("calendar:calendar:admin"),
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
|
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
|
||||||
|
|
||||||
@@ -107,7 +268,7 @@ def api_create_calendar(
|
|||||||
return _calendar_response(calendar)
|
return _calendar_response(calendar)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
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)
|
@router.patch("/calendars/{calendar_id}", response_model=CalendarCollectionResponse)
|
||||||
@@ -137,12 +298,154 @@ def api_delete_calendar(
|
|||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:calendar:admin")
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
try:
|
try:
|
||||||
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload)
|
delete_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
calendar_id=calendar_id,
|
||||||
|
payload=payload,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
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,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key_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)
|
@router.get("/caldav/sources", response_model=CalendarCalDavSourceListResponse)
|
||||||
@@ -156,6 +459,20 @@ def api_list_caldav_sources(
|
|||||||
return CalendarCalDavSourceListResponse(sources=[_caldav_source_response(source) for source in 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)
|
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def api_create_caldav_source(
|
def api_create_caldav_source(
|
||||||
payload: CalendarCalDavSourceCreateRequest,
|
payload: CalendarCalDavSourceCreateRequest,
|
||||||
@@ -170,7 +487,7 @@ def api_create_caldav_source(
|
|||||||
return _caldav_source_response(source)
|
return _caldav_source_response(source)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
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)
|
@router.patch("/caldav/sources/{source_id}", response_model=CalendarCalDavSourceResponse)
|
||||||
@@ -182,13 +499,20 @@ def api_update_caldav_source(
|
|||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:calendar:admin")
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
try:
|
try:
|
||||||
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
|
source = update_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
payload=payload,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(source)
|
session.refresh(source)
|
||||||
return _caldav_source_response(source)
|
return _caldav_source_response(source)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
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)
|
@router.delete("/caldav/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -199,12 +523,23 @@ def api_delete_caldav_source(
|
|||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:calendar:admin")
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
try:
|
try:
|
||||||
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id)
|
delete_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source_id=source_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
error_status = (
|
||||||
|
status.HTTP_404_NOT_FOUND
|
||||||
|
if "not found" in str(exc).lower()
|
||||||
|
else status.HTTP_409_CONFLICT
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
|
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
|
||||||
@@ -231,7 +566,7 @@ def api_sync_caldav_source(
|
|||||||
return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats))
|
return CalendarCalDavSyncResponse.model_validate(caldav_sync_response(source, stats))
|
||||||
except (CalendarError, CalDAVError, ICalendarError) as exc:
|
except (CalendarError, CalDAVError, ICalendarError) as exc:
|
||||||
session.rollback()
|
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)
|
@router.post("/caldav/sync-due", response_model=CalendarCalDavDueSyncResponse)
|
||||||
@@ -261,6 +596,117 @@ def api_sync_due_caldav_sources(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/caldav/outbox", response_model=CalendarOutboxOperationListResponse)
|
||||||
|
def api_list_caldav_outbox(
|
||||||
|
operation_status: str | None = Query(default=None, alias="status"),
|
||||||
|
source_id: str | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
operations = list_calendar_outbox_operations(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
status=operation_status,
|
||||||
|
source_id=source_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return CalendarOutboxOperationListResponse(
|
||||||
|
operations=[_outbox_operation_response(operation) for operation in operations]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/caldav/outbox/dispatch", response_model=CalendarOutboxDispatchResponse)
|
||||||
|
def api_dispatch_caldav_outbox(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
return CalendarOutboxDispatchResponse.model_validate(
|
||||||
|
dispatch_calendar_outbox(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/caldav/outbox/{operation_id}/retry",
|
||||||
|
response_model=CalendarOutboxOperationResponse,
|
||||||
|
)
|
||||||
|
def api_retry_caldav_outbox_operation(
|
||||||
|
operation_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
try:
|
||||||
|
operation = retry_calendar_outbox_operation(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
operation_id=operation_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(operation)
|
||||||
|
return _outbox_operation_response(operation)
|
||||||
|
except ValueError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/caldav/outbox/{operation_id}/reconcile",
|
||||||
|
response_model=CalendarOutboxOperationResponse,
|
||||||
|
)
|
||||||
|
def api_reconcile_caldav_outbox_operation(
|
||||||
|
operation_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
try:
|
||||||
|
operation = reconcile_calendar_outbox_operation(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
operation_id=operation_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(operation)
|
||||||
|
return _outbox_operation_response(operation)
|
||||||
|
except (ValueError, CalDAVError, CalendarError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/caldav/outbox/{operation_id}/discard",
|
||||||
|
response_model=CalendarOutboxOperationResponse,
|
||||||
|
)
|
||||||
|
def api_discard_caldav_outbox_operation(
|
||||||
|
operation_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Abandon local desired state and allow the next sync to accept remote."""
|
||||||
|
|
||||||
|
_require_scope(principal, "calendar:calendar:admin")
|
||||||
|
try:
|
||||||
|
operation = discard_calendar_outbox_operation(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
operation_id=operation_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(operation)
|
||||||
|
return _outbox_operation_response(operation)
|
||||||
|
except ValueError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events", response_model=CalendarEventListResponse)
|
@router.get("/events", response_model=CalendarEventListResponse)
|
||||||
def api_list_events(
|
def api_list_events(
|
||||||
calendar_id: str | None = None,
|
calendar_id: str | None = None,
|
||||||
@@ -274,13 +720,64 @@ def api_list_events(
|
|||||||
return CalendarEventListResponse(events=[_event_response(event) for event in 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)
|
@router.post("/events", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def api_create_event(
|
def api_create_event(
|
||||||
payload: CalendarEventCreateRequest,
|
payload: CalendarEventCreateRequest,
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:event:write")
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -288,7 +785,7 @@ def api_create_event(
|
|||||||
return _event_response(event)
|
return _event_response(event)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
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)
|
@router.get("/events/{event_id}", response_model=CalendarEventResponse)
|
||||||
@@ -311,7 +808,7 @@ def api_update_event(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:event:write")
|
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
|
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -319,7 +816,7 @@ def api_update_event(
|
|||||||
return _event_response(event)
|
return _event_response(event)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
session.rollback()
|
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)
|
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -328,9 +825,9 @@ def api_delete_event(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
|
_require_any_scope(principal, "calendar:event:delete", CALENDAR_EVENT_WRITE_SCOPE)
|
||||||
try:
|
try:
|
||||||
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id)
|
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
|
||||||
session.commit()
|
session.commit()
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
except CalendarError as exc:
|
except CalendarError as exc:
|
||||||
@@ -362,7 +859,7 @@ def api_import_ics_event(
|
|||||||
return _event_response(event)
|
return _event_response(event)
|
||||||
except (CalendarError, ICalendarError) as exc:
|
except (CalendarError, ICalendarError) as exc:
|
||||||
session.rollback()
|
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")
|
@router.get("/events/{event_id}/ics")
|
||||||
@@ -393,7 +890,7 @@ def api_freebusy(
|
|||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_scope(principal, "calendar:availability:read")
|
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
|
||||||
try:
|
try:
|
||||||
busy = list_freebusy(
|
busy = list_freebusy(
|
||||||
session,
|
session,
|
||||||
@@ -404,4 +901,4 @@ def api_freebusy(
|
|||||||
)
|
)
|
||||||
return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy)
|
return CalendarFreeBusyResponse(start_at=payload.start_at, end_at=payload.end_at, busy=busy)
|
||||||
except CalendarError as exc:
|
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
|
||||||
|
|||||||
@@ -1,36 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from govoplan_core.core.runtime import ModuleRuntimeState
|
||||||
|
|
||||||
_runtime_registry: object | None = None
|
_runtime = ModuleRuntimeState("Calendar")
|
||||||
_runtime_settings: object | None = None
|
|
||||||
|
|
||||||
|
configure_runtime = _runtime.configure_runtime
|
||||||
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
get_registry = _runtime.get_registry
|
||||||
global _runtime_registry, _runtime_settings
|
get_settings = _runtime.get_settings
|
||||||
if registry is not None:
|
settings = _runtime.settings
|
||||||
_runtime_registry = registry
|
|
||||||
if settings is not None:
|
|
||||||
_runtime_settings = settings
|
|
||||||
|
|
||||||
|
|
||||||
def get_registry() -> object | None:
|
|
||||||
return _runtime_registry
|
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> object:
|
|
||||||
if _runtime_settings is not None:
|
|
||||||
return _runtime_settings
|
|
||||||
try:
|
|
||||||
from govoplan_core.settings import settings as legacy_settings
|
|
||||||
except ModuleNotFoundError as exc:
|
|
||||||
raise RuntimeError("GovOPlaN Calendar runtime settings are not configured") from exc
|
|
||||||
return legacy_settings
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsProxy:
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
|
||||||
return getattr(get_settings(), name)
|
|
||||||
|
|
||||||
|
|
||||||
settings = SettingsProxy()
|
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||||
|
|
||||||
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
|
||||||
|
|
||||||
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
|
||||||
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
|
||||||
CalendarDeleteEventAction = Literal["delete", "move"]
|
CalendarDeleteEventAction = Literal["delete", "move"]
|
||||||
|
CalendarBulkMoveExternalAction = Literal[
|
||||||
|
"detach_keep_remote",
|
||||||
|
"copy_to_remote",
|
||||||
|
"remote_move",
|
||||||
|
]
|
||||||
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
|
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
|
||||||
CalendarSyncDirection = Literal["inbound", "two_way"]
|
CalendarSyncDirection = Literal["inbound", "two_way"]
|
||||||
|
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
|
||||||
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
|
CalendarCalDavConflictPolicy = Literal["etag", "overwrite"]
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +55,7 @@ class CalendarCollectionDeleteRequest(BaseModel):
|
|||||||
event_action: CalendarDeleteEventAction = "delete"
|
event_action: CalendarDeleteEventAction = "delete"
|
||||||
target_calendar_id: str | None = None
|
target_calendar_id: str | None = None
|
||||||
make_target_default: bool = False
|
make_target_default: bool = False
|
||||||
|
external_action: CalendarBulkMoveExternalAction | None = None
|
||||||
|
|
||||||
|
|
||||||
class CalendarCollectionResponse(BaseModel):
|
class CalendarCollectionResponse(BaseModel):
|
||||||
@@ -70,9 +79,10 @@ class CalendarCollectionListResponse(BaseModel):
|
|||||||
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
|
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSourceCreateRequest(BaseModel):
|
class CalendarSyncSourceCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
source_kind: CalendarSyncSourceKind = "caldav"
|
||||||
calendar_id: str
|
calendar_id: str
|
||||||
collection_url: str = Field(min_length=1, max_length=1000)
|
collection_url: str = Field(min_length=1, max_length=1000)
|
||||||
display_name: str | None = Field(default=None, max_length=255)
|
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)
|
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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
calendar_id: str | None = None
|
calendar_id: str | None = None
|
||||||
@@ -108,7 +122,11 @@ class CalendarCalDavSourceUpdateRequest(BaseModel):
|
|||||||
metadata: dict[str, Any] | None = None
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSourceResponse(BaseModel):
|
class CalendarCalDavSourceUpdateRequest(CalendarSyncSourceUpdateRequest):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
calendar_id: str
|
calendar_id: str
|
||||||
@@ -135,11 +153,44 @@ class CalendarCalDavSourceResponse(BaseModel):
|
|||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class CalendarCalDavSourceListResponse(BaseModel):
|
||||||
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
|
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")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
@@ -147,8 +198,12 @@ class CalendarCalDavSyncRequest(BaseModel):
|
|||||||
force_full: bool = False
|
force_full: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavSyncResponse(BaseModel):
|
class CalendarCalDavSyncRequest(CalendarSyncSourceSyncRequest):
|
||||||
source: CalendarCalDavSourceResponse
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncSourceSyncResponse(BaseModel):
|
||||||
|
source: CalendarSyncSourceResponse
|
||||||
created: int = 0
|
created: int = 0
|
||||||
updated: int = 0
|
updated: int = 0
|
||||||
deleted: int = 0
|
deleted: int = 0
|
||||||
@@ -161,7 +216,11 @@ class CalendarCalDavSyncResponse(BaseModel):
|
|||||||
errors: list[str] = Field(default_factory=list)
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavDueSyncItemResponse(BaseModel):
|
class CalendarCalDavSyncResponse(CalendarSyncSourceSyncResponse):
|
||||||
|
source: CalendarCalDavSourceResponse
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncDueSyncItemResponse(BaseModel):
|
||||||
source_id: str
|
source_id: str
|
||||||
calendar_id: str
|
calendar_id: str
|
||||||
status: str
|
status: str
|
||||||
@@ -173,10 +232,62 @@ class CalendarCalDavDueSyncItemResponse(BaseModel):
|
|||||||
fetched: int = 0
|
fetched: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarCalDavDueSyncItemResponse(CalendarSyncDueSyncItemResponse):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarSyncDueSyncResponse(BaseModel):
|
||||||
|
results: list[CalendarSyncDueSyncItemResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarCalDavDueSyncResponse(BaseModel):
|
class CalendarCalDavDueSyncResponse(BaseModel):
|
||||||
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
|
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarOutboxOperationResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
source_id: str
|
||||||
|
event_id: str | None = None
|
||||||
|
operation_kind: str
|
||||||
|
resource_href: str
|
||||||
|
payload_fingerprint: str | None = None
|
||||||
|
expected_etag: str | None = None
|
||||||
|
idempotency_key: str
|
||||||
|
status: str
|
||||||
|
attempt_count: int
|
||||||
|
max_attempts: int
|
||||||
|
available_at: datetime
|
||||||
|
last_attempt_at: datetime | None = None
|
||||||
|
lease_expires_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
reconciled_at: datetime | None = None
|
||||||
|
remote_etag: str | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarOutboxOperationListResponse(BaseModel):
|
||||||
|
operations: list[CalendarOutboxOperationResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarOutboxDispatchItemResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
status: str
|
||||||
|
attempt_count: int
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarOutboxDispatchResponse(BaseModel):
|
||||||
|
processed: int = 0
|
||||||
|
succeeded: int = 0
|
||||||
|
retrying: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
operations: list[CalendarOutboxDispatchItemResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CalendarEventCreateRequest(BaseModel):
|
class CalendarEventCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -283,6 +394,14 @@ class CalendarEventListResponse(BaseModel):
|
|||||||
events: list[CalendarEventResponse] = Field(default_factory=list)
|
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):
|
class CalendarFreeBusyRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,11 @@ from celery import shared_task
|
|||||||
|
|
||||||
@shared_task(name="govoplan_calendar.sync_due_caldav_sources")
|
@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]]:
|
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
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
with get_database().SessionLocal() as session:
|
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()
|
session.commit()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
+866
-39
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -92,6 +92,7 @@ END:VCALENDAR
|
|||||||
self.assertEqual(event["start_at"], datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc))
|
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["end_at"], datetime(2026, 7, 8, 8, 30, tzinfo=timezone.utc))
|
||||||
self.assertEqual(event["duration_seconds"], 5400)
|
self.assertEqual(event["duration_seconds"], 5400)
|
||||||
|
self.assertTrue(event["icalendar"]["standard"]["uses_duration"])
|
||||||
self.assertEqual(event["description"], "Line one\nLine two")
|
self.assertEqual(event["description"], "Line one\nLine two")
|
||||||
self.assertEqual(event["location"], "Room; 1")
|
self.assertEqual(event["location"], "Room; 1")
|
||||||
self.assertEqual(event["timezone"], "Europe/Berlin")
|
self.assertEqual(event["timezone"], "Europe/Berlin")
|
||||||
@@ -164,6 +165,38 @@ END:VCALENDAR
|
|||||||
self.assertIn("BEGIN:VALARM", ics)
|
self.assertIn("BEGIN:VALARM", ics)
|
||||||
self.assertIn("TRIGGER:-PT15M", 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:
|
def test_expand_event_occurrences_applies_rrule_rdate_and_exdate(self) -> None:
|
||||||
parsed = parse_vevent(
|
parsed = parse_vevent(
|
||||||
"""BEGIN:VCALENDAR
|
"""BEGIN:VCALENDAR
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+62
-1
@@ -6,7 +6,7 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
|
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
|
||||||
from govoplan_calendar.backend.service import delete_calendar, event_response
|
from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
@@ -65,6 +65,67 @@ class CalendarServiceResponseTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.start_at.tzinfo, timezone.utc)
|
self.assertEqual(response.start_at.tzinfo, timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarVisibilityTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def calendar(**overrides):
|
||||||
|
values = {
|
||||||
|
"visibility": "private",
|
||||||
|
"owner_type": "user",
|
||||||
|
"owner_id": "owner-1",
|
||||||
|
"created_by_user_id": "creator-1",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
def test_tenant_shared_and_public_calendars_are_visible_to_readers(self) -> None:
|
||||||
|
for visibility in ("tenant", "shared", "public"):
|
||||||
|
with self.subTest(visibility=visibility):
|
||||||
|
self.assertTrue(
|
||||||
|
calendar_is_visible_to_principal(
|
||||||
|
self.calendar(visibility=visibility),
|
||||||
|
user_id="reader-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_private_calendar_is_visible_to_user_owner_creator_or_group_member(self) -> None:
|
||||||
|
self.assertTrue(
|
||||||
|
calendar_is_visible_to_principal(
|
||||||
|
self.calendar(),
|
||||||
|
user_id="owner-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
calendar_is_visible_to_principal(
|
||||||
|
self.calendar(),
|
||||||
|
user_id="creator-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
calendar_is_visible_to_principal(
|
||||||
|
self.calendar(owner_type="group", owner_id="group-1"),
|
||||||
|
user_id="member-1",
|
||||||
|
group_ids=("group-1",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_private_calendar_is_hidden_from_other_readers_but_visible_to_admin(self) -> None:
|
||||||
|
calendar = self.calendar()
|
||||||
|
self.assertFalse(
|
||||||
|
calendar_is_visible_to_principal(
|
||||||
|
calendar,
|
||||||
|
user_id="other-1",
|
||||||
|
group_ids=("group-2",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
calendar_is_visible_to_principal(
|
||||||
|
calendar,
|
||||||
|
user_id="other-1",
|
||||||
|
can_admin=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalendarServiceDeleteTests(unittest.TestCase):
|
class CalendarServiceDeleteTests(unittest.TestCase):
|
||||||
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
|
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
|
||||||
session = FakeSession()
|
session = FakeSession()
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.5",
|
"version": "0.1.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,9 +13,12 @@
|
|||||||
},
|
},
|
||||||
"./styles/calendar.css": "./src/styles/calendar.css"
|
"./styles/calendar.css": "./src/styles/calendar.css"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.5",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
|||||||
+196
-4
@@ -55,6 +55,114 @@ export type CalendarEvent = {
|
|||||||
|
|
||||||
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
||||||
export type CalendarEventListResponse = { events: CalendarEvent[] };
|
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 = {
|
export type CalendarCollectionCreatePayload = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -71,25 +179,44 @@ export type CalendarCollectionCreatePayload = {
|
|||||||
|
|
||||||
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
|
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
|
||||||
export type CalendarDeleteEventAction = "delete" | "move";
|
export type CalendarDeleteEventAction = "delete" | "move";
|
||||||
|
export type CalendarBulkMoveExternalAction = "detach_keep_remote" | "copy_to_remote" | "remote_move";
|
||||||
export type CalendarCollectionDeletePayload = {
|
export type CalendarCollectionDeletePayload = {
|
||||||
event_action?: CalendarDeleteEventAction;
|
event_action?: CalendarDeleteEventAction;
|
||||||
target_calendar_id?: string | null;
|
target_calendar_id?: string | null;
|
||||||
make_target_default?: boolean;
|
make_target_default?: boolean;
|
||||||
|
external_action?: CalendarBulkMoveExternalAction | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CalendarEventCreatePayload = {
|
export type CalendarEventCreatePayload = {
|
||||||
calendar_id?: string | null;
|
calendar_id?: string | null;
|
||||||
|
uid?: string | null;
|
||||||
|
recurrence_id?: string | null;
|
||||||
|
sequence?: number;
|
||||||
summary: string;
|
summary: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
start_at: string;
|
|
||||||
end_at?: string | null;
|
|
||||||
all_day?: boolean;
|
|
||||||
timezone?: string | null;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
transparency?: string;
|
transparency?: string;
|
||||||
classification?: 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[];
|
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>;
|
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) });
|
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(
|
export function listCalendarEvents(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
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}`);
|
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> {
|
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
||||||
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export { calendarModule as default, calendarModule } from "./module";
|
export { calendarModule as default, calendarModule } from "./module";
|
||||||
export * from "./api/calendar";
|
export * from "./api/calendar";
|
||||||
|
export { default as CalendarPicker } from "./features/calendar/CalendarPicker";
|
||||||
|
export * from "./features/calendar/calendarPickerLogic";
|
||||||
|
|||||||
+17
-5
@@ -1,21 +1,33 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { CalendarPickerUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
import "./styles/calendar.css";
|
import "./styles/calendar.css";
|
||||||
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import CalendarPicker from "./features/calendar/CalendarPicker";
|
||||||
|
|
||||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||||
|
|
||||||
const eventRead = ["calendar:event:read"];
|
const eventRead = ["calendar:event:read"];
|
||||||
|
const translations = {
|
||||||
|
en: generatedTranslations.en,
|
||||||
|
de: generatedTranslations.de
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
||||||
|
|
||||||
export const calendarModule: PlatformWebModule = {
|
export const calendarModule: PlatformWebModule = {
|
||||||
id: "calendar",
|
id: "calendar",
|
||||||
label: "Calendar",
|
label: "i18n:govoplan-calendar.calendar.adab5090",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
|
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: [
|
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;
|
export default calendarModule;
|
||||||
|
|||||||
+303
-174
@@ -17,13 +17,18 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-loading-frame {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-shell {
|
.calendar-shell {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
background: var(--panel-header);
|
background: var(--panel-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,76 +89,15 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-icon-button.btn {
|
|
||||||
width: 36px;
|
|
||||||
min-width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-icon-button.btn:disabled {
|
|
||||||
opacity: .42;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-mode-switch {
|
.calendar-mode-switch {
|
||||||
flex: 0 1 520px;
|
flex: 0 1 520px;
|
||||||
display: inline-grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
width: min(520px, 100%);
|
width: min(520px, 100%);
|
||||||
max-width: 520px;
|
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;
|
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 {
|
.calendar-sidebar {
|
||||||
@@ -162,14 +106,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-sidebar-heading {
|
.calendar-sidebar-heading {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 13px 14px;
|
padding: 13px 14px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -178,7 +122,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-sidebar-heading.is-secondary {
|
.calendar-sidebar-heading.is-secondary {
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-list,
|
.calendar-list,
|
||||||
@@ -216,7 +160,7 @@
|
|||||||
.calendar-list-row {
|
.calendar-list-row {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 42px minmax(0, 1fr) 30px;
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -230,6 +174,10 @@
|
|||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-list-row.is-syncing {
|
||||||
|
background: var(--panel-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-visibility-switch {
|
.calendar-visibility-switch {
|
||||||
--calendar-list-color: var(--green);
|
--calendar-list-color: var(--green);
|
||||||
width: 34px;
|
width: 34px;
|
||||||
@@ -238,9 +186,9 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border: 1px solid #c9c3b9;
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #d8d3cb;
|
background: var(--calendar-switch-bg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .16s ease, border-color .16s ease;
|
transition: background .16s ease, border-color .16s ease;
|
||||||
}
|
}
|
||||||
@@ -254,8 +202,8 @@
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, .18);
|
box-shadow: var(--shadow-thumb);
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition: transform .16s ease;
|
transition: transform .16s ease;
|
||||||
}
|
}
|
||||||
@@ -286,64 +234,36 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-row-icon-button.btn {
|
.calendar-list-actions {
|
||||||
width: 28px;
|
width: auto;
|
||||||
min-width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
border-color: transparent;
|
|
||||||
background: transparent;
|
|
||||||
opacity: .72;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-row-icon-button.btn:hover,
|
.calendar-sync-spin {
|
||||||
.calendar-row-icon-button.btn:focus-visible {
|
animation: calendar-sync-spin .85s linear infinite;
|
||||||
border-color: var(--line-dark);
|
|
||||||
background: var(--surface);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form input[type="color"] {
|
@keyframes calendar-sync-spin {
|
||||||
width: 24px;
|
to {
|
||||||
min-width: 24px;
|
transform: rotate(360deg);
|
||||||
height: 24px;
|
}
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--line-dark);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form input:disabled {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
opacity: .55;
|
.calendar-sync-spin {
|
||||||
cursor: not-allowed;
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form {
|
.calendar-create-action {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 28px 36px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-create-form input[type="text"],
|
.calendar-create-action .btn {
|
||||||
.calendar-create-form input:not([type]) {
|
width: 100%;
|
||||||
min-width: 0;
|
justify-content: center;
|
||||||
height: 34px;
|
|
||||||
border: 1px solid var(--line-dark);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-agenda {
|
.calendar-agenda {
|
||||||
@@ -363,7 +283,7 @@
|
|||||||
.calendar-agenda-group + .calendar-agenda-group {
|
.calendar-agenda-group + .calendar-agenda-group {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--line);
|
border-top: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-agenda-group h3 {
|
.calendar-agenda-group h3 {
|
||||||
@@ -378,15 +298,15 @@
|
|||||||
|
|
||||||
.calendar-agenda-item {
|
.calendar-agenda-item {
|
||||||
--calendar-event-color: var(--green);
|
--calendar-event-color: var(--green);
|
||||||
--calendar-event-bg: #edf8f5;
|
--calendar-event-bg: var(--calendar-event-bg-default);
|
||||||
--calendar-event-border: #b9d7d0;
|
--calendar-event-border: var(--calendar-event-border-default);
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 5px 7px;
|
padding: 5px 7px;
|
||||||
border: 1px solid var(--calendar-event-border);
|
border: 1px solid var(--calendar-event-border);
|
||||||
border-left: 4px solid var(--calendar-event-color, var(--green));
|
border-left: 4px solid var(--calendar-event-color, var(--green));
|
||||||
background: var(--calendar-event-bg);
|
background: var(--calendar-event-bg);
|
||||||
color: #21453e;
|
color: var(--calendar-event-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-agenda-item strong {
|
.calendar-agenda-item strong {
|
||||||
@@ -401,7 +321,7 @@
|
|||||||
.calendar-agenda-item .calendar-event-separator,
|
.calendar-agenda-item .calendar-event-separator,
|
||||||
.calendar-agenda p {
|
.calendar-agenda p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #4d6764;
|
color: var(--calendar-event-muted-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,9 +353,9 @@
|
|||||||
inset: 0 auto auto 0;
|
inset: 0 auto auto 0;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: var(--panel-glass-bright);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-week-rows {
|
.calendar-week-rows {
|
||||||
@@ -471,7 +391,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border-bottom: 1px solid var(--line-dark);
|
border-bottom: var(--border-line-dark);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +411,7 @@
|
|||||||
|
|
||||||
.calendar-week-row {
|
.calendar-week-row {
|
||||||
min-height: 132px;
|
min-height: 132px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-week-rows.is-continuous .calendar-week-row {
|
.calendar-week-rows.is-continuous .calendar-week-row {
|
||||||
@@ -508,22 +428,22 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-even-month {
|
.calendar-day-cell.is-even-month {
|
||||||
background: #ffffff;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-odd-month {
|
.calendar-day-cell.is-odd-month {
|
||||||
background: #f8f6f2;
|
background: var(--calendar-grid-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-muted {
|
.calendar-day-cell.is-muted {
|
||||||
color: #8a8278;
|
color: var(--muted);
|
||||||
background: #f1efeb;
|
background: var(--control-gradient-end-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-today {
|
.calendar-day-cell.is-today {
|
||||||
@@ -531,7 +451,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-cell.is-drop-target {
|
.calendar-day-cell.is-drop-target {
|
||||||
background: #e3f3ef;
|
background: var(--calendar-today-bg);
|
||||||
box-shadow: inset 0 0 0 2px var(--green);
|
box-shadow: inset 0 0 0 2px var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,8 +487,8 @@
|
|||||||
|
|
||||||
.calendar-event-chip {
|
.calendar-event-chip {
|
||||||
--calendar-event-color: var(--green);
|
--calendar-event-color: var(--green);
|
||||||
--calendar-event-bg: #edf8f5;
|
--calendar-event-bg: var(--calendar-event-bg-default);
|
||||||
--calendar-event-border: #b9d7d0;
|
--calendar-event-border: var(--calendar-event-border-default);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 26px;
|
min-height: 26px;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -577,7 +497,7 @@
|
|||||||
border-left: 4px solid var(--calendar-event-color);
|
border-left: 4px solid var(--calendar-event-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--calendar-event-bg);
|
background: var(--calendar-event-bg);
|
||||||
color: #21453e;
|
color: var(--calendar-event-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -629,7 +549,7 @@
|
|||||||
.calendar-event-time,
|
.calendar-event-time,
|
||||||
.calendar-event-separator {
|
.calendar-event-separator {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #4d6764;
|
color: var(--calendar-event-muted-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-more-events {
|
.calendar-more-events {
|
||||||
@@ -667,8 +587,8 @@
|
|||||||
.calendar-time-corner,
|
.calendar-time-corner,
|
||||||
.calendar-all-day-label,
|
.calendar-all-day-label,
|
||||||
.calendar-all-day-cell {
|
.calendar-all-day-cell {
|
||||||
border-bottom: 1px solid var(--line-dark);
|
border-bottom: var(--border-line-dark);
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,12 +601,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-header > header.is-today {
|
.calendar-time-header > header.is-today {
|
||||||
background: #e0f0ea;
|
background: var(--calendar-selected-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-header > header.is-weekend,
|
.calendar-time-header > header.is-weekend,
|
||||||
.calendar-all-day-cell.is-weekend {
|
.calendar-all-day-cell.is-weekend {
|
||||||
background: #f1efeb;
|
background: var(--control-gradient-end-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-header > header span {
|
.calendar-time-header > header span {
|
||||||
@@ -697,7 +617,7 @@
|
|||||||
.calendar-all-day-strip {
|
.calendar-all-day-strip {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border-bottom: 1px solid var(--line-dark);
|
border-bottom: var(--border-line-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-all-day-label {
|
.calendar-all-day-label {
|
||||||
@@ -715,7 +635,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-all-day-cell.is-drop-target {
|
.calendar-all-day-cell.is-drop-target {
|
||||||
background: #e3f3ef;
|
background: var(--calendar-today-bg);
|
||||||
box-shadow: inset 0 0 0 2px var(--green);
|
box-shadow: inset 0 0 0 2px var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,8 +664,8 @@
|
|||||||
|
|
||||||
.calendar-hour-label {
|
.calendar-hour-label {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -756,17 +676,17 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 1536px;
|
height: 1536px;
|
||||||
min-height: 1536px;
|
min-height: 1536px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: var(--border-line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(to bottom, var(--calendar-day-shade), var(--calendar-day-shade)),
|
linear-gradient(to bottom, var(--calendar-day-shade), var(--calendar-day-shade)),
|
||||||
linear-gradient(
|
linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 0,
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 0,
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-start-y),
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-start-y),
|
||||||
transparent var(--calendar-work-start-y),
|
transparent var(--calendar-work-start-y),
|
||||||
transparent var(--calendar-work-end-y),
|
transparent var(--calendar-work-end-y),
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-end-y),
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-end-y),
|
||||||
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 100%
|
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 100%
|
||||||
),
|
),
|
||||||
repeating-linear-gradient(
|
repeating-linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
@@ -778,11 +698,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-time-column.is-weekend {
|
.calendar-day-time-column.is-weekend {
|
||||||
--calendar-day-shade: rgba(241, 239, 235, .74);
|
--calendar-day-shade: var(--calendar-day-shade);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-time-column.is-drop-target {
|
.calendar-day-time-column.is-drop-target {
|
||||||
box-shadow: inset 0 0 0 2px rgba(90, 169, 155, .55);
|
box-shadow: var(--calendar-focus-inset);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-time-drop-marker {
|
.calendar-time-drop-marker {
|
||||||
@@ -810,10 +730,10 @@
|
|||||||
top: -13px;
|
top: -13px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border: 1px solid #4f9489;
|
border: 1px solid var(--calendar-success-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
color: #fff;
|
color: var(--on-accent);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -831,15 +751,15 @@
|
|||||||
|
|
||||||
.calendar-timed-event {
|
.calendar-timed-event {
|
||||||
--calendar-event-color: var(--green);
|
--calendar-event-color: var(--green);
|
||||||
--calendar-event-bg: #edf8f5;
|
--calendar-event-bg: var(--calendar-event-bg-default);
|
||||||
--calendar-event-border: #b9d7d0;
|
--calendar-event-border: var(--calendar-event-border-default);
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--calendar-event-border);
|
border: 1px solid var(--calendar-event-border);
|
||||||
border-left: 4px solid var(--calendar-event-color);
|
border-left: 4px solid var(--calendar-event-color);
|
||||||
background: var(--calendar-event-bg);
|
background: var(--calendar-event-bg);
|
||||||
color: #21453e;
|
color: var(--calendar-event-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -849,8 +769,8 @@
|
|||||||
.calendar-timed-event.is-linked-hover,
|
.calendar-timed-event.is-linked-hover,
|
||||||
.calendar-timed-overflow:hover,
|
.calendar-timed-overflow:hover,
|
||||||
.calendar-timed-overflow:focus-visible {
|
.calendar-timed-overflow:focus-visible {
|
||||||
border-color: var(--calendar-event-color, #5aa99b);
|
border-color: var(--calendar-event-color, var(--success-border));
|
||||||
background: var(--calendar-event-bg, #e3f3ef);
|
background: var(--calendar-event-bg, var(--calendar-today-bg));
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,7 +789,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-timed-event-content:focus-visible {
|
.calendar-timed-event-content:focus-visible {
|
||||||
outline: 2px solid rgba(90, 169, 155, .35);
|
outline: var(--calendar-focus-outline);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,7 +816,7 @@
|
|||||||
width: 34px;
|
width: 34px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(33, 69, 62, .46);
|
background: var(--calendar-overlay);
|
||||||
content: "";
|
content: "";
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
@@ -922,7 +842,7 @@
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid var(--line-dark);
|
border: var(--border-line-dark);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -931,7 +851,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-event-dialog {
|
.calendar-event-dialog {
|
||||||
width: min(560px, 100%);
|
width: min(680px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-vevent-dialog {
|
||||||
|
width: min(920px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-dialog-form {
|
.calendar-dialog-form {
|
||||||
@@ -974,22 +898,213 @@
|
|||||||
|
|
||||||
.calendar-dialog-name-color-row {
|
.calendar-dialog-name-color-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 86px;
|
grid-template-columns: minmax(0, 1fr) 142px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-dialog-color-label input[type="color"] {
|
.calendar-source-switch {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
|
||||||
width: 100%;
|
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;
|
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 {
|
.calendar-form-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
border: 1px solid #f0b8b2;
|
border: 1px solid var(--calendar-danger-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fff2f0;
|
background: var(--calendar-danger-bg);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -1003,13 +1118,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-delete-warning {
|
.calendar-delete-warning {
|
||||||
border: 1px solid #f0b8b2;
|
border: 1px solid var(--calendar-danger-border);
|
||||||
background: #fff2f0;
|
background: var(--calendar-danger-bg);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-form-note {
|
.calendar-form-note {
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -1019,7 +1134,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--line);
|
border: var(--border-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
@@ -1056,6 +1171,15 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-delete-dialog {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-delete-dialog-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.calendar-event-dialog-footer {
|
.calendar-event-dialog-footer {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1103,7 +1227,7 @@
|
|||||||
|
|
||||||
.calendar-sidebar {
|
.calendar-sidebar {
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-mode-switch {
|
.calendar-mode-switch {
|
||||||
@@ -1114,7 +1238,12 @@
|
|||||||
|
|
||||||
.calendar-dialog-row,
|
.calendar-dialog-row,
|
||||||
.calendar-dialog-date-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;
|
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.");
|
||||||
@@ -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.");
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user