68 Commits
Author SHA1 Message Date
zemion dde8c50e55 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:12 +02:00
zemion 8e36f8b3d2 fix(calendar): bound recurrence work without hiding busy intervals
Module Package Release / publish-packages (push) Successful in 14s
Release v0.1.24. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:36 +02:00
zemion 3e5fc05ca3 feat: promote calendar to a stable product destination
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 17:58:27 +02:00
zemion 3792e9ab8a fix(webui): bind destructive calendar controls to help
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:36:33 +02:00
zemion ae0f3990f1 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:31 +02:00
zemion 4f02425a28 docs(calendar): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:19:13 +02:00
zemion a2a9e8e814 feat: add governed Calendar DSAR coverage 2026-08-20 23:27:27 +02:00
zemion 93fb8aeff8 feat: reconcile scheduling calendar holds 2026-08-20 07:44:15 +02:00
zemion 4bbf80e634 feat(webui): complete calendar quick access 2026-08-19 19:48:51 +02:00
zemion 734501281e style: use shared WebUI foundation tokens 2026-08-18 21:32:50 +02:00
zemion 35671dec73 Adopt shared WebUI structural primitives 2026-08-18 13:17:28 +02:00
zemion f9385519f3 Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion a6e10fd120 Adopt shared WebUI layout primitives 2026-08-18 10:42:51 +02:00
zemion b6f939eaba Contribute Calendar to Quick Access 2026-08-06 19:02:55 +02:00
zemion 089d07b60a Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
zemion d42153edc7 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:58 +02:00
zemion 128c78597e Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:51:59 +02:00
zemion f6faaf196d Release v0.1.15
Module Package Release / publish-packages (push) Successful in 10s
2026-08-04 15:18:08 +02:00
zemion 2b1fc9af19 Make package publication retries hash-safe 2026-08-04 14:32:18 +02:00
zemion 6e70bbec06 Harden module package publication 2026-08-04 14:02:39 +02:00
zemion 00add294a8 Gate calendar sync workers by tenant entitlement 2026-08-04 09:29:35 +02:00
zemion b4e5351b8c Add protected package release workflow 2026-08-04 04:14:02 +02:00
zemion 04438d96ca Add native permission-aware calendar search source 2026-08-04 03:03:26 +02:00
zemion d7fd9447a2 Migrate Calendar interface patterns 2026-08-03 15:08:18 +02:00
zemion a125b666da Implement destructive CalDAV move saga 2026-08-02 17:30:24 +02:00
zemion 56d7b2108d Implement correlated campaign invitations 2026-08-02 16:38:40 +02:00
zemion 77a40ec2f9 Add CalDAV outbox recovery UI 2026-08-02 15:57:01 +02:00
zemion 4e05ab2c3e Implement Open-Xchange calendar profiles 2026-08-02 05:59:06 +02:00
zemion 66a6df4f05 feat: declare governed external provider state 2026-08-01 17:48:23 +02:00
zemion ccdf12162a Add correlated calendar invitation capability 2026-07-31 22:48:06 +02:00
zemion 001ebd3b6d refactor: target workflow engine runtime 2026-07-31 16:59:21 +02:00
zemion f00cff1d8c Complete recurrence editing and calendar preferences 2026-07-31 05:46:50 +02:00
zemion 1e063f51cc Align calendar WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion b1cf001452 Bound continuous calendar event loading 2026-07-30 05:22:09 +02:00
zemion 56b387a784 refactor: split calendar page components 2026-07-29 19:53:11 +02:00
zemion 4011e61a63 refactor: split calendar source setup orchestration 2026-07-29 19:14:06 +02:00
zemion 2cb3aca27c refactor: harden calendar sync and add dashboard widget 2026-07-29 14:16:28 +02:00
zemion 77072d057a Declare calendar route View surface 2026-07-28 21:04:55 +02:00
zemion 202eb78ae4 Integrate calendar sources with credential envelopes 2026-07-28 19:33:04 +02:00
zemion bf59cd830c test(calendar): commit retirement before engine inspection 2026-07-22 03:21:20 +02:00
zemion 041e2159e7 docs: define CalDAV secret retirement 2026-07-21 15:54:47 +02:00
zemion d64de50802 fix(secrets): redact provider failure details 2026-07-21 15:47:50 +02:00
zemion 7843fe88e5 fix(installer): delete provider secrets before retirement 2026-07-21 15:46:24 +02:00
zemion 39b199d7e6 fix(secrets): fail closed on connector credential deletion 2026-07-21 15:45:43 +02:00
zemion 74495e56cb fix(webui): require Core 0.1.9 for table actions 2026-07-21 13:46:20 +02:00
zemion 6ca1816c95 refactor(webui): centralize Calendar icon actions 2026-07-21 12:36:13 +02:00
zemion 2d7d105ba3 Use explicit outbox source validation 2026-07-21 12:34:34 +02:00
zemion 41b9426670 Refactor calendar event update flow 2026-07-21 12:33:43 +02:00
zemion baf624c7a6 Refactor calendar sync source updates 2026-07-21 12:33:34 +02:00
zemion d10570fc0c Refactor synchronized calendar deletion 2026-07-21 12:33:07 +02:00
zemion 30f6d79f9e Refactor calendar outbox delivery paths 2026-07-21 12:30:03 +02:00
zemion 760a87ab99 Align Calendar runtime version metadata 2026-07-21 12:13:12 +02:00
zemion 7098751f7f Remove caller-managed Calendar secret references 2026-07-21 12:12:35 +02:00
zemion 1df2a1a730 Harden Calendar connector trust boundaries 2026-07-21 12:11:58 +02:00
zemion a52e35b52b Make calendar outbox invariants explicit 2026-07-21 03:16:45 +02:00
zemion a36a9e8c19 Confine calendar connectors to configured origins 2026-07-21 03:16:45 +02:00
zemion 68b165db7b feat(calendar): expose an accessible calendar picker 2026-07-20 17:01:26 +02:00
zemion 2ad6e9d60e feat(calendar): expose loss-safe bulk move modes 2026-07-20 17:01:02 +02:00
zemion a6cd64e3f9 feat(calendar): make external mutations durable 2026-07-20 16:58:45 +02:00
zemion 371a00aff5 intermittent commit 2026-07-14 13:22:10 +02:00
zemion c071235ad7 Harden CalDAV sync handling 2026-07-11 18:37:51 +02:00
zemion 9bcf41bb1f Release v0.1.8 2026-07-11 16:49:01 +02:00
zemion 39d2c28ab1 Release v0.1.7 2026-07-11 02:34:56 +02:00
zemion 9fcb812ddb Create scope tables in calendar backend tests 2026-07-11 00:13:13 +02:00
zemion 70bd067001 Add shared GovOPlaN gitignore 2026-07-10 21:57:21 +02:00
zemion 15aa4f3df3 Use capability-based module boundaries 2026-07-10 17:33:53 +02:00
zemion 15d5613f9b chore: sync GovOPlaN module split state 2026-07-10 12:51:18 +02:00
zemion c3c867391c Release v0.1.6 2026-07-07 15:55:37 +02:00
80 changed files with 27627 additions and 2305 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+268
View File
@@ -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/
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Calendar Codex Guide # GovOPlaN Calendar Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Calendar internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope ## Scope
This repository owns the `calendar` module: calendar collections, VEVENT storage, iCalendar import/export, availability primitives, backend module manifest, and `@govoplan/calendar-webui`. This repository owns the `calendar` module: calendar collections, VEVENT storage, iCalendar import/export, availability primitives, backend module manifest, and `@govoplan/calendar-webui`.
+44 -8
View File
@@ -1,5 +1,9 @@
# govoplan-calendar # govoplan-calendar
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware. GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware.
## Ownership ## Ownership
@@ -36,9 +40,28 @@ 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.
Open-Xchange is available as an explicit profile over that CalDAV engine. The
calendar dialog can retain optional connector-profile, identity/group-mapping,
and resource-calendar references. Resource bindings mark the collection as a
resource calendar. Optional connector modules can configure the same profile
through the Core-mediated `calendar.externalProfiles` capability; Calendar has
no hard dependency on Connectors, IDM, or Access mapping implementations.
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
@@ -47,12 +70,25 @@ write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and
ETag preconditions. If a remote resource changed, the local mutation is rejected ETag preconditions. If a remote resource changed, the local mutation is rejected
and the user must sync before retrying. A calendar-owned task, and the user must sync before retrying. A calendar-owned task,
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or `govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
cron-style scheduler. cron-style scheduler. Due-source and outbox-dispatch HTTP routes require a
service-account principal and are not interactive administration actions.
This is not yet a full CalDAV network server. Scheduling inbox/outbox behavior, Calendar exposes collection and credential management, sync status/actions,
CalDAV server endpoints, UI credential management, and full user-facing bounded outbound-change diagnostics and guarded retry/reconcile/discard recovery,
recurrence editing remain follow-up work. The backend now includes a free/busy durable per-user view preferences, recurrence expansion, detached occurrence
API primitive for scheduling and appointment modules. overrides, instance/series editing, and a free/busy API primitive for scheduling
and appointment modules. It is not yet a CalDAV network server: external clients
cannot use GovOPlaN itself as their CalDAV endpoint, and scheduling inbox/outbox
delivery remains owned by the scheduling and mail integration work.
Calendar also publishes `privacy.dsar.calendar`. Core's governed
data-subject-request workflow can use it to find tenant-scoped organizer,
attendee, preference, synchronization, outbox, and migration metadata. The
provider isolates the matching party and omits raw ICS, connector locators,
credentials, tokens, worker claims, and unrelated attendees. It classifies
synchronized and correlated state as retained evidence, leaves shared event
changes for manual review, and can safely delete only the subject's personal
view preference after revalidating tenant and ownership.
## Development ## Development
+205 -6
View File
@@ -6,7 +6,7 @@
- calendar collections - calendar collections
- VEVENT storage and iCalendar import/export - VEVENT storage and iCalendar import/export
- event recurrence data, recurrence exceptions, and future recurrence expansion - event recurrence data, recurrence expansion, and recurrence exceptions
- availability and free/busy semantics - availability and free/busy semantics
- resources such as rooms, shared equipment, and service desks - resources such as rooms, shared equipment, and service desks
- groupware calendar adapter boundaries, including CalDAV and Open-Xchange - groupware calendar adapter boundaries, including CalDAV and Open-Xchange
@@ -22,14 +22,204 @@ The first standalone module provides:
- normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag - normalized query fields: start, end, summary, location, all-day flag, status, transparency, classification, calendar ID, UID, recurrence ID, sequence, source, and ETag
- iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export - iCalendar preservation: raw VEVENT properties, parameters, and generated `text/calendar` export
- 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 - bounded occurrence expansion with RRULE, RDATE, EXDATE, detached overrides,
instance/series editing, and free/busy reconciliation
- durable per-user calendar view preferences exposed through the platform
Settings surface
- 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
- an Open-Xchange profile over the CalDAV transport, with explicit connector,
IDM/group-mapping, and resource-calendar references
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 implementation is not yet a full CalDAV network server. It is the internal
calendar storage, recurrence, sync, availability, and UI foundation on which
scheduling inbox/outbox behavior and CalDAV server endpoints can be built.
## Open-Xchange profile
Open-Xchange is an explicit external profile backed by Calendar's CalDAV
engine. A profile therefore receives the same bounded discovery, VEVENT
round-trip, RRULE/RDATE/EXDATE and detached-exception handling, sync-token/ctag
tracking, per-resource ETag conflict detection, and durable two-way outbox as a
generic CalDAV source. Calendar's availability capability reads the resulting
projection, so free/busy and collision checks use the same recurrence-aware
event set. Availability is current as of the source's visible last successful
sync; GovOPlaN does not claim live Open-Xchange state while a source is stale.
The source metadata retains three optional, non-secret bindings:
- `connector_profile_ref` links the endpoint to a Connectors-owned inventory or
deployment profile.
- `identity_mapping_ref` links attendee/group identifiers to an IDM- or
Access-governed mapping without importing either module.
- `resource_calendar_ref` identifies an Open-Xchange room/equipment calendar;
the corresponding Calendar collection is owned as a `resource`.
The `calendar.externalProfiles` capability lets an optional connector configure
the same profile through a Core contract. Direct Calendar setup remains
available when Connectors is absent. The connector never supplies a Calendar
event model and the references never contain credentials. Open-Xchange
credentials remain a reusable credential-envelope reference or a
Calendar-owned encrypted credential. The WebUI exposes Open-Xchange as a source
type and reuses CalDAV discovery; deployments must enter a DAV endpoint that
resolves to the intended collection.
## 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 open **Outbound changes** from a synchronized calendar's settings to
inspect a bounded queue and use only the retry, reconcile, or discard actions
that are valid for the latest resource generation. Dispatch and due-source
routes are worker-only and reject interactive sessions even when the account is
an administrator. 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.
The UI requires a separate destructive confirmation and explains that accepting
remote may lose the unresolved local desired state. Disabled and inbound-only
sources still permit discard when the retained source belongs to the tenant,
but they cannot be retried or reconciled until made writable again. Missing
sources, active worker leases, stale generations, and already-resolved rows
expose diagnostics instead of unsafe action buttons.
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.
### Runtime provider state
Calendar registers tenant-aware runtime state for
`calendar.caldav_sync`. Each active CalDAV source is projected by a stable
`calendar:sync-source:<id>` reference with its effective source-authority mode,
enabled state, health, freshness, unresolved-conflict state, recovery readiness,
last successful synchronization, and bounded outbox counts. Collection URLs,
usernames, credential references, remote resource paths, and error text are not
included.
Docs uses the projection to explain configured availability, Ops aggregates it
for operator inspection, and configuration-package preflight can require one
exact source binding. A source with dead or conflicting desired-state work
requires recovery attention; a never-synchronized source remains unknown rather
than being reported healthy.
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 and inbound-only destinations
are rejected. `remote_move` applies only between active, enabled, two-way
CalDAV sources. It is an administrator-authorized durable migration saga:
- the request requires the exact `MOVE REMOTE EVENTS` confirmation and a
retained authorization-evidence note;
- all destination resources must complete or reconcile their conditional PUTs
before any source DELETE can be leased;
- every source DELETE uses the ETag captured when the batch started, so a
concurrent remote edit becomes an explicit conflict rather than data loss;
- UIDs are preserved and target UID collisions stop the batch before mutation;
- both calendars, their sources, and moved events reject ordinary edits and
synchronization while the batch is active;
- progress, resource states, conflicts, authorization evidence, and actor
provenance remain queryable; and
- cancellation is available only before the first source DELETE attempt. It
finishes safe destination copies, retains the source resources, and restores
source synchronization. Once deletion starts, the batch must be reconciled.
The source collection is retired only after every source resource is confirmed
absent. A crash after a destination write is reconciled by semantic ICS content
before retry, preserving the outbox's no-blind-repeat guarantee.
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
## Data-subject requests and retention boundaries
Calendar implements the optional `privacy.dsar.calendar` capability used by
Core's governed data-subject-request workflow. A search is bounded to the
effective tenant and matches normalized organizer or attendee email, direct
membership references, and independently corroborated namespaced Calendar
collection or event references. Only the matching party fragment is projected;
unrelated attendees and unrelated events in the same collection are not copied
into the case.
The projection includes safe event and collection context, personal view
preferences, subject-owned synchronization configuration, outbox outcomes, and
migration state. It never includes raw ICS or complete iCalendar objects,
collection URLs, remote resource hrefs or ETags, sync tokens, usernames,
credential references or ciphertext, idempotency keys, worker leases, provider
error text, or opaque metadata. An authorized reviewer must use Calendar's own
screens when excluded content is necessary to decide the request.
Synchronized, correlated, deleted, queued, and migrated state is classified as
retained evidence with an explicit reason. Local collections and events and
active credential metadata remain manual-review items because erasure can
affect recurrence, other attendees, institutional scheduling, remote systems,
and retention duties. The only executable provider action is deletion of the
subject's personal view preference; execution locks and revalidates its tenant
and owner and is idempotent. Event, attendee, credential, outbox, and migration
records are never mutated directly by the DSAR provider. Related meeting-poll,
mail, and delivery data remains owned by Scheduling, Poll, Mail, and their own
providers.
### Scheduling ### Scheduling
`govoplan-scheduling` should use calendar for: `govoplan-scheduling` should use calendar for:
@@ -74,6 +264,18 @@ Tasks/workflow remain owners of assignment, status, SLA logic, and completion se
Mail remains owner of SMTP/IMAP profiles and mailbox transport. Notifications remains owner of delivery channels and delivery policy. Mail remains owner of SMTP/IMAP profiles and mailbox transport. Notifications remains owner of delivery channels and delivery policy.
The versioned `calendar.invitations` capability is the concrete Campaign/Mail
boundary. Campaign renders and freezes one `METHOD:REQUEST` attachment per
recipient, then upserts the correlated VEVENT only after delivery acceptance.
Calendar owns attendee `PARTSTAT`, response timestamps, bounded evidence,
CalDAV outbox state, and batched correlation/summary queries. Mail can forward
`METHOD:REPLY` parts discovered by an authorized, checkpointed IMAP
delivery-status source. Replaying the same mailbox evidence is idempotent.
Campaign reports read current Calendar state and retain only the invitation
request and mirror result in their own delivery provenance. Recurring Campaign
invitation series remain a separate workflow rather than being inferred from
unrelated recipient rows.
### Documents And DMS ### Documents And DMS
`govoplan-dms` can link documents to events for: `govoplan-dms` can link documents to events for:
@@ -113,9 +315,6 @@ The connector boundary should hand calendar a configured profile and credentials
## Follow-Up Work ## Follow-Up Work
- Full recurrence expansion for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
- User-facing recurrence editing for RRULE, RDATE, EXDATE, RECURRENCE-ID, overridden instances, and detached instances.
- UI management for CalDAV credentials, sync status, sync direction, and conflict resolution.
- CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections. - CalDAV server endpoints if GovOPlaN should expose calendars to external clients rather than only syncing remote collections.
- Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model. - Open-Xchange adapter that maps OX calendars, attendees, resources, recurrence, and free/busy to the internal calendar model.
- Attendee RSVP workflow and mail/notification bridge. - Attendee RSVP workflow and mail/notification bridge.
+24
View File
@@ -0,0 +1,24 @@
# Calendar interface pattern migration
Calendar uses the platform workspace pattern without changing ownership of calendar or synchronization state.
## Surfaces
- `calendar.page` is the route-level workspace.
- `calendar.page.sidebar` contains calendar visibility and collection actions; `calendar.page.agenda` is its event list.
- `calendar.page.workspace` contains continuous, month, week, workweek, and day views.
- `calendar.event-editor` owns event create, edit, occurrence/series selection, and deletion confirmation.
- `calendar.collection-editor` owns local and external collection configuration. Its `calendar.sync-status`, `calendar.outbox`, and `calendar.migration` children expose synchronization state and recovery.
- `calendar.settings.preferences` and `calendar.widget.upcoming` remain composed Settings and Dashboard surfaces.
The backend and WebUI manifests publish the same identifiers and parent hierarchy so Views can filter the route and contributed surfaces consistently.
## Consequences and recovery
Event and collection drafts use the shared unsaved-change guard. A write that completes but whose refresh fails is not repeated blindly by Calendar synchronization workers; durable outbox and migration state remain the source of recovery evidence. Event deletion uses the shared confirmation dialog. Collection removal and destructive remote moves keep their specialized confirmation because they must expose event counts, transfer choices, authorization text, and evidence.
Unavailable consequential actions remain visible where possible and explain the missing permission, input, active write, or migration lock. Contextual help resolves through `govoplan-docs` when installed and otherwise uses the hosted documentation fallback.
## Optional boundaries
Calendar does not import optional Mail, Campaign, Scheduling, Notifications, Connectors, Audit, or Ops implementations. Integrations continue through declared capabilities, interfaces, and stable references. Local calendars and the Calendar route remain usable without those optional modules.
+8 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.5", "version": "0.1.24",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,14 +19,14 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.5", "@govoplan/core-webui": "^0.1.44",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1", "react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^7.3.6"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+4 -3
View File
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-calendar" name = "govoplan-calendar"
version = "0.1.5" version = "0.1.24"
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.46",
"govoplan-access>=0.1.5", "govoplan-access>=0.1.18",
"defusedxml>=0.7,<1",
"icalendar>=7.2", "icalendar>=7.2",
"python-dateutil>=2.9", "python-dateutil>=2.9",
] ]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.4" __version__ = "0.1.24"
+372 -15
View File
@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None: def first_child(element: Any, name: str) -> Any | None:
for child in element: for child in element:
if local_name(child.tag) == name: if local_name(child.tag) == name:
return child return child
return None return None
def first_child_text(element: ElementTree.Element, name: str) -> str | None: def first_child_text(element: Any, name: str) -> str | None:
found = first_child(element, name) found = first_child(element, name)
if found is None or found.text is None: if found is None or found.text is None:
return None return None
return found.text.strip() return found.text.strip()
def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]: def child_elements(element: Any, name: str) -> list[Any]:
return [child for child in element if local_name(child.tag) == name] return [child for child in element if local_name(child.tag) == name]
def nested_href_texts(element: 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,919 @@
from __future__ import annotations
import hashlib
from collections import Counter
from collections.abc import Mapping, Sequence
from datetime import datetime
from types import SimpleNamespace
from sqlalchemy.orm import Session
from govoplan_core.core.calendar import (
CalendarCapabilityError,
CalendarEventRef,
CalendarEventReleaseRef,
CalendarEventRequest,
CalendarExternalProfileProvider,
CalendarExternalProfileRef,
CalendarExternalProfileRequest,
CalendarInvitationAttendeeRequest,
CalendarInvitationCalendarRef,
CalendarInvitationProvider,
CalendarInvitationRef,
CalendarInvitationRequest,
CalendarSchedulingProvider,
)
from govoplan_core.security.time import utc_now
from govoplan_calendar.backend.db.models import (
CalendarEvent,
CalendarSyncSource,
)
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, parse_vevents
from govoplan_calendar.backend.schemas import (
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
CalendarSyncSourceCreateRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
create_sync_source,
create_event,
delete_event,
list_calendars as list_calendar_collections,
list_freebusy,
update_event,
)
_PARTICIPATION_STATUSES = {
"NEEDS-ACTION",
"ACCEPTED",
"DECLINED",
"TENTATIVE",
"DELEGATED",
}
_OPEN_XCHANGE_PROFILE_KIND = "open_xchange"
def _external_state(event: CalendarEvent) -> tuple[str, str | None]:
caldav_metadata = (event.metadata_ or {}).get("caldav")
if not isinstance(caldav_metadata, dict):
return "local", None
state = str(caldav_metadata.get("external_state") or "queued")
operation_id = caldav_metadata.get("outbox_operation_id")
return state, str(operation_id) if operation_id else None
def _attendee_address(item: Mapping[str, object]) -> str:
raw = item.get("email") or item.get("address") or item.get("value") or ""
value = str(raw).strip()
if value.casefold().startswith("mailto:"):
value = value[7:]
return value.casefold()
def _attendee_record(
attendee: CalendarInvitationAttendeeRequest,
) -> dict[str, object]:
status = attendee.participation_status.strip().upper()
if status not in _PARTICIPATION_STATUSES:
raise CalendarCapabilityError(
f"Unsupported attendee participation status: {status}"
)
params: dict[str, list[str]] = {
"ROLE": [attendee.role.strip().upper() or "REQ-PARTICIPANT"],
"PARTSTAT": [status],
"RSVP": ["TRUE" if attendee.rsvp else "FALSE"],
}
if attendee.name:
params["CN"] = [attendee.name]
return {
"value": f"mailto:{attendee.address.strip()}",
"params": params,
"response_at": None,
}
def _attendee_status(item: Mapping[str, object]) -> str:
direct = item.get("participation_status") or item.get("response_status")
if direct:
return str(direct).upper()
params = item.get("params")
if isinstance(params, Mapping):
value = params.get("PARTSTAT")
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return str(value[0]).upper() if value else "NEEDS-ACTION"
if value:
return str(value).upper()
return "NEEDS-ACTION"
def _merge_attendees(
current: Sequence[Mapping[str, object]],
requested: Sequence[CalendarInvitationAttendeeRequest],
) -> list[dict[str, object]]:
prior = {_attendee_address(item): item for item in current}
merged: list[dict[str, object]] = []
for attendee in requested:
record = _attendee_record(attendee)
existing = prior.get(attendee.address.strip().casefold())
if existing is not None and _attendee_status(existing) != "NEEDS-ACTION":
params = dict(record["params"])
params["PARTSTAT"] = [_attendee_status(existing)]
record["params"] = params
record["response_at"] = existing.get("response_at")
if existing.get("response_evidence") is not None:
record["response_evidence"] = existing["response_evidence"]
merged.append(record)
return merged
def _invitation_uid(request: CalendarInvitationRequest) -> str:
identity = ":".join(
(
request.source_module,
request.source_resource_type,
request.source_resource_id or "",
request.correlation_id,
)
)
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:40]
return f"invitation-{digest}@govoplan.local"
def _invitation_metadata(
request: CalendarInvitationRequest,
previous: Mapping[str, object] | None = None,
) -> dict[str, object]:
metadata = dict(previous or {})
metadata.update(dict(request.metadata))
metadata["calendar_invitation"] = {
"correlation_id": request.correlation_id,
"source_module": request.source_module,
"source_resource_type": request.source_resource_type,
"source_resource_id": request.source_resource_id,
}
return metadata
def _invitation_ref(event: CalendarEvent) -> CalendarInvitationRef:
state, operation_id = _external_state(event)
return CalendarInvitationRef(
event_id=event.id,
calendar_id=event.calendar_id,
uid=event.uid,
correlation_id=event.correlation_key or "",
source_module=event.producer_module or "unknown",
source_resource_type=event.producer_resource_type or "unknown",
source_resource_id=event.producer_resource_id,
attendees=tuple(dict(item) for item in event.attendees or []),
external_state=state,
outbox_operation_id=operation_id,
reply_ingress="icalendar-reply",
degraded_reasons=(
"Recurring campaign invitations require a separate series workflow.",
),
)
def _invitation_render_event(request: CalendarInvitationRequest) -> SimpleNamespace:
return SimpleNamespace(
uid=_invitation_uid(request),
recurrence_id=None,
sequence=0,
summary=request.summary,
description=request.description,
location=request.location,
status="CONFIRMED",
transparency="OPAQUE",
classification=request.classification,
start_at=request.start_at,
end_at=request.end_at,
duration_seconds=None,
all_day=False,
timezone=request.timezone,
organizer=dict(request.organizer) if request.organizer else None,
attendees=[_attendee_record(item) for item in request.attendees],
categories=list(request.categories),
rrule=None,
rdate=[],
exdate=[],
reminders=[],
attachments=[],
related_to=[],
icalendar={"method": "REQUEST"},
)
def _calendar_source(
session: Session,
*,
tenant_id: str,
calendar_id: str,
) -> CalendarSyncSource | None:
return (
session.query(CalendarSyncSource)
.filter(
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.calendar_id == calendar_id,
CalendarSyncSource.deleted_at.is_(None),
)
.order_by(CalendarSyncSource.created_at.desc())
.first()
)
def _invitation_summary(events: Sequence[CalendarEvent]) -> dict[str, object]:
attendee_statuses: Counter[str] = Counter()
external_states: Counter[str] = Counter()
degraded_reasons: set[str] = set()
replied = 0
for event in events:
ref = _invitation_ref(event)
external_states[ref.external_state] += 1
degraded_reasons.update(ref.degraded_reasons)
for attendee in ref.attendees:
status = _attendee_status(attendee)
attendee_statuses[status] += 1
if status != "NEEDS-ACTION":
replied += 1
attendee_total = sum(attendee_statuses.values())
return {
"available": True,
"invitation_count": len(events),
"attendee_count": attendee_total,
"response_count": replied,
"pending_response_count": attendee_statuses.get("NEEDS-ACTION", 0),
"by_participation_status": dict(attendee_statuses),
"by_external_state": dict(external_states),
"reply_ingress": "icalendar-reply",
"recurrence_supported": False,
"degraded_reasons": sorted(degraded_reasons),
}
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
external_state, outbox_operation_id = _external_state(event)
return CalendarEventRef(
id=event.id,
calendar_id=event.calendar_id,
uid=event.uid,
external_state=external_state,
outbox_operation_id=outbox_operation_id,
)
def promote_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
event_id: str,
request: CalendarEventRequest,
) -> CalendarEventRef:
try:
payload = CalendarEventUpdateRequest(
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),
)
event = update_event(
session,
tenant_id=tenant_id,
user_id=user_id,
event_id=event_id,
payload=payload,
)
except (CalendarError, TypeError, ValueError) as exc:
raise CalendarCapabilityError(str(exc)) from exc
external_state, outbox_operation_id = _external_state(event)
return CalendarEventRef(
id=event.id,
calendar_id=event.calendar_id,
uid=event.uid,
external_state=external_state,
outbox_operation_id=outbox_operation_id,
)
def release_event(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
event_id: str,
) -> CalendarEventReleaseRef:
if not isinstance(session, Session):
raise CalendarCapabilityError("Calendar release requires a database session")
event = (
session.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.id == event_id,
)
.first()
)
if event is None:
return CalendarEventReleaseRef(
event_id=event_id,
already_released=True,
external_state="not_found",
)
was_released = event.deleted_at is not None
if not was_released:
try:
delete_event(
session,
tenant_id=tenant_id,
event_id=event_id,
user_id=user_id,
)
except CalendarError as exc:
raise CalendarCapabilityError(str(exc)) from exc
session.flush()
external_state, outbox_operation_id = _external_state(event)
return CalendarEventReleaseRef(
event_id=event_id,
already_released=was_released,
external_state=(
external_state if external_state != "local" else "local_released"
),
outbox_operation_id=outbox_operation_id,
)
class SqlCalendarExternalProfileProvider(CalendarExternalProfileProvider):
"""Configure groupware profiles while Calendar retains event semantics."""
def supported_profiles(self) -> tuple[Mapping[str, object], ...]:
return (
{
"profile_kind": _OPEN_XCHANGE_PROFILE_KIND,
"transport_kind": "caldav",
"operations": (
"discover",
"read",
"write",
"delete",
"freebusy",
),
"resource_calendars": True,
"recurrence": True,
"conflict_tokens": ("sync-token", "ctag", "etag"),
},
)
def configure_profile(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarExternalProfileRequest,
) -> CalendarExternalProfileRef:
db = self._session(session)
profile_kind = request.profile_kind.strip().lower().replace("-", "_")
if profile_kind != _OPEN_XCHANGE_PROFILE_KIND:
raise CalendarCapabilityError(
f"Unsupported external calendar profile: {request.profile_kind}"
)
metadata = dict(request.metadata)
metadata.update(
{
"integration_profile": _OPEN_XCHANGE_PROFILE_KIND,
"transport_kind": "caldav",
"connector_profile_ref": self._reference(
request.connector_profile_ref,
label="connector_profile_ref",
),
"identity_mapping_ref": self._reference(
request.identity_mapping_ref,
label="identity_mapping_ref",
),
"resource_calendar_ref": self._reference(
request.resource_calendar_ref,
label="resource_calendar_ref",
),
}
)
metadata = {key: value for key, value in metadata.items() if value is not None}
try:
payload = CalendarSyncSourceCreateRequest(
source_kind="caldav",
calendar_id=request.calendar_id,
collection_url=request.endpoint_url,
display_name=request.display_name,
auth_type=request.auth_type,
username=request.username,
credential_ref=request.credential_ref,
sync_enabled=request.sync_enabled,
sync_interval_seconds=request.sync_interval_seconds,
sync_direction=request.sync_direction,
conflict_policy=request.conflict_policy,
metadata=metadata,
)
source = create_sync_source(
db,
tenant_id=tenant_id,
user_id=user_id,
payload=payload,
)
except (CalendarError, TypeError, ValueError) as exc:
raise CalendarCapabilityError(str(exc)) from exc
if request.resource_calendar_ref:
source.calendar.owner_type = "resource"
source.calendar.owner_id = request.resource_calendar_ref.strip()
db.flush()
return CalendarExternalProfileRef(
source_id=source.id,
calendar_id=source.calendar_id,
profile_kind=_OPEN_XCHANGE_PROFILE_KIND,
transport_kind="caldav",
connector_profile_ref=request.connector_profile_ref,
identity_mapping_ref=request.identity_mapping_ref,
resource_calendar_ref=request.resource_calendar_ref,
)
@staticmethod
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise CalendarCapabilityError(
"External calendar profiles require a SQLAlchemy Session."
)
return session
@staticmethod
def _reference(value: str | None, *, label: str) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
return None
if len(normalized) > 255:
raise CalendarCapabilityError(f"{label} must not exceed 255 characters.")
return normalized
class SqlCalendarInvitationProvider(CalendarInvitationProvider):
def list_calendars(
self,
session: object,
*,
tenant_id: str,
user_id: str | None = None,
group_ids: Sequence[str] = (),
can_admin: bool = False,
) -> tuple[CalendarInvitationCalendarRef, ...]:
db = self._session(session)
calendars = list_calendar_collections(
db,
tenant_id=tenant_id,
user_id=user_id,
group_ids=group_ids,
can_admin=can_admin,
)
result: list[CalendarInvitationCalendarRef] = []
for calendar in calendars:
source = _calendar_source(
db,
tenant_id=tenant_id,
calendar_id=calendar.id,
)
writable = source is None or (
source.source_kind == "caldav"
and source.sync_enabled
and source.sync_direction == "two_way"
)
result.append(
CalendarInvitationCalendarRef(
id=calendar.id,
name=calendar.name,
color=calendar.color,
timezone=calendar.timezone,
source_kind=source.source_kind if source is not None else "local",
writable=writable,
)
)
return tuple(result)
def render_invitation(self, request: CalendarInvitationRequest) -> str:
self._validate_request(request)
try:
return event_to_ics(_invitation_render_event(request))
except (ICalendarError, TypeError, ValueError) as exc:
raise CalendarCapabilityError(str(exc)) from exc
def upsert_invitation(
self,
session: object,
*,
tenant_id: str,
user_id: str | None,
request: CalendarInvitationRequest,
) -> CalendarInvitationRef:
db = self._session(session)
self._validate_request(request)
existing = (
db.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.correlation_key == request.correlation_id,
CalendarEvent.deleted_at.is_(None),
)
.one_or_none()
)
attendees = _merge_attendees(
existing.attendees if existing is not None else (),
request.attendees,
)
metadata = _invitation_metadata(
request,
existing.metadata_ if existing is not None else None,
)
try:
if existing is None:
event = create_event(
db,
tenant_id=tenant_id,
user_id=user_id,
payload=CalendarEventCreateRequest(
calendar_id=request.calendar_id,
uid=_invitation_uid(request),
summary=request.summary,
description=request.description,
location=request.location,
classification=request.classification,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
organizer=(
dict(request.organizer) if request.organizer else None
),
attendees=attendees,
categories=list(request.categories),
metadata=metadata,
),
)
else:
event = update_event(
db,
tenant_id=tenant_id,
user_id=user_id,
event_id=existing.id,
payload=CalendarEventUpdateRequest(
calendar_id=request.calendar_id,
summary=request.summary,
description=request.description,
location=request.location,
classification=request.classification,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
organizer=(
dict(request.organizer) if request.organizer else None
),
attendees=attendees,
categories=list(request.categories),
metadata=metadata,
),
)
except (CalendarError, TypeError, ValueError) as exc:
raise CalendarCapabilityError(str(exc)) from exc
event.correlation_key = request.correlation_id
event.producer_module = request.source_module
event.producer_resource_type = request.source_resource_type
event.producer_resource_id = request.source_resource_id
db.flush()
return _invitation_ref(event)
def get_invitation(
self,
session: object,
*,
tenant_id: str,
correlation_id: str,
) -> CalendarInvitationRef | None:
event = (
self._session(session)
.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.correlation_key == correlation_id,
CalendarEvent.deleted_at.is_(None),
)
.one_or_none()
)
return _invitation_ref(event) if event is not None else None
def get_invitations(
self,
session: object,
*,
tenant_id: str,
correlation_ids: Sequence[str],
) -> dict[str, CalendarInvitationRef]:
normalized = tuple(
dict.fromkeys(
str(value).strip()
for value in correlation_ids
if str(value).strip()
)
)
if not normalized:
return {}
if len(normalized) > 500:
raise CalendarCapabilityError(
"At most 500 invitation correlation IDs may be queried at once."
)
events = (
self._session(session)
.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.correlation_key.in_(normalized),
CalendarEvent.deleted_at.is_(None),
)
.all()
)
return {
event.correlation_key: _invitation_ref(event)
for event in events
if event.correlation_key
}
def summarize_invitations(
self,
session: object,
*,
tenant_id: str,
source_module: str,
source_resource_type: str,
source_resource_id: str | None,
) -> Mapping[str, object]:
query = self._session(session).query(CalendarEvent).filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.producer_module == source_module,
CalendarEvent.producer_resource_type == source_resource_type,
CalendarEvent.deleted_at.is_(None),
)
query = query.filter(
CalendarEvent.producer_resource_id == source_resource_id
if source_resource_id is not None
else CalendarEvent.producer_resource_id.is_(None)
)
return _invitation_summary(query.all())
def record_response(
self,
session: object,
*,
tenant_id: str,
attendee_address: str,
participation_status: str,
correlation_id: str | None = None,
uid: str | None = None,
responded_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> CalendarInvitationRef:
if bool(correlation_id) == bool(uid):
raise CalendarCapabilityError(
"Specify exactly one invitation correlation_id or uid."
)
status = participation_status.strip().upper()
if status not in _PARTICIPATION_STATUSES - {"NEEDS-ACTION"}:
raise CalendarCapabilityError(
f"Unsupported attendee participation status: {status}"
)
db = self._session(session)
query = db.query(CalendarEvent).filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.deleted_at.is_(None),
)
query = query.filter(
CalendarEvent.correlation_key == correlation_id
if correlation_id
else CalendarEvent.uid == uid
)
event = query.one_or_none()
if event is None:
raise CalendarCapabilityError("Calendar invitation was not found.")
target = attendee_address.strip().casefold()
attendees: list[dict[str, object]] = []
matched = False
changed = False
response_time = responded_at or utc_now()
for raw in event.attendees or []:
item = dict(raw)
if _attendee_address(item) == target:
current_evidence = item.get("response_evidence")
same_evidence = (
bool(evidence)
and isinstance(current_evidence, Mapping)
and dict(current_evidence) == dict(evidence)
)
if _attendee_status(item) == status and same_evidence:
matched = True
attendees.append(item)
continue
params = dict(item.get("params") or {})
params["PARTSTAT"] = [status]
item["params"] = params
item["response_at"] = response_time.isoformat()
item["response_evidence"] = dict(evidence or {})
matched = True
changed = True
attendees.append(item)
if not matched:
raise CalendarCapabilityError(
"The reply address is not an attendee of this invitation."
)
if not changed:
return _invitation_ref(event)
metadata = dict(event.metadata_ or {})
invitation_metadata = dict(metadata.get("calendar_invitation") or {})
invitation_metadata["last_response_at"] = response_time.isoformat()
metadata["calendar_invitation"] = invitation_metadata
try:
event = update_event(
db,
tenant_id=tenant_id,
user_id=None,
event_id=event.id,
payload=CalendarEventUpdateRequest(
attendees=attendees,
metadata=metadata,
),
)
except CalendarError as exc:
raise CalendarCapabilityError(str(exc)) from exc
return _invitation_ref(event)
def record_icalendar_reply(
self,
session: object,
*,
tenant_id: str,
icalendar: str,
received_at: datetime | None = None,
evidence: Mapping[str, object] | None = None,
) -> tuple[CalendarInvitationRef, ...]:
try:
replies = parse_vevents(icalendar)
except ICalendarError as exc:
raise CalendarCapabilityError(str(exc)) from exc
recorded: list[CalendarInvitationRef] = []
for reply in replies:
metadata = reply.get("icalendar")
method = metadata.get("method") if isinstance(metadata, Mapping) else None
if str(method or "").upper() != "REPLY":
continue
uid = str(reply.get("uid") or "").strip()
if not uid:
continue
event = (
self._session(session)
.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.uid == uid,
CalendarEvent.correlation_key.is_not(None),
CalendarEvent.producer_module == "campaigns",
CalendarEvent.deleted_at.is_(None),
)
.order_by(CalendarEvent.created_at.asc())
.first()
)
if event is None:
continue
for attendee in reply.get("attendees") or []:
if not isinstance(attendee, Mapping):
continue
status = _attendee_status(attendee)
address = _attendee_address(attendee)
if (
not address
or status not in _PARTICIPATION_STATUSES - {"NEEDS-ACTION"}
):
continue
response_evidence = dict(evidence or {})
response_evidence.update(
{
"method": "REPLY",
"uid": uid,
"sequence": int(reply.get("sequence") or 0),
}
)
try:
recorded.append(
self.record_response(
session,
tenant_id=tenant_id,
attendee_address=address,
participation_status=status,
uid=uid,
responded_at=received_at,
evidence=response_evidence,
)
)
except CalendarCapabilityError as exc:
if "not an attendee" not in str(exc):
raise
return tuple(recorded)
@staticmethod
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise CalendarCapabilityError(
"Calendar invitations require a SQLAlchemy Session."
)
return session
@staticmethod
def _validate_request(request: CalendarInvitationRequest) -> None:
for label, value, maximum in (
("correlation_id", request.correlation_id, 255),
("source_module", request.source_module, 100),
("source_resource_type", request.source_resource_type, 100),
("summary", request.summary, 500),
):
if not value.strip() or len(value) > maximum:
raise CalendarCapabilityError(
f"Invitation {label} must contain 1 to {maximum} characters."
)
if request.source_resource_id and len(request.source_resource_id) > 255:
raise CalendarCapabilityError(
"Invitation source_resource_id must not exceed 255 characters."
)
if not request.attendees:
raise CalendarCapabilityError(
"Calendar invitations require at least one attendee."
)
addresses = [item.address.strip().casefold() for item in request.attendees]
if any(not item for item in addresses) or len(set(addresses)) != len(addresses):
raise CalendarCapabilityError(
"Invitation attendee addresses must be non-empty and unique."
)
+177 -10
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid import uuid
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
@@ -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)
@@ -52,13 +52,30 @@ class CalendarEvent(Base, TimestampMixin):
UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"), UniqueConstraint("calendar_id", "uid", "recurrence_id", name="uq_calendar_events_calendar_uid_recurrence"),
Index("ix_calendar_events_range", "tenant_id", "calendar_id", "start_at", "end_at"), Index("ix_calendar_events_range", "tenant_id", "calendar_id", "start_at", "end_at"),
Index("ix_calendar_events_tenant_uid", "tenant_id", "uid"), Index("ix_calendar_events_tenant_uid", "tenant_id", "uid"),
Index(
"ix_calendar_events_tenant_correlation",
"tenant_id",
"correlation_key",
),
Index("ix_calendar_events_tenant_status", "tenant_id", "status"), Index("ix_calendar_events_tenant_status", "tenant_id", "status"),
) )
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)
correlation_key: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
producer_module: Mapped[str | None] = mapped_column(
String(100), nullable=True, index=True
)
producer_resource_type: Mapped[str | None] = mapped_column(
String(100), nullable=True
)
producer_resource_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) recurrence_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False) sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
summary: Mapped[str] = mapped_column(String(500), nullable=False) summary: Mapped[str] = mapped_column(String(500), nullable=False)
@@ -86,14 +103,44 @@ 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)
calendar: Mapped[CalendarCollection] = relationship(back_populates="events") calendar: Mapped[CalendarCollection] = relationship(back_populates="events")
class CalendarViewPreference(Base, TimestampMixin):
__tablename__ = "calendar_view_preferences"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"user_id",
name="uq_calendar_view_preferences_tenant_user",
),
)
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,
)
user_id: Mapped[str] = mapped_column(
ForeignKey("access_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
dim_weekends: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
dim_off_hours: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
workday_start_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
workday_end_hour: Mapped[int | None] = mapped_column(Integer, nullable=True)
continuous_virtualization: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
continuous_overscan_weeks: Mapped[int | None] = mapped_column(Integer, nullable=True)
alternate_continuous_months: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
class CalendarSyncSource(Base, TimestampMixin): class CalendarSyncSource(Base, TimestampMixin):
__tablename__ = "calendar_sync_sources" __tablename__ = "calendar_sync_sources"
__table_args__ = ( __table_args__ = (
@@ -110,7 +157,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 +189,133 @@ 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()
class CalendarMigrationBatch(Base, TimestampMixin):
"""Durable destructive remote-move saga and its authorization evidence."""
__tablename__ = "calendar_migration_batches"
__table_args__ = (
Index("ix_calendar_migration_batches_tenant_status", "tenant_id", "status", "created_at"),
Index("ix_calendar_migration_batches_calendars", "tenant_id", "source_calendar_id", "target_calendar_id"),
)
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)
migration_kind: Mapped[str] = mapped_column(String(30), default="remote_move", nullable=False)
status: Mapped[str] = mapped_column(String(30), default="active", nullable=False, index=True)
phase: Mapped[str] = mapped_column(String(40), default="copying_destination", nullable=False, index=True)
source_calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="RESTRICT"), nullable=False, index=True)
target_calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="RESTRICT"), nullable=False, index=True)
source_sync_source_id: Mapped[str] = mapped_column(ForeignKey("calendar_sync_sources.id", ondelete="RESTRICT"), nullable=False, index=True)
target_sync_source_id: Mapped[str] = mapped_column(ForeignKey("calendar_sync_sources.id", ondelete="RESTRICT"), nullable=False, index=True)
total_resources: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
total_events: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
created_by_api_key_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
authorization_evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
cancellation_evidence: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
last_error: Mapped[str | None] = mapped_column(Text)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
resources: Mapped[list["CalendarMigrationResource"]] = relationship(back_populates="batch", cascade="all, delete-orphan")
class CalendarMigrationResource(Base, TimestampMixin):
__tablename__ = "calendar_migration_resources"
__table_args__ = (
UniqueConstraint("batch_id", "source_href", name="uq_calendar_migration_resources_batch_href"),
Index("ix_calendar_migration_resources_batch_status", "batch_id", "status"),
)
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)
batch_id: Mapped[str] = mapped_column(ForeignKey("calendar_migration_batches.id", ondelete="CASCADE"), nullable=False, index=True)
source_href: Mapped[str] = mapped_column(String(1000), nullable=False)
source_expected_etag: Mapped[str] = mapped_column(String(255), nullable=False)
destination_href: Mapped[str] = mapped_column(String(1000), nullable=False)
event_ids: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
status: Mapped[str] = mapped_column(String(30), default="copy_pending", nullable=False, index=True)
destination_operation_id: Mapped[str | None] = mapped_column(ForeignKey("calendar_outbox_operations.id", ondelete="SET NULL"), nullable=True, unique=True)
source_delete_operation_id: Mapped[str | None] = mapped_column(ForeignKey("calendar_outbox_operations.id", ondelete="SET NULL"), nullable=True, unique=True)
last_error: Mapped[str | None] = mapped_column(Text)
batch: Mapped[CalendarMigrationBatch] = relationship(back_populates="resources")
__all__ = [
"CalendarCollection",
"CalendarEvent",
"CalendarMigrationBatch",
"CalendarMigrationResource",
"CalendarOutboxOperation",
"CalendarSyncCredential",
"CalendarSyncSource",
"CalendarViewPreference",
"new_uuid",
]
@@ -0,0 +1,888 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from sqlalchemy import Text, cast, func, or_
from sqlalchemy.orm import Session
from govoplan_calendar.backend.db.models import (
CalendarCollection,
CalendarEvent,
CalendarMigrationBatch,
CalendarMigrationResource,
CalendarOutboxOperation,
CalendarSyncCredential,
CalendarSyncSource,
CalendarViewPreference,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
CALENDAR_DSAR_CAPABILITY = dsar_capability_name("calendar")
_MAX_RECORDS = 5_000
_SECRET_PARTS = (
"authorization",
"credential",
"idempotency",
"lease",
"password",
"secret",
"token",
)
_LOCATOR_PARTS = ("href", "path", "url")
class CalendarDsarProvider:
provider_id = "calendar"
module_id = "calendar"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
subject_user_id = _subject_user_id(subject)
subject_email = _subject_email(subject)
references = _calendar_references(subject)
if subject_user_id is None and subject_email is None and not references:
return ()
records: list[DsarRecordRef] = []
def append(record: DsarRecordRef) -> None:
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Calendar DSAR match limit exceeded; narrow the subject selectors."
)
records.append(record)
events = _matching_events(
db,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
subject_email=subject_email,
event_id=references.get("event"),
)
event_ids = {row.id for row in events}
collection_ids = {row.calendar_id for row in events}
if references.get("collection"):
collection_ids.add(references["collection"])
collections = _matching_collections(
db,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
collection_ids=collection_ids,
)
collection_ids = {row.id for row in collections}
owned_collection_ids = {
row.id
for row in collections
if subject_user_id is not None
and (
(row.owner_type == "user" and row.owner_id == subject_user_id)
or row.created_by_user_id == subject_user_id
)
}
outbox_by_event = _outbox_by_event(
db,
tenant_id=tenant_id,
event_ids=event_ids,
)
for collection in collections:
match_fields = _matching_fields(
collection,
subject_user_id,
("created_by_user_id",),
)
if (
subject_user_id is not None
and collection.owner_type == "user"
and collection.owner_id == subject_user_id
):
match_fields.insert(0, "owner_id")
immutable = collection.deleted_at is not None
append(
_record(
"calendar_collection",
collection.id,
"calendar_collection",
collection.name,
{
"match_fields": match_fields,
"event_context": collection.id
in {row.calendar_id for row in events},
"slug": collection.slug,
"name": collection.name,
"description": collection.description,
"timezone": collection.timezone,
"color": collection.color,
"owner_type": collection.owner_type,
"visibility": collection.visibility,
"is_default": collection.is_default,
"deleted_at": _iso(collection.deleted_at),
},
observed_at=collection.updated_at,
immutable=immutable,
retention_reason=(
"Deleted collection state is retained until Calendar lifecycle cleanup completes."
if immutable
else None
),
source_path="/calendar",
)
)
for event in events:
organizer = _matching_party(event.organizer, subject_email)
attendees = _matching_parties(event.attendees, subject_email)
actor_fields = _matching_fields(
event,
subject_user_id,
("created_by_user_id", "updated_by_user_id"),
)
immutable = bool(
event.deleted_at
or event.source_kind != "local"
or event.correlation_key
or event.producer_module
or outbox_by_event.get(event.id)
)
append(
_record(
"calendar_event",
event.id,
"calendar_event",
event.summary,
{
"match_fields": actor_fields
+ (["organizer"] if organizer else [])
+ (["attendees"] if attendees else []),
"calendar_id": event.calendar_id,
"uid": event.uid,
"recurrence_id": event.recurrence_id,
"sequence": event.sequence,
"summary": event.summary,
"description": event.description,
"location": event.location,
"status": event.status,
"transparency": event.transparency,
"classification": event.classification,
"start_at": _iso(event.start_at),
"end_at": _iso(event.end_at),
"duration_seconds": event.duration_seconds,
"all_day": event.all_day,
"timezone": event.timezone,
"organizer": organizer,
"matching_attendees": attendees,
"categories": _safe_value(event.categories),
"rrule": _safe_value(event.rrule),
"rdate": _safe_value(event.rdate),
"exdate": _safe_value(event.exdate),
"reminders": _safe_value(event.reminders),
"attachment_count": len(event.attachments or ()),
"source_kind": event.source_kind,
"producer_module": event.producer_module,
"producer_resource_type": event.producer_resource_type,
"producer_resource_id": event.producer_resource_id,
"deleted_at": _iso(event.deleted_at),
},
observed_at=event.updated_at,
immutable=immutable,
retention_reason=(
"Synchronized, correlated, deleted, or externally queued event state is retained as Calendar execution evidence."
if immutable
else None
),
source_path="/calendar",
)
)
for operations in outbox_by_event.values():
for operation in operations:
append(
_record(
"calendar_outbox_operation",
operation.id,
"calendar_sync_evidence",
f"Calendar {operation.operation_kind} operation",
{
"event_id": operation.event_id,
"operation_kind": operation.operation_kind,
"payload_fingerprint": operation.payload_fingerprint,
"status": operation.status,
"attempt_count": operation.attempt_count,
"max_attempts": operation.max_attempts,
"available_at": _iso(operation.available_at),
"last_attempt_at": _iso(operation.last_attempt_at),
"completed_at": _iso(operation.completed_at),
"reconciled_at": _iso(operation.reconciled_at),
},
observed_at=operation.updated_at,
immutable=True,
retention_reason="Calendar outbox outcomes are synchronization and recovery evidence.",
)
)
self._append_user_resources(
db,
append=append,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
)
self._append_sync_and_migration_resources(
db,
append=append,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
owned_collection_ids=owned_collection_ids,
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del session
subject_user_id = _subject_user_id(subject)
actions: list[DsarErasureActionRef] = []
for record in records:
if (
record.provider_id != self.provider_id
or record.module_id != self.module_id
):
raise ValueError("Calendar DSAR received a foreign provider record.")
actions.append(
_action(
f"calendar:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
"retain" if record.immutable_evidence else "manual_review",
record,
f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
record.retention_reason
or "Calendar content can span recurrence, attendees, synchronized resources, and external recovery state; review it through Calendar lifecycle controls.",
executable=False,
)
)
if (
record.resource_type == "calendar_view_preference"
and "user_id" in record.data.get("match_fields", ())
and subject_user_id is not None
):
actions.append(
_action(
f"calendar:delete:calendar_view_preference:{record.resource_id}",
"delete",
record,
"Delete personal Calendar view preference",
"The personal presentation preference can be removed without changing events or synchronization evidence.",
executable=True,
irreversible=True,
metadata={
"subject_user_id": subject_user_id,
"tenant_id": tenant_id,
},
)
)
action_ids = [action.action_id for action in actions]
if len(action_ids) != len(set(action_ids)):
raise ValueError("Calendar DSAR produced duplicate action ids.")
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
subject_user_id = _subject_user_id(subject)
results: list[DsarExecutionResultRef] = []
for action in actions:
if (
subject_user_id is None
or action.provider_id != self.provider_id
or action.module_id != self.module_id
or action.metadata.get("subject_user_id") != subject_user_id
or action.metadata.get("tenant_id") != tenant_id
):
results.append(
_blocked(action, "The Calendar DSAR action is stale or invalid.")
)
continue
if action.action_id.startswith("calendar:delete:calendar_view_preference:"):
results.append(
_delete_view_preference(
db,
tenant_id=tenant_id,
subject_user_id=subject_user_id,
action=action,
request_id=request_id,
)
)
else:
results.append(
_blocked(action, "Calendar does not execute this action kind.")
)
db.flush()
return tuple(results)
def _append_user_resources(
self,
db: Session,
*,
append: object,
tenant_id: str,
subject_user_id: str | None,
) -> None:
if subject_user_id is None:
return
for preference in _bounded_rows(
db.query(CalendarViewPreference)
.filter(
CalendarViewPreference.tenant_id == tenant_id,
CalendarViewPreference.user_id == subject_user_id,
)
.order_by(CalendarViewPreference.id)
):
append( # type: ignore[operator]
_record(
"calendar_view_preference",
preference.id,
"personal_calendar_preference",
"Calendar view preference",
{
"match_fields": ["user_id"],
"dim_weekends": preference.dim_weekends,
"dim_off_hours": preference.dim_off_hours,
"workday_start_hour": preference.workday_start_hour,
"workday_end_hour": preference.workday_end_hour,
"continuous_virtualization": preference.continuous_virtualization,
"continuous_overscan_weeks": preference.continuous_overscan_weeks,
"alternate_continuous_months": preference.alternate_continuous_months,
},
observed_at=preference.updated_at,
source_path="/settings?section=calendar",
)
)
for credential in _bounded_rows(
db.query(CalendarSyncCredential)
.filter(
CalendarSyncCredential.tenant_id == tenant_id,
CalendarSyncCredential.created_by_user_id == subject_user_id,
)
.order_by(CalendarSyncCredential.id)
):
append( # type: ignore[operator]
_record(
"calendar_sync_credential",
credential.id,
"calendar_sync_configuration",
credential.label or "Calendar sync credential",
{
"match_fields": ["created_by_user_id"],
"credential_kind": credential.credential_kind,
"label": credential.label,
"deleted_at": _iso(credential.deleted_at),
},
observed_at=credential.updated_at,
immutable=credential.deleted_at is not None,
retention_reason=(
"Retired credential metadata remains synchronization governance evidence."
if credential.deleted_at is not None
else None
),
)
)
def _append_sync_and_migration_resources(
self,
db: Session,
*,
append: object,
tenant_id: str,
subject_user_id: str | None,
owned_collection_ids: set[str],
) -> None:
if owned_collection_ids:
for source in _bounded_rows(
db.query(CalendarSyncSource)
.filter(
CalendarSyncSource.tenant_id == tenant_id,
CalendarSyncSource.calendar_id.in_(owned_collection_ids),
)
.order_by(CalendarSyncSource.id)
):
append( # type: ignore[operator]
_record(
"calendar_sync_source",
source.id,
"calendar_sync_configuration",
source.display_name or source.source_kind,
{
"calendar_id": source.calendar_id,
"source_kind": source.source_kind,
"display_name": source.display_name,
"auth_type": source.auth_type,
"sync_enabled": source.sync_enabled,
"sync_interval_seconds": source.sync_interval_seconds,
"sync_direction": source.sync_direction,
"conflict_policy": source.conflict_policy,
"last_attempt_at": _iso(source.last_attempt_at),
"last_synced_at": _iso(source.last_synced_at),
"next_sync_at": _iso(source.next_sync_at),
"last_status": source.last_status,
"deleted_at": _iso(source.deleted_at),
},
observed_at=source.updated_at,
immutable=True,
retention_reason="External source configuration and synchronization state require coordinated Calendar review.",
)
)
migration_conditions = []
if subject_user_id is not None:
migration_conditions.append(
CalendarMigrationBatch.created_by_user_id == subject_user_id
)
if owned_collection_ids:
migration_conditions.extend(
(
CalendarMigrationBatch.source_calendar_id.in_(owned_collection_ids),
CalendarMigrationBatch.target_calendar_id.in_(owned_collection_ids),
)
)
if not migration_conditions:
return
batches = _bounded_rows(
db.query(CalendarMigrationBatch)
.filter(
CalendarMigrationBatch.tenant_id == tenant_id,
or_(*migration_conditions),
)
.order_by(CalendarMigrationBatch.id)
)
batch_ids = {row.id for row in batches}
for batch in batches:
append( # type: ignore[operator]
_record(
"calendar_migration_batch",
batch.id,
"calendar_migration_evidence",
f"Calendar migration {batch.id}",
{
"match_fields": _matching_fields(
batch, subject_user_id, ("created_by_user_id",)
),
"migration_kind": batch.migration_kind,
"status": batch.status,
"phase": batch.phase,
"total_resources": batch.total_resources,
"total_events": batch.total_events,
"completed_at": _iso(batch.completed_at),
},
observed_at=batch.updated_at,
immutable=True,
retention_reason="Remote-move authorization and outcome state is immutable migration evidence.",
)
)
if not batch_ids:
return
for resource in _bounded_rows(
db.query(CalendarMigrationResource)
.filter(CalendarMigrationResource.batch_id.in_(batch_ids))
.order_by(CalendarMigrationResource.id)
):
append( # type: ignore[operator]
_record(
"calendar_migration_resource",
resource.id,
"calendar_migration_evidence",
"Calendar migration resource",
{
"batch_id": resource.batch_id,
"status": resource.status,
"event_count": len(resource.event_ids or ()),
},
observed_at=resource.updated_at,
immutable=True,
retention_reason="Per-resource move state is immutable migration and recovery evidence.",
)
)
def _matching_events(
session: Session,
*,
tenant_id: str,
subject_user_id: str | None,
subject_email: str | None,
event_id: str | None,
) -> list[CalendarEvent]:
conditions = []
if event_id:
conditions.append(CalendarEvent.id == event_id)
if subject_user_id:
conditions.extend(
(
CalendarEvent.created_by_user_id == subject_user_id,
CalendarEvent.updated_by_user_id == subject_user_id,
)
)
if subject_email:
pattern = f"%{_escape_like(subject_email)}%"
conditions.extend(
(
func.lower(cast(CalendarEvent.organizer, Text)).like(
pattern, escape="\\"
),
func.lower(cast(CalendarEvent.attendees, Text)).like(
pattern, escape="\\"
),
)
)
if not conditions:
return []
candidates = _bounded_rows(
session.query(CalendarEvent)
.filter(CalendarEvent.tenant_id == tenant_id, or_(*conditions))
.order_by(CalendarEvent.id)
)
return [
row
for row in candidates
if row.id == event_id
or (
subject_user_id is not None
and (
row.created_by_user_id == subject_user_id
or row.updated_by_user_id == subject_user_id
)
)
or _matching_party(row.organizer, subject_email)
or _matching_parties(row.attendees, subject_email)
]
def _matching_collections(
session: Session,
*,
tenant_id: str,
subject_user_id: str | None,
collection_ids: set[str],
) -> list[CalendarCollection]:
conditions = []
if collection_ids:
conditions.append(CalendarCollection.id.in_(collection_ids))
if subject_user_id:
conditions.extend(
(
(CalendarCollection.owner_type == "user")
& (CalendarCollection.owner_id == subject_user_id),
CalendarCollection.created_by_user_id == subject_user_id,
)
)
if not conditions:
return []
return _bounded_rows(
session.query(CalendarCollection)
.filter(CalendarCollection.tenant_id == tenant_id, or_(*conditions))
.order_by(CalendarCollection.id)
)
def _outbox_by_event(
session: Session,
*,
tenant_id: str,
event_ids: set[str],
) -> dict[str, list[CalendarOutboxOperation]]:
if not event_ids:
return {}
result: dict[str, list[CalendarOutboxOperation]] = {}
for row in _bounded_rows(
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.event_id.in_(event_ids),
)
.order_by(CalendarOutboxOperation.id)
):
result.setdefault(str(row.event_id), []).append(row)
return result
def _matching_party(value: object, email: str | None) -> dict[str, object] | None:
if email is None or not isinstance(value, Mapping):
return None
address = _party_email(value)
if address != email:
return None
params = value.get("params")
safe_params: dict[str, object] = {}
if isinstance(params, Mapping):
for key in ("CN", "CUTYPE", "PARTSTAT", "ROLE", "RSVP"):
if key in params:
safe_params[key] = _safe_value(params[key])
return {
"email": address,
"name": value.get("name") or _first_value(safe_params.get("CN")),
"participation_status": _first_value(safe_params.get("PARTSTAT")),
"role": _first_value(safe_params.get("ROLE")),
"rsvp": _first_value(safe_params.get("RSVP")),
}
def _matching_parties(
values: object,
email: str | None,
) -> list[dict[str, object]]:
if not isinstance(values, list):
return []
return [party for value in values[:512] if (party := _matching_party(value, email))]
def _party_email(value: Mapping[object, object]) -> str | None:
candidate = value.get("email") or value.get("address") or value.get("value")
if not isinstance(candidate, str):
return None
if candidate.casefold().startswith("mailto:"):
candidate = candidate[7:]
return _normalized_email(candidate)
def _safe_value(value: object, *, depth: int = 0) -> object:
if depth >= 5:
return "[depth-limited]"
if isinstance(value, Mapping):
result: dict[str, object] = {}
for key, item in list(value.items())[:128]:
normalized = str(key).casefold()
if any(part in normalized for part in _SECRET_PARTS + _LOCATOR_PARTS):
continue
result[str(key)] = _safe_value(item, depth=depth + 1)
return result
if isinstance(value, list):
return [_safe_value(item, depth=depth + 1) for item in value[:256]]
if isinstance(value, str):
return value[:2_000]
if value is None or isinstance(value, (bool, int, float)):
return value
return str(value)[:2_000]
def _delete_view_preference(
session: Session,
*,
tenant_id: str,
subject_user_id: str,
action: DsarErasureActionRef,
request_id: str,
) -> DsarExecutionResultRef:
row = (
session.query(CalendarViewPreference)
.filter(
CalendarViewPreference.id == action.resource_id,
CalendarViewPreference.tenant_id == tenant_id,
)
.with_for_update()
.one_or_none()
)
if row is None:
return _result(
action,
"unchanged",
"The Calendar view preference was already absent.",
{"request_id": request_id},
)
if row.user_id != subject_user_id:
return _blocked(action, "The Calendar preference owner changed after planning.")
session.delete(row)
return _result(
action,
"executed",
"The personal Calendar view preference was deleted.",
{"request_id": request_id},
)
def _calendar_references(subject: DsarSubjectRef) -> dict[str, str]:
aliases = {
"calendar.collection": "collection",
"calendar.event": "event",
}
return {
target: value
for key, target in aliases.items()
if (value := str(subject.external_references.get(key) or "").strip())
}
def _subject_user_id(subject: DsarSubjectRef) -> str | None:
candidates = [subject.membership_id] if subject.membership_id else []
for key in (
"calendar.user",
"calendar.membership",
"access.membership",
"membership_id",
):
value = str(subject.external_references.get(key) or "").strip()
if value:
candidates.append(value)
normalized = {value.strip() for value in candidates if value.strip()}
return normalized.pop() if len(normalized) == 1 else None
def _subject_email(subject: DsarSubjectRef) -> str | None:
candidates = [subject.email] if subject.email else []
for key in ("calendar.email", "calendar.attendee_email"):
value = str(subject.external_references.get(key) or "").strip()
if value:
candidates.append(value)
normalized = {
email for value in candidates if (email := _normalized_email(value)) is not None
}
return normalized.pop() if len(normalized) == 1 else None
def _normalized_email(value: object) -> str | None:
if not isinstance(value, str):
return None
normalized = value.strip().casefold()
if normalized.startswith("mailto:"):
normalized = normalized[7:]
return normalized or None
def _first_value(value: object) -> object:
if isinstance(value, list):
return value[0] if value else None
return value
def _matching_fields(
row: object,
subject_user_id: str | None,
fields: Sequence[str],
) -> list[str]:
if subject_user_id is None:
return []
return [field for field in fields if getattr(row, field) == subject_user_id]
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None = None,
immutable: bool = False,
retention_reason: str | None = None,
source_path: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="calendar",
module_id="calendar",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path=source_path,
)
def _action(
action_id: str,
kind: str,
record: DsarRecordRef,
title: str,
rationale: str,
*,
executable: bool,
irreversible: bool = False,
metadata: Mapping[str, object] | None = None,
) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=action_id,
provider_id="calendar",
module_id="calendar",
kind=kind, # type: ignore[arg-type]
resource_type=record.resource_type,
resource_id=record.resource_id,
title=title,
rationale=rationale,
executable=executable,
irreversible=irreversible,
metadata=metadata or {},
)
def _result(
action: DsarErasureActionRef,
status: str,
summary: str,
evidence: Mapping[str, object] | None = None,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status=status, # type: ignore[arg-type]
summary=summary,
evidence=evidence or {},
)
def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef:
return _result(action, "blocked", summary)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Calendar DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_rows(query: object) -> list[object]:
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Calendar DSAR match limit exceeded; narrow the subject selectors."
)
return rows
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
__all__ = ["CALENDAR_DSAR_CAPABILITY", "CalendarDsarProvider"]
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from defusedxml import ElementTree as SafeElementTree
EWS_SOAP_NS = "http://schemas.xmlsoap.org/soap/envelope/"
EWS_MESSAGES_NS = (
"http://schemas.microsoft.com/exchange/services/2006/messages"
)
EWS_TYPES_NS = "http://schemas.microsoft.com/exchange/services/2006/types"
class EwsAdapterError(ValueError):
"""Raised when an EWS response cannot be translated safely."""
def ews_find_item_body(
*,
start: datetime,
end: datetime,
mailbox: Any | None = None,
) -> str:
start_text = normalize_datetime(start).isoformat().replace("+00:00", "Z")
end_text = normalize_datetime(end).isoformat().replace("+00:00", "Z")
mailbox_xml = ""
if mailbox:
mailbox_xml = (
"<t:Mailbox><t:EmailAddress>"
f"{xml_escape(str(mailbox))}"
"</t:EmailAddress></t:Mailbox>"
)
return f"""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="{EWS_SOAP_NS}" xmlns:m="{EWS_MESSAGES_NS}" xmlns:t="{EWS_TYPES_NS}">
<s:Header>
<t:RequestServerVersion Version="Exchange2013_SP1" />
</s:Header>
<s:Body>
<m:FindItem Traversal="Shallow">
<m:ItemShape>
<t:BaseShape>AllProperties</t:BaseShape>
</m:ItemShape>
<m:CalendarView StartDate="{start_text}" EndDate="{end_text}" />
<m:ParentFolderIds>
<t:DistinguishedFolderId Id="calendar">{mailbox_xml}</t:DistinguishedFolderId>
</m:ParentFolderIds>
</m:FindItem>
</s:Body>
</s:Envelope>"""
def parse_ews_calendar_items(xml_text: str) -> list[dict[str, Any]]:
try:
root = SafeElementTree.fromstring(xml_text)
except SafeElementTree.ParseError as exc:
raise EwsAdapterError(f"Invalid EWS response XML: {exc}") from exc
items: list[dict[str, Any]] = []
for item in root.findall(f".//{{{EWS_TYPES_NS}}}CalendarItem"):
item_id = item.find(f"./{{{EWS_TYPES_NS}}}ItemId")
href = item_id.get("Id") if item_id is not None else None
if not href:
continue
change_key = (
item_id.get("ChangeKey") if item_id is not None else None
)
start = parse_ews_datetime(text_of(item, "Start"))
end_text = text_of(item, "End")
end = parse_ews_datetime(end_text) if end_text else start
uid = text_of(item, "UID") or href
subject = text_of(item, "Subject") or "(Untitled event)"
all_day = text_of(item, "IsAllDayEvent") == "true"
free_busy = text_of(item, "LegacyFreeBusyStatus") or "Busy"
sensitivity = text_of(item, "Sensitivity") or "Normal"
body = item.find(f"./{{{EWS_TYPES_NS}}}Body")
provider_payload = element_to_dict(item)
items.append(
{
"href": href,
"uid": uid,
"recurrence_id": (
href
if text_of(item, "CalendarItemType")
in {"Occurrence", "Exception"}
else None
),
"sequence": int_or_default(
text_of(item, "AppointmentSequenceNumber"),
0,
),
"summary": subject,
"description": body.text if body is not None else None,
"location": text_of(item, "Location"),
"status": (
"CANCELLED"
if text_of(item, "IsCancelled") == "true"
else "CONFIRMED"
),
"transparency": (
"TRANSPARENT"
if free_busy.lower() == "free"
else "OPAQUE"
),
"classification": (
"PRIVATE"
if sensitivity.lower() == "private"
else "PUBLIC"
),
"start_at": start,
"end_at": end,
"duration_seconds": int((end - start).total_seconds()),
"all_day": all_day,
"timezone": "UTC",
"organizer": ews_mailbox_record(
item.find(
f"./{{{EWS_TYPES_NS}}}Organizer/"
f"{{{EWS_TYPES_NS}}}Mailbox"
)
),
"attendees": ews_attendees(item),
"categories": [
category.text
for category in item.findall(
f"./{{{EWS_TYPES_NS}}}Categories/"
f"{{{EWS_TYPES_NS}}}String"
)
if category.text
],
"rrule": None,
"rdate": [],
"exdate": [],
"reminders": ews_reminders(item),
"attachments": [],
"related_to": [],
"etag": change_key,
"icalendar": {
"component": "VEVENT",
"schema_version": 1,
"provider": "ews",
"ews": provider_payload,
},
"metadata": {"ews": provider_payload},
}
)
return items
def parse_ews_datetime(value: str | None) -> datetime:
if not value:
return datetime.now(timezone.utc)
return normalize_datetime(
datetime.fromisoformat(value.replace("Z", "+00:00"))
)
def text_of(item: Any, name: str) -> str | None:
child = item.find(f"./{{{EWS_TYPES_NS}}}{name}")
return child.text if child is not None else None
def ews_mailbox_record(mailbox: Any | None) -> dict[str, Any] | None:
if mailbox is None:
return None
return {
"name": text_of(mailbox, "Name") or "",
"email": text_of(mailbox, "EmailAddress") or "",
"routing_type": text_of(mailbox, "RoutingType") or "",
}
def ews_attendees(item: Any) -> list[dict[str, Any]]:
attendees: list[dict[str, Any]] = []
for role, path in (
("required", "RequiredAttendees"),
("optional", "OptionalAttendees"),
):
for attendee in item.findall(
f"./{{{EWS_TYPES_NS}}}{path}/"
f"{{{EWS_TYPES_NS}}}Attendee"
):
mailbox = attendee.find(f"./{{{EWS_TYPES_NS}}}Mailbox")
record = ews_mailbox_record(mailbox) or {}
record["role"] = role
response = text_of(attendee, "ResponseType")
if response:
record["status"] = response
attendees.append(record)
return attendees
def ews_reminders(item: Any) -> list[dict[str, Any]]:
if text_of(item, "ReminderIsSet") == "true":
minutes = int_or_default(
text_of(item, "ReminderMinutesBeforeStart"),
15,
)
return [{"action": "DISPLAY", "trigger_minutes_before": minutes}]
return []
def element_to_dict(element: Any) -> dict[str, Any]:
tag = element.tag.rsplit("}", 1)[-1]
children = list(element)
result: dict[str, Any] = {"tag": tag}
if element.attrib:
result["attributes"] = dict(element.attrib)
if element.text and element.text.strip():
result["text"] = element.text.strip()
if children:
result["children"] = [
element_to_dict(child) for child in children
]
return result
def int_or_default(value: str | None, default: int) -> int:
try:
return int(value) if value is not None else default
except ValueError:
return default
def xml_escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@@ -0,0 +1,112 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'calendar.external-sources-and-sync': {'consequence_classes': {'change_collection': 'Änderung der '
'Kalenderidentität, '
'Quellkonfiguration, '
'Anmeldeinformationen '
'und '
'Synchronisierungsrichtlinie',
'delete_or_remove_collection': 'Löschen '
'eines '
'lokalen '
'Kalenders '
'oder '
'Entfernen '
'einer '
'externen '
'Quelle '
'nach '
'expliziter '
'Ereignisbehandlung',
'execute_remote_move': 'Kopieren '
'aller '
'Zielressourcen '
'vor dem '
'bedingten '
'Löschen '
'von '
'Quellressourcen',
'force_full_sync': 'Lesen Sie die '
'vollständige '
'Remote-Quelle '
'erneut und '
'versöhnen Sie '
'sie mit dem '
'lokalen Staat',
'synchronize_source': 'Lesen und, '
'wo '
'konfiguriert, '
'Schreiben '
'des '
'Remote-Zustands '
'unter '
'Verwendung '
'von '
'Begrenzten '
'Synchronisationsnachweisen'}},
'calendar.manage-calendars-and-events': {'consequence_classes': {'delete_event': 'Löschen des '
'ausgewählten '
'Ereignisses '
'oder der '
'ausgewählten '
'Serie und '
'Synchronisieren '
'der '
'Warteschlange '
'bei Bedarf',
'save_event': 'Erstellen oder '
'Aktualisieren des '
'maßgeblichen '
'Ereignisses und '
'Synchronisieren '
'der Warteschlange '
'bei Bedarf'}},
'calendar.outbound-change-recovery': {'consequence_classes': {'reconcile_outbox': 'Vergleichen '
'Sie die '
'neueste '
'gewünschte '
'Generation mit '
'dem '
'Remote-Zustand, '
'bevor Sie '
'erneut '
'versuchen oder '
'verwerfen'}},
'calendar.privacy.data-subject-requests': {'limitations': ['Rohe ICS und Steckverbinder-Locatoren '
'sind nicht in den JSON-Export '
'eingebettet; autorisierte '
'Kalenderüberprüfung bleibt '
'verbindlich.',
'Event- und Teilnehmerlöschung ist '
'manuell, da sich Wiederholung, '
'gemeinsame Teilnahme, externe '
'Synchronisierung und institutionelle '
'Bindung überschneiden können.'],
'prerequisites': ['Die Datenschutzanfrage und die '
'Kalenderauswahl wurden unabhängig '
'autorisiert und bestätigt.',
'Der Rezensent versteht die '
'effektiven Kalenderspeicherungs- '
'und '
'Synchronisationsnachweispflichten.'],
'steps': ['Führen Sie die Kalenderanbietersuche aus '
'und überprüfen Sie die isolierten '
'Ereignis-, Teilnahme-, Präferenz-, '
'Synchronisierungs-, Outbox- und '
'Migrationsdatensätze.',
'Bewahren Sie alle Gründe für die '
'Aufbewahrung von Nachweisen mit der '
'Fallentscheidung auf und koordinieren Sie '
'jede Inhaltsänderung durch '
'Kalender-Lebenszykluskontrollen.',
'Führen Sie nur eine genehmigte Löschung der '
'persönlichen Ansichtspräferenz aus.',
'Überprüfen Sie die zugehörigen '
'Terminplanungs- oder E-Mail-Datensätze über '
'ihre eigenen DSAR-Anbieter, wenn diese '
'Module installiert sind.']}}
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
import uuid
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def graph_event_payload(item: dict[str, Any]) -> dict[str, Any]:
"""Translate one Microsoft Graph event into the calendar event contract."""
start = graph_datetime(item.get("start"))
end = graph_datetime(item.get("end"))
all_day = bool(item.get("isAllDay"))
uid = str(
item.get("iCalUId")
or item.get("uid")
or item.get("id")
or uuid.uuid4()
)
return {
"uid": uid,
"recurrence_id": (
str(item.get("id"))
if item.get("type") == "occurrence"
else None
),
"sequence": int(item.get("sequence") or 0),
"summary": str(item.get("subject") or "(Untitled event)"),
"description": graph_body_text(item),
"location": graph_location_text(item.get("location")),
"status": (
"CANCELLED" if item.get("isCancelled") else "CONFIRMED"
),
"transparency": (
"TRANSPARENT"
if item.get("showAs") in {"free", "workingElsewhere"}
else "OPAQUE"
),
"classification": (
"PRIVATE"
if item.get("sensitivity") == "private"
else "PUBLIC"
),
"start_at": start,
"end_at": end,
"duration_seconds": int((end - start).total_seconds()),
"all_day": all_day,
"timezone": (
(item.get("start") or {}).get("timeZone")
if isinstance(item.get("start"), dict)
else None
)
or "UTC",
"organizer": graph_party(item.get("organizer")),
"attendees": [
graph_party(attendee)
for attendee in item.get("attendees") or []
],
"categories": [
str(category) for category in item.get("categories") or []
],
"rrule": (
item.get("recurrence")
if isinstance(item.get("recurrence"), dict)
else None
),
"rdate": [],
"exdate": [],
"reminders": graph_reminders(item),
"attachments": [],
"related_to": [],
"etag": item.get("@odata.etag"),
"icalendar": {
"component": "VEVENT",
"schema_version": 1,
"provider": "graph",
"graph": item,
},
"metadata": {"graph": item},
}
def graph_datetime(value: Any) -> datetime:
if not isinstance(value, dict) or not value.get("dateTime"):
return datetime.now(timezone.utc)
raw = str(value["dateTime"]).replace("Z", "+00:00")
parsed = datetime.fromisoformat(raw)
tz_name = str(value.get("timeZone") or "UTC")
if parsed.tzinfo is None:
try:
parsed = parsed.replace(tzinfo=ZoneInfo(tz_name))
except ZoneInfoNotFoundError:
parsed = parsed.replace(tzinfo=timezone.utc)
return normalize_datetime(parsed)
def graph_body_text(item: dict[str, Any]) -> str | None:
body = item.get("body")
if isinstance(body, dict) and body.get("content"):
return str(body["content"])
return str(item.get("bodyPreview")) if item.get("bodyPreview") else None
def graph_location_text(value: Any) -> str | None:
if isinstance(value, dict) and value.get("displayName"):
return str(value["displayName"])
return None
def graph_party(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
email = (
value.get("emailAddress")
if isinstance(value.get("emailAddress"), dict)
else value
)
result = {
"name": (
str(email.get("name") or "")
if isinstance(email, dict)
else ""
),
"email": (
str(email.get("address") or "")
if isinstance(email, dict)
else ""
),
}
if value.get("type"):
result["role"] = value["type"]
if value.get("status"):
result["status"] = value["status"]
return result
def graph_reminders(item: dict[str, Any]) -> list[dict[str, Any]]:
if (
item.get("isReminderOn")
and item.get("reminderMinutesBeforeStart") is not None
):
return [
{
"action": "DISPLAY",
"trigger_minutes_before": int(
item["reminderMinutesBeforeStart"]
),
}
]
return []
def normalize_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
+166 -11
View File
@@ -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,13 +214,83 @@ 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): def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
component.add_component(alarm) """Return every occurrence in range, or explicitly reject an unsafe expansion."""
return component return expand_events_occurrences([event], range_start, range_end, limit=limit)[0]
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]: def expand_events_occurrences(
events: list[Any], range_start: datetime, range_end: datetime,
*, limit: int = 10_000, admission: Any = None,
) -> list[list[dict[str, Any]]]:
from govoplan_core.security.bounded_process import (
ProcessBudgetError, bounded_operation_admission, run_bounded_operation,
)
from govoplan_core.security.worker_payload import (
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
)
from govoplan_calendar.backend.recurrence_worker import RECURRENCE_LIMITS, expand_recurrences_worker
if not events:
return []
if type(limit) is not int or not 1 <= limit <= 10_000 or len(events) > 2_000:
raise ICalendarError("Recurrence expansion exceeds its limit; request a smaller range or fewer calendars.")
try:
if admission is None:
with bounded_operation_admission() as reserved:
return expand_events_occurrences(events, range_start, range_end, limit=limit, admission=reserved)
projections = [
{
"uid": item.uid,
"start_at": item.start_at,
"end_at": item.end_at,
"duration_seconds": getattr(item, "duration_seconds", None),
"all_day": item.all_day,
"timezone": getattr(item, "timezone", None),
"rrule": item.rrule,
"rdate": item.rdate or [],
"exdate": item.exdate or [],
}
for item in events
]
payload = encode_worker_payload(
{"events": projections, "start": range_start, "end": range_end, "limit": limit},
max_bytes=RECURRENCE_LIMITS.input_bytes,
)
result = decode_worker_payload(
run_bounded_operation(expand_recurrences_worker, payload, limits=RECURRENCE_LIMITS, admission=admission),
max_bytes=RECURRENCE_LIMITS.output_bytes,
)
if not isinstance(result, dict) or result.get("error"):
raise ICalendarError("Recurrence expansion is invalid or exceeds its limit; request a smaller range or fewer calendars.")
batches = result.get("occurrences")
if not isinstance(batches, list) or len(batches) != len(events):
raise ICalendarError("Recurrence worker returned an invalid result.")
count = 0
for source, batch in zip(events, batches, strict=True):
if not isinstance(batch, list):
raise ICalendarError("Recurrence worker returned an invalid result.")
count += len(batch)
if count > limit:
raise ICalendarError("Recurrence worker exceeded its result limit.")
for item in batch:
if (
not isinstance(item, dict)
or set(item) != {"uid", "recurrence_id", "start_at", "end_at", "all_day"}
or item["uid"] != source.uid
or not isinstance(item["recurrence_id"], str)
or not isinstance(item["start_at"], datetime)
or (item["end_at"] is not None and not isinstance(item["end_at"], datetime))
or type(item["all_day"]) is not bool
):
raise ICalendarError("Recurrence worker returned an invalid result.")
return batches
except (ProcessBudgetError, WorkerPayloadError) as exc:
raise ICalendarError("Recurrence expansion could not complete within its resource limits; narrow the request or retry later.") from exc
def _expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
"""Expand an event's recurrence primitives within a range. """Expand an event's recurrence primitives within a range.
This is a backend primitive for later API/UI recurrence handling. It expands This is a backend primitive for later API/UI recurrence handling. It expands
@@ -217,14 +315,20 @@ def expand_event_occurrences(event: Any, range_start: datetime, range_end: datet
for exdate in temporal_values_from_records(event.exdate or []): for exdate in temporal_values_from_records(event.exdate or []):
rules.exdate(exdate) rules.exdate(exdate)
starts = rules.between(range_start - duration, range_end, inc=True)
occurrences: list[dict[str, Any]] = [] occurrences: list[dict[str, Any]] = []
for occurrence_start in starts: # Never materialize rules.between(): dense rules allocate the whole range
# before a result limit can run. Sparse rules and pre-range scans are also
# covered by the disposable worker's CPU/wall/memory limits.
for scanned, occurrence_start in enumerate(rules, start=1):
if scanned > 100_000:
raise ICalendarError("Recurrence scan limit exceeded.")
occurrence_start = normalize_datetime(occurrence_start) occurrence_start = normalize_datetime(occurrence_start)
if occurrence_start > range_end:
break
if occurrence_overlaps(occurrence_start, duration, range_start, range_end): if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
occurrences.append(occurrence_payload(occurrence_start, duration, event))
if len(occurrences) >= limit: if len(occurrences) >= limit:
break raise ICalendarError("Recurrence result limit exceeded.")
occurrences.append(occurrence_payload(occurrence_start, duration, event))
return occurrences return occurrences
@@ -279,6 +383,30 @@ def recurrence_id_value(value: Any | None) -> str | None:
return f"TZID={tzid}:{raw_value}" if tzid else raw_value return f"TZID={tzid}:{raw_value}" if tzid else raw_value
def recurrence_id_datetime(value: str | None) -> datetime | None:
"""Parse the canonical/raw RECURRENCE-ID forms stored by the module."""
if not value:
return None
raw_value = value
params: dict[str, Any] = {}
if value.startswith("TZID=") and ":" in value:
tzid, raw_value = value.removeprefix("TZID=").split(":", 1)
params["TZID"] = [tzid]
parsed = parse_ical_temporal(raw_value, params)
if parsed is not None:
return parsed
try:
return normalize_datetime(datetime.fromisoformat(raw_value))
except ValueError:
return None
def normalized_recurrence_id(value: str | None) -> str | None:
parsed = recurrence_id_datetime(value)
return format_ical_datetime(parsed) if parsed is not None else value
def recurrence_rule(value: Any | None) -> dict[str, Any] | None: def recurrence_rule(value: Any | None) -> dict[str, Any] | None:
if value is None: if value is None:
return None return None
@@ -474,6 +602,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:
@@ -620,13 +758,30 @@ def occurrence_overlaps(start_at: datetime, duration: timedelta, range_start: da
def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]: def occurrence_payload(start_at: datetime, duration: timedelta, event: Any) -> dict[str, Any]:
return { return {
"uid": event.uid, "uid": event.uid,
"recurrence_id": format_ical_datetime(start_at), "recurrence_id": occurrence_recurrence_id(start_at, event),
"start_at": start_at, "start_at": start_at,
"end_at": start_at + duration if duration else None, "end_at": start_at + duration if duration else None,
"all_day": event.all_day, "all_day": event.all_day,
} }
def occurrence_recurrence_id(start_at: datetime, event: Any) -> str:
start_at = normalize_datetime(start_at)
if event.all_day:
return start_at.strftime("%Y%m%d")
timezone_id = getattr(event, "timezone", None)
if timezone_id:
try:
local_start = start_at.astimezone(ZoneInfo(timezone_id))
return (
f"TZID={timezone_id}:"
f"{local_start.strftime('%Y%m%dT%H%M%S')}"
)
except ZoneInfoNotFoundError:
pass
return format_ical_datetime(start_at)
def int_or_zero(value: str | None) -> int: def int_or_zero(value: str | None) -> int:
try: try:
return int(value or "0") return int(value or "0")
File diff suppressed because it is too large Load Diff
@@ -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"),
) )
@@ -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"):
@@ -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."""
@@ -0,0 +1,28 @@
"""Add the durable CalDAV desired-state outbox on the development track.
Revision ID: af1b2c3d4e5f
Revises: 9e0f1a2b3c4d
Create Date: 2026-07-20 00:00:00.000000
"""
from __future__ import annotations
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
downgrade as _downgrade,
)
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
upgrade as _upgrade,
)
revision = "af1b2c3d4e5f"
down_revision = "9e0f1a2b3c4d"
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
_upgrade()
def downgrade() -> None:
_downgrade()
@@ -0,0 +1,28 @@
"""Add durable per-user calendar view preferences on the development track.
Revision ID: b02c3d4e5f60
Revises: af1b2c3d4e5f
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
downgrade as _downgrade,
)
from govoplan_calendar.backend.migrations.versions.b02c3d4e5f60_calendar_view_preferences import (
upgrade as _upgrade,
)
revision = "b02c3d4e5f60"
down_revision = "af1b2c3d4e5f"
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
_upgrade()
def downgrade() -> None:
_downgrade()
@@ -0,0 +1,174 @@
"""v0.1.7 calendar baseline
Revision ID: 9e0f1a2b3c4d
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '9e0f1a2b3c4d'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('calendar_collections',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('timezone', sa.String(length=100), nullable=False),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('owner_type', sa.String(length=20), nullable=False),
sa.Column('owner_id', sa.String(length=36), nullable=True),
sa.Column('visibility', sa.String(length=20), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_collections_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_collections_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_collections'))
)
op.create_index(op.f('ix_calendar_collections_created_by_user_id'), 'calendar_collections', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_deleted_at'), 'calendar_collections', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_collections_is_default'), 'calendar_collections', ['is_default'], unique=False)
op.create_index('ix_calendar_collections_owner', 'calendar_collections', ['tenant_id', 'owner_type', 'owner_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_owner_id'), 'calendar_collections', ['owner_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_owner_type'), 'calendar_collections', ['owner_type'], unique=False)
op.create_index(op.f('ix_calendar_collections_tenant_id'), 'calendar_collections', ['tenant_id'], unique=False)
op.create_index(op.f('ix_calendar_collections_visibility'), 'calendar_collections', ['visibility'], unique=False)
op.create_index('uq_calendar_collections_active_slug', 'calendar_collections', ['tenant_id', 'slug'], unique=True, sqlite_where=sa.text('deleted_at IS NULL'), postgresql_where=sa.text('deleted_at IS NULL'))
op.create_table('calendar_sync_credentials',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('credential_kind', sa.String(length=30), nullable=False),
sa.Column('label', sa.String(length=255), nullable=True),
sa.Column('secret_encrypted', sa.Text(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_sync_credentials_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_sync_credentials_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_credentials'))
)
op.create_index(op.f('ix_calendar_sync_credentials_created_by_user_id'), 'calendar_sync_credentials', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_credential_kind'), 'calendar_sync_credentials', ['credential_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_deleted_at'), 'calendar_sync_credentials', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_credentials_tenant_id'), 'calendar_sync_credentials', ['tenant_id'], unique=False)
op.create_index('ix_calendar_sync_credentials_tenant_kind', 'calendar_sync_credentials', ['tenant_id', 'credential_kind'], unique=False)
op.create_table('calendar_events',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('calendar_id', sa.String(length=36), nullable=False),
sa.Column('uid', sa.String(length=255), nullable=False),
sa.Column('recurrence_id', sa.String(length=255), nullable=True),
sa.Column('sequence', sa.Integer(), nullable=False),
sa.Column('summary', sa.String(length=500), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('location', sa.String(length=500), nullable=True),
sa.Column('status', sa.String(length=40), nullable=False),
sa.Column('transparency', sa.String(length=20), nullable=False),
sa.Column('classification', sa.String(length=20), nullable=False),
sa.Column('start_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('end_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('duration_seconds', sa.Integer(), nullable=True),
sa.Column('all_day', sa.Boolean(), nullable=False),
sa.Column('timezone', sa.String(length=100), nullable=True),
sa.Column('organizer', sa.JSON(), nullable=True),
sa.Column('attendees', sa.JSON(), nullable=False),
sa.Column('categories', sa.JSON(), nullable=False),
sa.Column('rrule', sa.JSON(), nullable=True),
sa.Column('rdate', sa.JSON(), nullable=False),
sa.Column('exdate', sa.JSON(), nullable=False),
sa.Column('reminders', sa.JSON(), nullable=False),
sa.Column('attachments', sa.JSON(), nullable=False),
sa.Column('related_to', sa.JSON(), nullable=False),
sa.Column('source_kind', sa.String(length=30), nullable=False),
sa.Column('source_href', sa.String(length=1000), nullable=True),
sa.Column('etag', sa.String(length=255), nullable=True),
sa.Column('icalendar', sa.JSON(), nullable=False),
sa.Column('raw_ics', sa.Text(), nullable=True),
sa.Column('created_by_user_id', sa.String(length=36), nullable=True),
sa.Column('updated_by_user_id', sa.String(length=36), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['calendar_id'], ['calendar_collections.id'], name=op.f('fk_calendar_events_calendar_id_calendar_collections'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_events_created_by_user_id_access_users'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_events_tenant_id_scopes'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['access_users.id'], name=op.f('fk_calendar_events_updated_by_user_id_access_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_events')),
sa.UniqueConstraint('calendar_id', 'uid', 'recurrence_id', name='uq_calendar_events_calendar_uid_recurrence')
)
op.create_index(op.f('ix_calendar_events_all_day'), 'calendar_events', ['all_day'], unique=False)
op.create_index(op.f('ix_calendar_events_calendar_id'), 'calendar_events', ['calendar_id'], unique=False)
op.create_index(op.f('ix_calendar_events_created_by_user_id'), 'calendar_events', ['created_by_user_id'], unique=False)
op.create_index(op.f('ix_calendar_events_deleted_at'), 'calendar_events', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_events_end_at'), 'calendar_events', ['end_at'], unique=False)
op.create_index(op.f('ix_calendar_events_etag'), 'calendar_events', ['etag'], unique=False)
op.create_index('ix_calendar_events_range', 'calendar_events', ['tenant_id', 'calendar_id', 'start_at', 'end_at'], unique=False)
op.create_index(op.f('ix_calendar_events_recurrence_id'), 'calendar_events', ['recurrence_id'], unique=False)
op.create_index(op.f('ix_calendar_events_source_kind'), 'calendar_events', ['source_kind'], unique=False)
op.create_index(op.f('ix_calendar_events_start_at'), 'calendar_events', ['start_at'], unique=False)
op.create_index(op.f('ix_calendar_events_status'), 'calendar_events', ['status'], unique=False)
op.create_index(op.f('ix_calendar_events_tenant_id'), 'calendar_events', ['tenant_id'], unique=False)
op.create_index('ix_calendar_events_tenant_status', 'calendar_events', ['tenant_id', 'status'], unique=False)
op.create_index('ix_calendar_events_tenant_uid', 'calendar_events', ['tenant_id', 'uid'], unique=False)
op.create_index(op.f('ix_calendar_events_uid'), 'calendar_events', ['uid'], unique=False)
op.create_index(op.f('ix_calendar_events_updated_by_user_id'), 'calendar_events', ['updated_by_user_id'], unique=False)
op.create_table('calendar_sync_sources',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=False),
sa.Column('calendar_id', sa.String(length=36), nullable=False),
sa.Column('source_kind', sa.String(length=30), nullable=False),
sa.Column('collection_url', sa.String(length=1000), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('auth_type', sa.String(length=30), nullable=False),
sa.Column('username', sa.String(length=255), nullable=True),
sa.Column('credential_ref', sa.String(length=255), nullable=True),
sa.Column('sync_enabled', sa.Boolean(), nullable=False),
sa.Column('sync_interval_seconds', sa.Integer(), nullable=False),
sa.Column('sync_direction', sa.String(length=30), nullable=False),
sa.Column('conflict_policy', sa.String(length=30), nullable=False),
sa.Column('sync_token', sa.Text(), nullable=True),
sa.Column('ctag', sa.String(length=255), nullable=True),
sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('next_sync_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_status', sa.String(length=30), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['calendar_id'], ['calendar_collections.id'], name=op.f('fk_calendar_sync_sources_calendar_id_calendar_collections'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tenant_id'], ['core_scopes.id'], name=op.f('fk_calendar_sync_sources_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_calendar_sync_sources'))
)
op.create_index('ix_calendar_sync_sources_calendar', 'calendar_sync_sources', ['tenant_id', 'calendar_id', 'source_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_calendar_id'), 'calendar_sync_sources', ['calendar_id'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_deleted_at'), 'calendar_sync_sources', ['deleted_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_next_sync_at'), 'calendar_sync_sources', ['next_sync_at'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_source_kind'), 'calendar_sync_sources', ['source_kind'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_sync_enabled'), 'calendar_sync_sources', ['sync_enabled'], unique=False)
op.create_index(op.f('ix_calendar_sync_sources_tenant_id'), 'calendar_sync_sources', ['tenant_id'], unique=False)
op.create_index('uq_calendar_sync_sources_active_url', 'calendar_sync_sources', ['tenant_id', 'source_kind', 'collection_url'], unique=True, sqlite_where=sa.text('deleted_at IS NULL'), postgresql_where=sa.text('deleted_at IS NULL'))
def downgrade() -> None:
op.drop_table('calendar_sync_sources')
op.drop_table('calendar_events')
op.drop_table('calendar_sync_credentials')
op.drop_table('calendar_collections')
@@ -0,0 +1,114 @@
"""Add the durable CalDAV desired-state outbox.
Revision ID: af1b2c3d4e5f
Revises: 9e0f1a2b3c4d
Create Date: 2026-07-20 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "af1b2c3d4e5f"
down_revision = "9e0f1a2b3c4d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_outbox_operations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("source_id", sa.String(length=36), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=True),
sa.Column("operation_kind", sa.String(length=20), nullable=False),
sa.Column("resource_href", sa.String(length=1000), nullable=False),
sa.Column("payload_ics", sa.Text(), nullable=True),
sa.Column("payload_fingerprint", sa.String(length=64), nullable=True),
sa.Column("expected_etag", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("attempt_count", sa.Integer(), nullable=False),
sa.Column("max_attempts", sa.Integer(), nullable=False),
sa.Column("available_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("lease_token", sa.String(length=36), nullable=True),
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("reconciled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("remote_etag", sa.String(length=255), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["event_id"],
["calendar_events.id"],
name=op.f("fk_calendar_outbox_operations_event_id_calendar_events"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["source_id"],
["calendar_sync_sources.id"],
name=op.f("fk_calendar_outbox_operations_source_id_calendar_sync_sources"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_calendar_outbox_operations_tenant_id_scopes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_outbox_operations")),
sa.UniqueConstraint(
"idempotency_key",
name="uq_calendar_outbox_operations_idempotency_key",
),
)
op.create_index(
"ix_calendar_outbox_due",
"calendar_outbox_operations",
["status", "available_at", "created_at"],
unique=False,
)
op.create_index(
"ix_calendar_outbox_lease",
"calendar_outbox_operations",
["status", "lease_expires_at"],
unique=False,
)
op.create_index(
"ix_calendar_outbox_resource",
"calendar_outbox_operations",
["source_id", "resource_href", "created_at"],
unique=False,
)
op.create_index(
"ix_calendar_outbox_tenant_status",
"calendar_outbox_operations",
["tenant_id", "status"],
unique=False,
)
for column in (
"available_at",
"event_id",
"lease_expires_at",
"lease_token",
"operation_kind",
"payload_fingerprint",
"source_id",
"status",
"tenant_id",
):
op.create_index(
op.f(f"ix_calendar_outbox_operations_{column}"),
"calendar_outbox_operations",
[column],
unique=False,
)
def downgrade() -> None:
op.drop_table("calendar_outbox_operations")
@@ -0,0 +1,83 @@
"""Add durable per-user calendar view preferences.
Revision ID: b02c3d4e5f60
Revises: af1b2c3d4e5f
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b02c3d4e5f60"
down_revision = "af1b2c3d4e5f"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_view_preferences",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("dim_weekends", sa.Boolean(), nullable=True),
sa.Column("dim_off_hours", sa.Boolean(), nullable=True),
sa.Column("workday_start_hour", sa.Integer(), nullable=True),
sa.Column("workday_end_hour", sa.Integer(), nullable=True),
sa.Column("continuous_virtualization", sa.Boolean(), nullable=True),
sa.Column("continuous_overscan_weeks", sa.Integer(), nullable=True),
sa.Column("alternate_continuous_months", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f(
"fk_calendar_view_preferences_tenant_id_core_scopes"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["user_id"],
["access_users.id"],
name=op.f(
"fk_calendar_view_preferences_user_id_access_users"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_calendar_view_preferences"),
),
sa.UniqueConstraint(
"tenant_id",
"user_id",
name="uq_calendar_view_preferences_tenant_user",
),
)
op.create_index(
op.f("ix_calendar_view_preferences_tenant_id"),
"calendar_view_preferences",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_calendar_view_preferences_user_id"),
"calendar_view_preferences",
["user_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_calendar_view_preferences_user_id"),
table_name="calendar_view_preferences",
)
op.drop_index(
op.f("ix_calendar_view_preferences_tenant_id"),
table_name="calendar_view_preferences",
)
op.drop_table("calendar_view_preferences")
@@ -0,0 +1,59 @@
"""calendar invitation correlation
Revision ID: c13d4e5f6071
Revises: b02c3d4e5f60
Create Date: 2026-07-31 18:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c13d4e5f6071"
down_revision = "b02c3d4e5f60"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("calendar_events") as batch_op:
batch_op.add_column(
sa.Column("correlation_key", sa.String(length=255), nullable=True)
)
batch_op.add_column(
sa.Column("producer_module", sa.String(length=100), nullable=True)
)
batch_op.add_column(
sa.Column(
"producer_resource_type", sa.String(length=100), nullable=True
)
)
batch_op.add_column(
sa.Column("producer_resource_id", sa.String(length=255), nullable=True)
)
batch_op.create_index(
"ix_calendar_events_correlation_key", ("correlation_key",)
)
batch_op.create_index(
"ix_calendar_events_producer_module", ("producer_module",)
)
batch_op.create_index(
"ix_calendar_events_producer_resource_id", ("producer_resource_id",)
)
batch_op.create_index(
"ix_calendar_events_tenant_correlation",
("tenant_id", "correlation_key"),
)
def downgrade() -> None:
with op.batch_alter_table("calendar_events") as batch_op:
batch_op.drop_index("ix_calendar_events_tenant_correlation")
batch_op.drop_index("ix_calendar_events_producer_resource_id")
batch_op.drop_index("ix_calendar_events_producer_module")
batch_op.drop_index("ix_calendar_events_correlation_key")
batch_op.drop_column("producer_resource_id")
batch_op.drop_column("producer_resource_type")
batch_op.drop_column("producer_module")
batch_op.drop_column("correlation_key")
@@ -0,0 +1,150 @@
"""calendar remote move saga
Revision ID: d24e5f607182
Revises: c13d4e5f6071
Create Date: 2026-08-02 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d24e5f607182"
down_revision = "c13d4e5f6071"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"calendar_migration_batches",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("migration_kind", sa.String(length=30), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("phase", sa.String(length=40), nullable=False),
sa.Column("source_calendar_id", sa.String(length=36), nullable=False),
sa.Column("target_calendar_id", sa.String(length=36), nullable=False),
sa.Column("source_sync_source_id", sa.String(length=36), nullable=False),
sa.Column("target_sync_source_id", sa.String(length=36), nullable=False),
sa.Column("total_resources", sa.Integer(), nullable=False),
sa.Column("total_events", sa.Integer(), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_by_api_key_id", sa.String(length=36), nullable=True),
sa.Column("authorization_evidence", sa.JSON(), nullable=False),
sa.Column("cancellation_evidence", sa.JSON(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), 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"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["source_calendar_id"],
["calendar_collections.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["source_sync_source_id"],
["calendar_sync_sources.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["target_calendar_id"],
["calendar_collections.id"],
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["target_sync_source_id"],
["calendar_sync_sources.id"],
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id"),
)
for name, columns in (
("ix_calendar_migration_batches_tenant_id", ("tenant_id",)),
("ix_calendar_migration_batches_status", ("status",)),
("ix_calendar_migration_batches_phase", ("phase",)),
("ix_calendar_migration_batches_source_calendar_id", ("source_calendar_id",)),
("ix_calendar_migration_batches_target_calendar_id", ("target_calendar_id",)),
(
"ix_calendar_migration_batches_source_sync_source_id",
("source_sync_source_id",),
),
(
"ix_calendar_migration_batches_target_sync_source_id",
("target_sync_source_id",),
),
("ix_calendar_migration_batches_created_by_user_id", ("created_by_user_id",)),
(
"ix_calendar_migration_batches_created_by_api_key_id",
("created_by_api_key_id",),
),
("ix_calendar_migration_batches_completed_at", ("completed_at",)),
(
"ix_calendar_migration_batches_tenant_status",
("tenant_id", "status", "created_at"),
),
(
"ix_calendar_migration_batches_calendars",
("tenant_id", "source_calendar_id", "target_calendar_id"),
),
):
op.create_index(name, "calendar_migration_batches", columns, unique=False)
op.create_table(
"calendar_migration_resources",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("batch_id", sa.String(length=36), nullable=False),
sa.Column("source_href", sa.String(length=1000), nullable=False),
sa.Column("source_expected_etag", sa.String(length=255), nullable=False),
sa.Column("destination_href", sa.String(length=1000), nullable=False),
sa.Column("event_ids", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("destination_operation_id", sa.String(length=36), nullable=True),
sa.Column("source_delete_operation_id", sa.String(length=36), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["batch_id"],
["calendar_migration_batches.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["destination_operation_id"],
["calendar_outbox_operations.id"],
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["source_delete_operation_id"],
["calendar_outbox_operations.id"],
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"batch_id",
"source_href",
name="uq_calendar_migration_resources_batch_href",
),
sa.UniqueConstraint("destination_operation_id"),
sa.UniqueConstraint("source_delete_operation_id"),
)
for name, columns in (
("ix_calendar_migration_resources_tenant_id", ("tenant_id",)),
("ix_calendar_migration_resources_batch_id", ("batch_id",)),
("ix_calendar_migration_resources_status", ("status",)),
("ix_calendar_migration_resources_batch_status", ("batch_id", "status")),
):
op.create_index(name, "calendar_migration_resources", columns, unique=False)
def downgrade() -> None:
op.drop_table("calendar_migration_resources")
op.drop_table("calendar_migration_batches")
@@ -0,0 +1,993 @@
from __future__ import annotations
import hashlib
from collections import defaultdict
from collections.abc import Sequence
from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_calendar.backend.db.models import (
CalendarCollection,
CalendarEvent,
CalendarMigrationBatch,
CalendarMigrationResource,
CalendarOutboxOperation,
CalendarSyncSource,
)
from govoplan_calendar.backend.ical import events_to_ics
from govoplan_core.audit.logging import audit_event
from govoplan_core.db.base import utcnow
REMOTE_MOVE_CONFIRMATION = "MOVE REMOTE EVENTS"
ACTIVE_MIGRATION_STATUSES = {"active", "blocked", "cancel_requested"}
BLOCKING_OUTBOX_STATUSES = {"conflict", "dead", "cancelled"}
class CalendarMigrationError(ValueError):
pass
def active_tenant_migration(
session: Session,
*,
tenant_id: str,
) -> CalendarMigrationBatch | None:
return (
session.query(CalendarMigrationBatch)
.filter(
CalendarMigrationBatch.tenant_id == tenant_id,
CalendarMigrationBatch.status.in_(sorted(ACTIVE_MIGRATION_STATUSES)),
)
.order_by(CalendarMigrationBatch.created_at.asc())
.first()
)
def active_calendar_migration(
session: Session,
*,
tenant_id: str,
calendar_id: str,
) -> CalendarMigrationBatch | None:
return (
session.query(CalendarMigrationBatch)
.filter(
CalendarMigrationBatch.tenant_id == tenant_id,
CalendarMigrationBatch.status.in_(sorted(ACTIVE_MIGRATION_STATUSES)),
or_(
CalendarMigrationBatch.source_calendar_id == calendar_id,
CalendarMigrationBatch.target_calendar_id == calendar_id,
),
)
.order_by(CalendarMigrationBatch.created_at.asc())
.first()
)
def active_source_migration(
session: Session,
*,
tenant_id: str,
source_id: str,
) -> CalendarMigrationBatch | None:
return (
session.query(CalendarMigrationBatch)
.filter(
CalendarMigrationBatch.tenant_id == tenant_id,
CalendarMigrationBatch.status.in_(sorted(ACTIVE_MIGRATION_STATUSES)),
or_(
CalendarMigrationBatch.source_sync_source_id == source_id,
CalendarMigrationBatch.target_sync_source_id == source_id,
),
)
.order_by(CalendarMigrationBatch.created_at.asc())
.first()
)
def assert_calendar_not_migrating(
session: Session,
*,
tenant_id: str,
calendar_id: str,
) -> None:
batch = active_calendar_migration(
session,
tenant_id=tenant_id,
calendar_id=calendar_id,
)
if batch is not None:
raise CalendarMigrationError(
"Calendar changes are blocked while remote move "
f"{batch.id} is {batch.phase}."
)
def assert_source_not_migrating(
session: Session,
*,
tenant_id: str,
source_id: str,
) -> None:
batch = active_source_migration(
session,
tenant_id=tenant_id,
source_id=source_id,
)
if batch is not None:
raise CalendarMigrationError(
"Calendar source changes and synchronization are blocked while "
f"remote move {batch.id} is {batch.phase}."
)
def event_migration_batch_id(event: CalendarEvent) -> str | None:
metadata = event.metadata_ if isinstance(event.metadata_, dict) else {}
migration = metadata.get("calendar_migration")
if not isinstance(migration, dict) or not migration.get("locked"):
return None
value = str(migration.get("batch_id") or "").strip()
return value or None
def assert_event_not_migrating(event: CalendarEvent) -> None:
batch_id = event_migration_batch_id(event)
if batch_id:
raise CalendarMigrationError(
"Event changes are blocked while destructive Calendar move "
f"{batch_id} is being reconciled."
)
def _validate_remote_move_sources(
source: CalendarSyncSource,
target: CalendarSyncSource,
) -> None:
for label, item in (("source", source), ("target", target)):
if (
item.source_kind != "caldav"
or item.sync_direction != "two_way"
or not item.sync_enabled
or item.deleted_at is not None
):
raise CalendarMigrationError(
f"Remote move {label} must be an active two-way CalDAV source."
)
def _validate_remote_move_authorization(
*,
confirmation: str | None,
evidence_note: str | None,
) -> str:
if confirmation != REMOTE_MOVE_CONFIRMATION:
raise CalendarMigrationError(
f"Type {REMOTE_MOVE_CONFIRMATION!r} to authorize the destructive remote move."
)
evidence = str(evidence_note or "").strip()
if len(evidence) < 10:
raise CalendarMigrationError(
"Destructive remote moves require an evidence note of at least 10 characters."
)
return evidence
def _group_source_resources(
events: Sequence[CalendarEvent],
) -> dict[str, list[CalendarEvent]]:
grouped: dict[str, list[CalendarEvent]] = defaultdict(list)
for event in events:
if event.source_kind != "caldav" or not event.source_href or not event.etag:
raise CalendarMigrationError(
"Every moved event must have a synchronized CalDAV href and ETag; "
"synchronize and reconcile the source before moving it."
)
grouped[event.source_href].append(event)
if not grouped:
raise CalendarMigrationError(
"Remote move requires at least one synchronized Calendar resource."
)
for href, resource_events in grouped.items():
etags = {event.etag for event in resource_events}
if len(etags) != 1:
raise CalendarMigrationError(
f"CalDAV resource {href!r} has inconsistent ETags; synchronize before moving it."
)
return dict(grouped)
def _assert_no_uid_collisions(
session: Session,
*,
tenant_id: str,
target_calendar_id: str,
events: Sequence[CalendarEvent],
) -> None:
incoming = {(event.uid, event.recurrence_id) for event in events}
existing = {
(uid, recurrence_id)
for uid, recurrence_id in (
session.query(CalendarEvent.uid, CalendarEvent.recurrence_id)
.filter(
CalendarEvent.tenant_id == tenant_id,
CalendarEvent.calendar_id == target_calendar_id,
CalendarEvent.deleted_at.is_(None),
)
.all()
)
}
collisions = sorted(incoming & existing, key=lambda item: (item[0], item[1] or ""))
if collisions:
uid, recurrence_id = collisions[0]
suffix = f" recurrence {recurrence_id}" if recurrence_id else ""
raise CalendarMigrationError(
f"Target calendar already contains UID {uid!r}{suffix}; UIDs are "
"preserved and the collision must be resolved first."
)
def _destination_href(
*,
source: CalendarSyncSource,
target: CalendarSyncSource,
source_href: str,
) -> str:
from govoplan_calendar.backend.service import normalize_caldav_href
digest = hashlib.sha256(
f"{source.id}\0{target.id}\0{source_href}".encode("utf-8")
).hexdigest()[:40]
return normalize_caldav_href(
target.collection_url,
f"govoplan-move-{digest}.ics",
)
def _set_calendar_migration_metadata(
calendar: CalendarCollection,
*,
batch: CalendarMigrationBatch,
role: str,
) -> None:
metadata = dict(calendar.metadata_ or {})
metadata["remote_move"] = {
"batch_id": batch.id,
"role": role,
"status": batch.status,
"phase": batch.phase,
"source_calendar_id": batch.source_calendar_id,
"target_calendar_id": batch.target_calendar_id,
}
calendar.metadata_ = metadata
def _set_event_migration_metadata(
event: CalendarEvent,
*,
batch: CalendarMigrationBatch,
resource: CalendarMigrationResource,
) -> None:
metadata = dict(event.metadata_ or {})
metadata.pop("caldav", None)
metadata["calendar_migration"] = {
"batch_id": batch.id,
"resource_id": resource.id,
"locked": True,
"source_href": resource.source_href,
"destination_href": resource.destination_href,
}
event.metadata_ = metadata
def start_remote_move(
session: Session,
*,
tenant_id: str,
source_calendar: CalendarCollection,
target_calendar: CalendarCollection,
source: CalendarSyncSource,
target_source: CalendarSyncSource,
events: Sequence[CalendarEvent],
previous_event_states: dict[str, dict[str, Any]],
make_target_default: bool,
confirmation: str | None,
evidence_note: str | None,
user_id: str | None,
api_key_id: str | None,
) -> CalendarMigrationBatch:
from govoplan_calendar.backend.outbox import (
calendar_outbox_has_live_lease,
calendar_outbox_has_unresolved_desired_state,
enqueue_caldav_desired_state,
)
from govoplan_calendar.backend.service import (
clear_default_calendar,
record_calendar_event_change,
)
evidence = _validate_remote_move_authorization(
confirmation=confirmation,
evidence_note=evidence_note,
)
_validate_remote_move_sources(source, target_source)
if active_calendar_migration(
session,
tenant_id=tenant_id,
calendar_id=source_calendar.id,
) or active_calendar_migration(
session,
tenant_id=tenant_id,
calendar_id=target_calendar.id,
):
raise CalendarMigrationError(
"Source or target calendar already participates in an active remote move."
)
for item in (source, target_source):
if calendar_outbox_has_live_lease(session, source_id=item.id):
raise CalendarMigrationError(
"Remote move cannot start while either CalDAV source has an active delivery lease."
)
if calendar_outbox_has_unresolved_desired_state(session, source_id=item.id):
raise CalendarMigrationError(
"Remote move requires both CalDAV sources to have no unresolved outbound desired state."
)
grouped = _group_source_resources(events)
_assert_no_uid_collisions(
session,
tenant_id=tenant_id,
target_calendar_id=target_calendar.id,
events=events,
)
batch = CalendarMigrationBatch(
tenant_id=tenant_id,
source_calendar_id=source_calendar.id,
target_calendar_id=target_calendar.id,
source_sync_source_id=source.id,
target_sync_source_id=target_source.id,
total_resources=len(grouped),
total_events=len(events),
created_by_user_id=user_id,
created_by_api_key_id=api_key_id,
authorization_evidence={
"confirmation": confirmation,
"note": evidence,
"authorized_at": utcnow().isoformat(),
"source_sync_enabled": bool(source.sync_enabled),
"source_was_default": bool(source_calendar.is_default),
"target_was_default": bool(target_calendar.is_default),
},
)
session.add(batch)
session.flush()
_set_calendar_migration_metadata(source_calendar, batch=batch, role="source")
_set_calendar_migration_metadata(target_calendar, batch=batch, role="target")
for source_href, resource_events in sorted(grouped.items()):
destination_href = _destination_href(
source=source,
target=target_source,
source_href=source_href,
)
resource = CalendarMigrationResource(
tenant_id=tenant_id,
batch_id=batch.id,
source_href=source_href,
source_expected_etag=str(resource_events[0].etag),
destination_href=destination_href,
event_ids=[event.id for event in resource_events],
)
session.add(resource)
session.flush()
for event in resource_events:
event.calendar_id = target_calendar.id
event.source_kind = "local"
event.source_href = None
event.etag = None
_set_event_migration_metadata(
event,
batch=batch,
resource=resource,
)
session.flush()
destination_operation = enqueue_caldav_desired_state(
session,
source=target_source,
trigger_event=resource_events[0],
href=destination_href,
resource_events=list(resource_events),
payload_ics=events_to_ics(list(resource_events)),
expected_etag=None,
idempotency_context=(
f"calendar-migration:{batch.id}:{resource.id}:destination"
),
)
destination_metadata = dict(destination_operation.metadata_ or {})
destination_metadata.update(
{
"calendar_migration_batch_id": batch.id,
"calendar_migration_resource_id": resource.id,
"calendar_migration_role": "destination_put",
"overwrite": False,
}
)
destination_operation.metadata_ = destination_metadata
source_delete_operation = enqueue_caldav_desired_state(
session,
source=source,
trigger_event=None,
href=source_href,
resource_events=[],
payload_ics=None,
expected_etag=resource.source_expected_etag,
idempotency_context=(
f"calendar-migration:{batch.id}:{resource.id}:source-delete"
),
)
source_delete_metadata = dict(source_delete_operation.metadata_ or {})
source_delete_metadata.update(
{
"calendar_migration_batch_id": batch.id,
"calendar_migration_resource_id": resource.id,
"calendar_migration_role": "source_delete",
"depends_on_operation_id": destination_operation.id,
"overwrite": False,
}
)
source_delete_operation.metadata_ = source_delete_metadata
resource.destination_operation_id = destination_operation.id
resource.source_delete_operation_id = source_delete_operation.id
for event in resource_events:
record_calendar_event_change(
session,
event=event,
operation="updated",
user_id=user_id,
previous=previous_event_states[event.id],
)
source.sync_enabled = False
source.next_sync_at = None
source_metadata = dict(source.metadata_ or {})
source_metadata["remote_move_batch_id"] = batch.id
source.metadata_ = source_metadata
if make_target_default:
clear_default_calendar(session, tenant_id=tenant_id)
target_calendar.is_default = True
audit_event(
session,
tenant_id=tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action="calendar.remote_move.started",
object_type="calendar_migration_batch",
object_id=batch.id,
details={
"source_calendar_id": source_calendar.id,
"target_calendar_id": target_calendar.id,
"source_sync_source_id": source.id,
"target_sync_source_id": target_source.id,
"event_count": len(events),
"resource_count": len(grouped),
"evidence_note": evidence,
},
)
session.flush()
return batch
def migration_operation_dependency_ready(
session: Session,
operation: CalendarOutboxOperation,
) -> bool:
metadata = operation.metadata_ if isinstance(operation.metadata_, dict) else {}
if metadata.get("calendar_migration_role") != "source_delete":
return True
batch_id = str(metadata.get("calendar_migration_batch_id") or "")
batch = session.get(CalendarMigrationBatch, batch_id) if batch_id else None
if batch is None or batch.status in {"cancel_requested", "cancelled"}:
return False
destination_ids = [
value
for (value,) in session.query(
CalendarMigrationResource.destination_operation_id
)
.filter(CalendarMigrationResource.batch_id == batch.id)
.all()
if value
]
if not destination_ids:
return False
succeeded = int(
session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.id.in_(destination_ids),
CalendarOutboxOperation.status == "succeeded",
)
.count()
)
return succeeded == len(destination_ids)
def _operation_by_id(
session: Session,
operation_id: str | None,
) -> CalendarOutboxOperation | None:
return session.get(CalendarOutboxOperation, operation_id) if operation_id else None
def _update_resource_state(
resource: CalendarMigrationResource,
destination: CalendarOutboxOperation | None,
source_delete: CalendarOutboxOperation | None,
) -> None:
if destination is None or source_delete is None:
resource.status = "invalid"
resource.last_error = "Migration outbox operation is missing."
return
if destination.status in BLOCKING_OUTBOX_STATUSES:
resource.status = "destination_blocked"
resource.last_error = destination.last_error or destination.status
return
if destination.status != "succeeded":
resource.status = "copy_pending"
resource.last_error = destination.last_error
return
if source_delete.status in BLOCKING_OUTBOX_STATUSES:
resource.status = (
"source_retained"
if source_delete.status == "cancelled"
else "source_blocked"
)
resource.last_error = source_delete.last_error
return
if source_delete.status == "succeeded":
resource.status = "completed"
resource.last_error = None
return
resource.status = (
"delete_in_progress" if source_delete.attempt_count else "copy_succeeded"
)
resource.last_error = source_delete.last_error
def _clear_migration_metadata(
calendar: CalendarCollection | None,
) -> None:
if calendar is None:
return
metadata = dict(calendar.metadata_ or {})
metadata.pop("remote_move", None)
calendar.metadata_ = metadata
def _unlock_batch_events(session: Session, batch: CalendarMigrationBatch) -> None:
event_ids = {
str(event_id)
for resource in batch.resources
for event_id in resource.event_ids or []
}
if not event_ids:
return
for event in (
session.query(CalendarEvent)
.filter(
CalendarEvent.tenant_id == batch.tenant_id,
CalendarEvent.id.in_(sorted(event_ids)),
)
.all()
):
metadata = dict(event.metadata_ or {})
metadata.pop("calendar_migration", None)
event.metadata_ = metadata
def _finalize_completed_batch(
session: Session,
batch: CalendarMigrationBatch,
) -> None:
from govoplan_calendar.backend.service import retire_sync_source
now = utcnow()
source_calendar = session.get(CalendarCollection, batch.source_calendar_id)
target_calendar = session.get(CalendarCollection, batch.target_calendar_id)
source = session.get(CalendarSyncSource, batch.source_sync_source_id)
_unlock_batch_events(session, batch)
_clear_migration_metadata(source_calendar)
_clear_migration_metadata(target_calendar)
if source_calendar is not None:
source_calendar.is_default = False
source_calendar.deleted_at = now
if source is not None and source.deleted_at is None:
retire_sync_source(
session,
tenant_id=batch.tenant_id,
source=source,
deleted_at=now,
deletion_reason="remote_move_completed",
user_id=batch.created_by_user_id,
api_key_id=batch.created_by_api_key_id,
)
batch.status = "completed"
batch.phase = "completed"
batch.completed_at = now
batch.last_error = None
audit_event(
session,
tenant_id=batch.tenant_id,
user_id=None,
action="calendar.remote_move.completed",
object_type="calendar_migration_batch",
object_id=batch.id,
details={
"source_calendar_id": batch.source_calendar_id,
"target_calendar_id": batch.target_calendar_id,
"event_count": batch.total_events,
"resource_count": batch.total_resources,
},
)
def _finalize_cancelled_batch(
session: Session,
batch: CalendarMigrationBatch,
) -> None:
now = utcnow()
source_calendar = session.get(CalendarCollection, batch.source_calendar_id)
target_calendar = session.get(CalendarCollection, batch.target_calendar_id)
source = session.get(CalendarSyncSource, batch.source_sync_source_id)
_unlock_batch_events(session, batch)
_clear_migration_metadata(source_calendar)
_clear_migration_metadata(target_calendar)
if source_calendar is not None:
source_calendar.is_default = bool(
(batch.authorization_evidence or {}).get("source_was_default", False)
)
if target_calendar is not None:
target_calendar.is_default = bool(
(batch.authorization_evidence or {}).get("target_was_default", False)
)
if source is not None and source.deleted_at is None:
source.sync_enabled = bool(
(batch.authorization_evidence or {}).get("source_sync_enabled", True)
)
source.next_sync_at = now if source.sync_enabled else None
metadata = dict(source.metadata_ or {})
metadata.pop("remote_move_batch_id", None)
source.metadata_ = metadata
batch.status = "cancelled"
batch.phase = "cancelled_source_retained"
batch.completed_at = now
def refresh_calendar_migration_batch(
session: Session,
*,
batch_id: str,
) -> CalendarMigrationBatch:
batch = (
session.query(CalendarMigrationBatch)
.filter(CalendarMigrationBatch.id == batch_id)
.populate_existing()
.with_for_update()
.one_or_none()
)
if batch is None:
raise CalendarMigrationError("Calendar migration batch not found.")
if batch.status in {"completed", "cancelled"}:
return batch
destination_states: list[str] = []
delete_states: list[str] = []
errors: list[str] = []
for resource in batch.resources:
destination = _operation_by_id(session, resource.destination_operation_id)
source_delete = _operation_by_id(session, resource.source_delete_operation_id)
_update_resource_state(resource, destination, source_delete)
destination_states.append(destination.status if destination else "missing")
delete_states.append(source_delete.status if source_delete else "missing")
if resource.last_error:
errors.append(resource.last_error)
all_destinations_succeeded = bool(destination_states) and all(
value == "succeeded" for value in destination_states
)
all_deletes_succeeded = bool(delete_states) and all(
value == "succeeded" for value in delete_states
)
if batch.status == "cancel_requested":
if all(
value in {"succeeded", "conflict", "dead", "cancelled"}
for value in destination_states
):
_finalize_cancelled_batch(session, batch)
else:
batch.phase = "finishing_destination_copies"
elif all_deletes_succeeded:
_finalize_completed_batch(session, batch)
elif any(value in BLOCKING_OUTBOX_STATUSES for value in destination_states):
batch.status = "blocked"
batch.phase = "destination_conflict"
elif all_destinations_succeeded and any(
value in {"conflict", "dead"} for value in delete_states
):
batch.status = "blocked"
batch.phase = "source_delete_conflict"
elif all_destinations_succeeded:
batch.status = "active"
batch.phase = "deleting_source"
else:
batch.status = "active"
batch.phase = "copying_destination"
batch.last_error = errors[0][:4000] if errors else None
source_calendar = session.get(CalendarCollection, batch.source_calendar_id)
target_calendar = session.get(CalendarCollection, batch.target_calendar_id)
if batch.status in ACTIVE_MIGRATION_STATUSES:
if source_calendar is not None:
_set_calendar_migration_metadata(
source_calendar, batch=batch, role="source"
)
if target_calendar is not None:
_set_calendar_migration_metadata(
target_calendar, batch=batch, role="target"
)
session.flush()
return batch
def refresh_migration_for_operation(
session: Session,
operation: CalendarOutboxOperation,
) -> None:
metadata = operation.metadata_ if isinstance(operation.metadata_, dict) else {}
batch_id = str(metadata.get("calendar_migration_batch_id") or "").strip()
if batch_id:
refresh_calendar_migration_batch(session, batch_id=batch_id)
def get_calendar_migration_batch(
session: Session,
*,
tenant_id: str,
batch_id: str,
refresh: bool = True,
) -> CalendarMigrationBatch:
batch = (
session.query(CalendarMigrationBatch)
.filter(
CalendarMigrationBatch.tenant_id == tenant_id,
CalendarMigrationBatch.id == batch_id,
)
.one_or_none()
)
if batch is None:
raise CalendarMigrationError("Calendar migration batch not found.")
return (
refresh_calendar_migration_batch(session, batch_id=batch.id)
if refresh and batch.status in ACTIVE_MIGRATION_STATUSES
else batch
)
def list_calendar_migration_batches(
session: Session,
*,
tenant_id: str,
calendar_id: str | None = None,
limit: int = 100,
) -> list[CalendarMigrationBatch]:
query = session.query(CalendarMigrationBatch).filter(
CalendarMigrationBatch.tenant_id == tenant_id
)
if calendar_id:
query = query.filter(
or_(
CalendarMigrationBatch.source_calendar_id == calendar_id,
CalendarMigrationBatch.target_calendar_id == calendar_id,
)
)
batches = (
query.order_by(
CalendarMigrationBatch.created_at.desc(),
CalendarMigrationBatch.id.desc(),
)
.limit(max(1, min(limit, 500)))
.all()
)
return [
refresh_calendar_migration_batch(session, batch_id=batch.id)
if batch.status in ACTIVE_MIGRATION_STATUSES
else batch
for batch in batches
]
def cancel_calendar_migration_batch(
session: Session,
*,
tenant_id: str,
batch_id: str,
evidence_note: str,
user_id: str | None,
api_key_id: str | None,
) -> CalendarMigrationBatch:
note = evidence_note.strip()
if len(note) < 10:
raise CalendarMigrationError(
"Cancelling a remote move requires an evidence note of at least "
"10 characters."
)
batch = get_calendar_migration_batch(
session,
tenant_id=tenant_id,
batch_id=batch_id,
refresh=False,
)
if batch.status not in {"active", "blocked"}:
raise CalendarMigrationError(
f"Calendar migration cannot be cancelled while it is {batch.status}."
)
delete_ids = [
resource.source_delete_operation_id
for resource in batch.resources
if resource.source_delete_operation_id
]
session.query(CalendarSyncSource).filter(
CalendarSyncSource.id == batch.source_sync_source_id,
CalendarSyncSource.tenant_id == tenant_id,
).with_for_update().one()
delete_operations = (
session.query(CalendarOutboxOperation)
.filter(CalendarOutboxOperation.id.in_(delete_ids))
.order_by(CalendarOutboxOperation.id.asc())
.with_for_update()
.all()
)
batch = refresh_calendar_migration_batch(session, batch_id=batch.id)
if batch.status not in {"active", "blocked"}:
raise CalendarMigrationError(
f"Calendar migration cannot be cancelled while it is {batch.status}."
)
if any(
operation.attempt_count > 0 or operation.status in {"in_progress", "succeeded"}
for operation in delete_operations
):
raise CalendarMigrationError(
"Cancellation is no longer safe because conditional source deletion "
"has started; reconcile the batch to completion."
)
now = utcnow()
for operation in delete_operations:
operation.status = "cancelled"
operation.completed_at = now
operation.last_error = "Remote move cancelled before source deletion."
operation.lease_token = None
operation.lease_expires_at = None
batch.status = "cancel_requested"
batch.phase = "finishing_destination_copies"
batch.cancellation_evidence = {
"note": note,
"cancelled_at": now.isoformat(),
"cancelled_by_user_id": user_id,
"cancelled_by_api_key_id": api_key_id,
}
audit_event(
session,
tenant_id=tenant_id,
user_id=user_id,
api_key_id=api_key_id,
action="calendar.remote_move.cancel_requested",
object_type="calendar_migration_batch",
object_id=batch.id,
details={"evidence_note": note},
)
return refresh_calendar_migration_batch(session, batch_id=batch.id)
def calendar_migration_response(
session: Session,
batch: CalendarMigrationBatch,
) -> dict[str, Any]:
resources: list[dict[str, Any]] = []
copied = 0
deleted = 0
conflicts = 0
source_delete_started = False
for resource in batch.resources:
destination = _operation_by_id(session, resource.destination_operation_id)
source_delete = _operation_by_id(session, resource.source_delete_operation_id)
if destination and destination.status == "succeeded":
copied += 1
if source_delete and source_delete.status == "succeeded":
deleted += 1
if source_delete and (
source_delete.attempt_count > 0
or source_delete.status in {"in_progress", "succeeded"}
):
source_delete_started = True
if resource.status in {"destination_blocked", "source_blocked", "invalid"}:
conflicts += 1
resources.append(
{
"id": resource.id,
"source_href": resource.source_href,
"destination_href": resource.destination_href,
"event_ids": list(resource.event_ids or []),
"status": resource.status,
"destination_operation_id": resource.destination_operation_id,
"destination_operation_status": destination.status
if destination
else None,
"source_delete_operation_id": resource.source_delete_operation_id,
"source_delete_operation_status": source_delete.status
if source_delete
else None,
"last_error": resource.last_error,
}
)
return {
"id": batch.id,
"migration_kind": batch.migration_kind,
"status": batch.status,
"phase": batch.phase,
"source_calendar_id": batch.source_calendar_id,
"target_calendar_id": batch.target_calendar_id,
"source_sync_source_id": batch.source_sync_source_id,
"target_sync_source_id": batch.target_sync_source_id,
"total_resources": batch.total_resources,
"copied_resources": copied,
"deleted_source_resources": deleted,
"conflict_count": conflicts,
"total_events": batch.total_events,
"last_error": batch.last_error,
"can_cancel": batch.status in {"active", "blocked"}
and not source_delete_started,
"authorization_evidence": dict(batch.authorization_evidence or {}),
"cancellation_evidence": (
dict(batch.cancellation_evidence)
if batch.cancellation_evidence
else None
),
"created_by_user_id": batch.created_by_user_id,
"created_by_api_key_id": batch.created_by_api_key_id,
"created_at": batch.created_at,
"updated_at": batch.updated_at,
"completed_at": batch.completed_at,
"resources": resources,
}
def migration_source_ids_in_progress(
session: Session,
*,
tenant_id: str | None = None,
) -> set[str]:
query = session.query(
CalendarMigrationBatch.source_sync_source_id,
CalendarMigrationBatch.target_sync_source_id,
).filter(CalendarMigrationBatch.status.in_(sorted(ACTIVE_MIGRATION_STATUSES)))
if tenant_id is not None:
query = query.filter(CalendarMigrationBatch.tenant_id == tenant_id)
return {source_id for row in query.all() for source_id in row if source_id}
__all__ = [
"ACTIVE_MIGRATION_STATUSES",
"CalendarMigrationError",
"REMOTE_MOVE_CONFIRMATION",
"active_calendar_migration",
"active_source_migration",
"active_tenant_migration",
"assert_calendar_not_migrating",
"assert_event_not_migrating",
"assert_source_not_migrating",
"calendar_migration_response",
"cancel_calendar_migration_batch",
"get_calendar_migration_batch",
"list_calendar_migration_batches",
"migration_operation_dependency_ready",
"migration_source_ids_in_progress",
"refresh_calendar_migration_batch",
"refresh_migration_for_operation",
"start_remote_move",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from govoplan_calendar.backend.db.models import (
CalendarOutboxOperation,
CalendarSyncSource,
)
from govoplan_core.core.provider_governance import (
ExternalProviderRuntimeState,
ExternalProviderStateContext,
)
CALDAV_PROVIDER_ID = "calendar.caldav_sync"
ICS_PROVIDER_ID = "calendar.ics_subscription"
GRAPH_PROVIDER_ID = "calendar.microsoft_graph"
EWS_PROVIDER_ID = "calendar.exchange_ews"
OPEN_XCHANGE_PROVIDER_ID = "calendar.open_xchange"
OPEN_XCHANGE_PROFILE_KIND = "open_xchange"
_HEALTHY_STATUSES = frozenset({"ok", "outbound_ok"})
_ERROR_STATUSES = frozenset({"error", "outbound_error"})
_WARNING_STATUSES = frozenset(
{"outbound_retry", "outbound_cancelled", "outbound_discarded"}
)
_ACTIVE_OUTBOX_STATUSES = frozenset({"pending", "retry", "in_progress"})
def caldav_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=CALDAV_PROVIDER_ID,
source_kinds=("caldav",),
include_outbox=True,
exclude_profile_kinds=(OPEN_XCHANGE_PROFILE_KIND,),
)
def open_xchange_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=OPEN_XCHANGE_PROVIDER_ID,
source_kinds=("caldav",),
include_outbox=True,
profile_kind=OPEN_XCHANGE_PROFILE_KIND,
)
def ics_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=ICS_PROVIDER_ID,
source_kinds=("ics", "webcal"),
)
def graph_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=GRAPH_PROVIDER_ID,
source_kinds=("graph",),
)
def ews_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _provider_states(
context,
provider_id=EWS_PROVIDER_ID,
source_kinds=("ews",),
)
def _provider_states(
context: ExternalProviderStateContext,
*,
provider_id: str,
source_kinds: tuple[str, ...],
include_outbox: bool = False,
profile_kind: str | None = None,
exclude_profile_kinds: tuple[str, ...] = (),
) -> tuple[ExternalProviderRuntimeState, ...]:
if not isinstance(context.session, Session):
raise RuntimeError("Calendar provider state requires a database session.")
statement = select(CalendarSyncSource).where(
CalendarSyncSource.source_kind.in_(source_kinds),
CalendarSyncSource.deleted_at.is_(None),
)
if context.tenant_id is not None:
statement = statement.where(
CalendarSyncSource.tenant_id == context.tenant_id
)
profile_expression = CalendarSyncSource.metadata_["integration_profile"].as_string()
if profile_kind is not None:
statement = statement.where(profile_expression == profile_kind)
elif exclude_profile_kinds:
statement = statement.where(
or_(
CalendarSyncSource.metadata_.is_(None),
profile_expression.is_(None),
profile_expression.notin_(exclude_profile_kinds),
)
)
sources = tuple(
context.session.scalars(
statement.order_by(
CalendarSyncSource.tenant_id,
CalendarSyncSource.id,
).limit(context.max_items + 1)
)
)
if not sources:
return ()
counts = (
_outbox_counts(context.session, tuple(item.id for item in sources))
if include_outbox
else {}
)
observed_at = datetime.now(UTC)
return tuple(
_source_state(
source,
provider_id=provider_id,
observed_at=observed_at,
outbox_counts=counts.get(source.id, {}),
)
for source in sources
)
def _outbox_counts(
session: Session,
source_ids: tuple[str, ...],
) -> dict[str, dict[str, int]]:
result: dict[str, dict[str, int]] = defaultdict(dict)
if not source_ids:
return result
rows = session.execute(
select(
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.status,
func.count(CalendarOutboxOperation.id),
)
.where(CalendarOutboxOperation.source_id.in_(source_ids))
.group_by(
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.status,
)
)
for source_id, status, count in rows:
result[str(source_id)][str(status)] = int(count)
return result
def _source_state(
source: CalendarSyncSource,
*,
provider_id: str,
observed_at: datetime,
outbox_counts: dict[str, int],
) -> ExternalProviderRuntimeState:
active = bool(source.sync_enabled)
conflict_count = int(outbox_counts.get("conflict", 0))
dead_count = int(outbox_counts.get("dead", 0))
pending_count = sum(
int(outbox_counts.get(status, 0))
for status in _ACTIVE_OUTBOX_STATUSES
)
health = _health_state(
active=active,
last_status=source.last_status,
conflict_count=conflict_count,
dead_count=dead_count,
pending_count=pending_count,
)
conflict = (
"blocked"
if dead_count
else "pending"
if conflict_count
else "clear"
)
freshness = _freshness_state(source, observed_at=observed_at)
recovery = (
"not_applicable"
if not active
else "attention"
if conflict_count or dead_count or health == "error"
else "ready"
)
return ExternalProviderRuntimeState(
provider_id=provider_id,
binding_ref=f"calendar:sync-source:{source.id}",
authority_mode=(
"governed_sync"
if source.source_kind == "caldav" and source.sync_direction == "two_way"
else "external_mirror"
),
observed_at=observed_at,
configured=True,
active=active,
health=health,
freshness=freshness,
conflict=conflict,
recovery=recovery,
last_success_at=_aware(source.last_synced_at),
detail=_state_detail(
active=active,
health=health,
freshness=freshness,
conflict=conflict,
),
metrics={
"pending_operations": pending_count,
"conflict_operations": conflict_count,
"dead_operations": dead_count,
"sync_interval_seconds": source.sync_interval_seconds,
},
)
def _health_state(
*,
active: bool,
last_status: str | None,
conflict_count: int,
dead_count: int,
pending_count: int,
) -> str:
if not active:
return "inactive"
if dead_count or last_status in _ERROR_STATUSES:
return "error"
if conflict_count or last_status in _WARNING_STATUSES:
return "warning"
if last_status in _HEALTHY_STATUSES:
return "healthy"
if pending_count:
return "warning"
return "unknown"
def _freshness_state(
source: CalendarSyncSource,
*,
observed_at: datetime,
) -> str:
if not source.sync_enabled:
return "not_applicable"
last_synced_at = _aware(source.last_synced_at)
if last_synced_at is None:
return "unknown"
interval = max(int(source.sync_interval_seconds or 0), 60)
grace = timedelta(seconds=max(interval * 2, 1_800))
return "current" if observed_at - last_synced_at <= grace else "stale"
def _state_detail(
*,
active: bool,
health: str,
freshness: str,
conflict: str,
) -> str:
if not active:
return "Synchronization is configured but disabled."
if conflict != "clear":
return "Synchronization has unresolved external outcomes."
if health == "error":
return "The latest synchronization attempt failed."
if freshness == "stale":
return "The last successful synchronization is stale."
if health == "healthy":
return "Synchronization is healthy."
return "Synchronization has not produced a conclusive health observation."
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
__all__ = [
"CALDAV_PROVIDER_ID",
"EWS_PROVIDER_ID",
"GRAPH_PROVIDER_ID",
"ICS_PROVIDER_ID",
"caldav_provider_states",
"ews_provider_states",
"graph_provider_states",
"ics_provider_states",
]
+44
View File
@@ -0,0 +1,44 @@
"""Data-only recurrence work; resource boundary, not an arbitrary-code sandbox."""
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from govoplan_core.security.bounded_process import ProcessLimits
from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload
RECURRENCE_LIMITS = ProcessLimits(
wall_seconds=5, cpu_seconds=3, memory_bytes=256 * 1024 * 1024,
input_bytes=4 * 1024 * 1024, output_bytes=4 * 1024 * 1024,
)
def expand_recurrences_worker(payload: bytes) -> bytes:
from govoplan_calendar.backend.ical import _expand_event_occurrences
value = decode_worker_payload(payload, max_bytes=RECURRENCE_LIMITS.input_bytes)
try:
if not isinstance(value, dict) or set(value) != {"events", "start", "end", "limit"}:
raise ValueError("Invalid request")
if not isinstance(value["events"], list) or len(value["events"]) > 2_000:
raise ValueError("Invalid event count")
if type(value["limit"]) is not int or not 1 <= value["limit"] <= 10_000:
raise ValueError("Invalid result limit")
if not isinstance(value["start"], datetime) or not isinstance(value["end"], datetime):
raise ValueError("Invalid range")
remaining = value["limit"]
batches = []
for event in value["events"]:
if not isinstance(event, dict) or set(event) != {
"uid", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
}:
raise ValueError("Invalid event")
batch = _expand_event_occurrences(
SimpleNamespace(**event), value["start"], value["end"], limit=remaining,
)
remaining -= len(batch)
batches.append(batch)
result = {"occurrences": batches}
except (ValueError, TypeError, OverflowError, KeyError, AttributeError):
result = {"error": "invalid_or_excessive_recurrence"}
return encode_worker_payload(result, max_bytes=RECURRENCE_LIMITS.output_bytes)
File diff suppressed because it is too large Load Diff
+6 -32
View File
@@ -1,36 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from govoplan_core.core.runtime import ModuleRuntimeState
_runtime_registry: object | None = None _runtime = ModuleRuntimeState("Calendar")
_runtime_settings: object | None = None
configure_runtime = _runtime.configure_runtime
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None: get_registry = _runtime.get_registry
global _runtime_registry, _runtime_settings get_settings = _runtime.get_settings
if registry is not None: settings = _runtime.settings
_runtime_registry = registry
if settings is not None:
_runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object:
if _runtime_settings is not None:
return _runtime_settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError("GovOPlaN Calendar runtime settings are not configured") from exc
return legacy_settings
class SettingsProxy:
def __getattr__(self, name: str) -> Any:
return getattr(get_settings(), name)
settings = SettingsProxy()
+244 -7
View File
@@ -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,9 @@ 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
destructive_confirmation: str | None = Field(default=None, max_length=100)
evidence_note: str | None = Field(default=None, max_length=2000)
class CalendarCollectionResponse(BaseModel): class CalendarCollectionResponse(BaseModel):
@@ -70,9 +81,59 @@ class CalendarCollectionListResponse(BaseModel):
calendars: list[CalendarCollectionResponse] = Field(default_factory=list) calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
class CalendarCalDavSourceCreateRequest(BaseModel): class CalendarMigrationResourceResponse(BaseModel):
id: str
source_href: str
destination_href: str
event_ids: list[str] = Field(default_factory=list)
status: str
destination_operation_id: str | None = None
destination_operation_status: str | None = None
source_delete_operation_id: str | None = None
source_delete_operation_status: str | None = None
last_error: str | None = None
class CalendarMigrationBatchResponse(BaseModel):
id: str
migration_kind: str
status: str
phase: str
source_calendar_id: str
target_calendar_id: str
source_sync_source_id: str
target_sync_source_id: str
total_resources: int
copied_resources: int
deleted_source_resources: int
conflict_count: int
total_events: int
last_error: str | None = None
can_cancel: bool = False
authorization_evidence: dict[str, Any] = Field(default_factory=dict)
cancellation_evidence: dict[str, Any] | None = None
created_by_user_id: str | None = None
created_by_api_key_id: str | None = None
created_at: datetime
updated_at: datetime
completed_at: datetime | None = None
resources: list[CalendarMigrationResourceResponse] = Field(default_factory=list)
class CalendarMigrationBatchListResponse(BaseModel):
migrations: list[CalendarMigrationBatchResponse] = Field(default_factory=list)
class CalendarMigrationCancelRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
evidence_note: str = Field(min_length=10, max_length=2000)
class CalendarSyncSourceCreateRequest(BaseModel):
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 +149,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 +173,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
@@ -118,6 +187,7 @@ class CalendarCalDavSourceResponse(BaseModel):
auth_type: str auth_type: str
username: str | None = None username: str | None = None
credential_ref: str | None = None credential_ref: str | None = None
credential_envelope_id: str | None = None
has_credential: bool = False has_credential: bool = False
sync_enabled: bool sync_enabled: bool
sync_interval_seconds: int sync_interval_seconds: int
@@ -135,11 +205,64 @@ 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 CalendarCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class CalendarCredentialEnvelopeListResponse(BaseModel):
credentials: list[CalendarCredentialEnvelopeResponse] = Field(default_factory=list)
class CalendarCalDavDiscoveryRequest(BaseModel):
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 +270,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 +288,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 +304,70 @@ 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 CalendarOutboxActionAvailability(BaseModel):
allowed: bool = False
reason: str | None = None
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)
actions: dict[str, CalendarOutboxActionAvailability] = 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")
@@ -243,6 +434,16 @@ class CalendarEventUpdateRequest(BaseModel):
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
class CalendarEventOccurrenceUpdateRequest(CalendarEventUpdateRequest):
recurrence_id: str = Field(min_length=1, max_length=255)
class CalendarEventOccurrenceDeleteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
recurrence_id: str = Field(min_length=1, max_length=255)
class CalendarEventResponse(BaseModel): class CalendarEventResponse(BaseModel):
id: str id: str
tenant_id: str tenant_id: str
@@ -277,12 +478,48 @@ class CalendarEventResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
instance_id: str | None = None
series_event_id: str | None = None
is_occurrence: bool = False
is_override: bool = False
class CalendarEventListResponse(BaseModel): 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 CalendarViewPreferencesUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
dim_weekends: bool | None = None
dim_off_hours: bool | None = None
workday_start_hour: int | None = Field(default=None, ge=0, le=23)
workday_end_hour: int | None = Field(default=None, ge=1, le=24)
continuous_virtualization: bool | None = None
continuous_overscan_weeks: int | None = Field(default=None, ge=2, le=20)
alternate_continuous_months: bool | None = None
class CalendarViewPreferencesResponse(BaseModel):
dim_weekends: bool
dim_off_hours: bool
workday_start_hour: int
workday_end_hour: int
continuous_virtualization: bool
continuous_overscan_weeks: int
alternate_continuous_months: bool
overridden_fields: list[str] = Field(default_factory=list)
defaults: dict[str, bool | int] = Field(default_factory=dict)
class CalendarFreeBusyRequest(BaseModel): class CalendarFreeBusyRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -0,0 +1,285 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
from govoplan_calendar.backend.service import calendar_is_visible_to_principal
PROVIDER_ID = "calendar.events"
RESOURCE_TYPE = "calendar_event"
READ_SCOPE = "calendar:event:read"
ADMIN_SCOPE = "calendar:calendar:admin"
class CalendarSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="calendar",
resource_type=RESOURCE_TYPE,
label="Calendar events",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = (
select(CalendarEvent, CalendarCollection)
.join(
CalendarCollection,
CalendarCollection.id == CalendarEvent.calendar_id,
)
.where(
CalendarEvent.tenant_id == request.tenant_id,
CalendarEvent.deleted_at.is_(None),
CalendarCollection.tenant_id == request.tenant_id,
CalendarCollection.deleted_at.is_(None),
)
)
if request.cursor:
statement = statement.where(CalendarEvent.id > request.cursor)
rows = list(
db.execute(
statement.order_by(CalendarEvent.id).limit(request.limit + 1)
).all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(CalendarEvent.updated_at)).where(
CalendarEvent.tenant_id == request.tenant_id,
CalendarEvent.deleted_at.is_(None),
)
)
return SearchBackfillPage(
documents=tuple(
_document(event, calendar=calendar)
for event, calendar in selected
),
next_cursor=(
selected[-1][0].id if has_more and selected else None
),
complete=not has_more,
high_watermark=(
high_watermark.isoformat()
if high_watermark is not None
else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
return decisions
valid = tuple(
item
for item in requests
if item.reference.tenant_id == principal.tenant_id
and item.reference.module_id == "calendar"
and item.reference.resource_type == RESOURCE_TYPE
)
if not valid:
return decisions
db = _session(session)
ids = {item.reference.resource_id for item in valid}
events = {
event.id: (event, calendar)
for event, calendar in db.execute(
select(CalendarEvent, CalendarCollection)
.join(
CalendarCollection,
CalendarCollection.id == CalendarEvent.calendar_id,
)
.where(
CalendarEvent.id.in_(ids),
CalendarEvent.tenant_id == principal.tenant_id,
CalendarEvent.deleted_at.is_(None),
CalendarCollection.tenant_id == principal.tenant_id,
CalendarCollection.deleted_at.is_(None),
)
).all()
}
user_id = str(
getattr(principal.user, "id", "") or principal.membership_id or ""
)
for item in valid:
match = events.get(item.reference.resource_id)
if match is None:
continue
_event, calendar = match
decisions[item.reference.key] = calendar_is_visible_to_principal(
calendar,
user_id=user_id,
group_ids=principal.group_ids,
can_admin=principal.has(ADMIN_SCOPE),
)
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "calendar"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
row = db.get(CalendarEvent, event.resource.id)
calendar = (
db.get(CalendarCollection, row.calendar_id)
if row is not None and row.tenant_id == event.tenant.id
else None
)
deleted = (
row is None
or row.tenant_id != event.tenant.id
or row.deleted_at is not None
or calendar is None
or calendar.deleted_at is not None
)
cursor = event.event_id
document = (
None
if deleted
else _document(row, calendar=calendar, change_cursor=cursor)
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="calendar",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="delete" if deleted else "upsert",
reference=reference,
source_revision=(
document.source_revision if document is not None else cursor
),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_calendar_search_source(
_context: ModuleContext,
) -> CalendarSearchSource:
return CalendarSearchSource()
def _document(
event: CalendarEvent,
*,
calendar: CalendarCollection,
change_cursor: str | None = None,
) -> SearchDocument:
tokens = [f"scope:{READ_SCOPE}"]
if calendar.visibility == "private":
if calendar.owner_type == "user" and calendar.owner_id:
tokens.append(f"membership:{calendar.owner_id}")
elif calendar.owner_type == "group" and calendar.owner_id:
tokens.append(f"group:{calendar.owner_id}")
elif calendar.created_by_user_id:
tokens.append(f"membership:{calendar.created_by_user_id}")
updated_at = event.updated_at or event.created_at
keywords = tuple(
value
for value in (
calendar.name,
event.location,
event.status,
*tuple(event.categories or ()),
)
if value
)[:100]
return SearchDocument(
tenant_id=event.tenant_id,
module_id="calendar",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=event.id,
title=event.summary,
url=f"/calendar?eventId={quote(event.id, safe='')}",
summary=(event.description or event.location or "")[:4000] or None,
body=" ".join(
value
for value in (event.description, event.location, calendar.name)
if value
)[:200_000],
keywords=tuple(value[:200] for value in keywords),
visibility="restricted",
acl_tokens=tuple(dict.fromkeys(tokens)),
metadata={
"calendar_id": event.calendar_id,
"calendar_name": calendar.name,
"start_at": event.start_at.isoformat(),
"end_at": event.end_at.isoformat() if event.end_at else None,
"all_day": event.all_day,
"status": event.status,
},
source_revision=f"{event.sequence}:{updated_at.isoformat()}",
change_cursor=change_cursor,
source_updated_at=updated_at,
requires_authorization_recheck=True,
)
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Calendar search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Calendar search requires a SQLAlchemy session.")
return value
__all__ = [
"CalendarSearchSource",
"PROVIDER_ID",
"RESOURCE_TYPE",
"create_calendar_search_source",
]
File diff suppressed because it is too large Load Diff
+60 -16
View File
@@ -5,23 +5,67 @@ 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.core.module_entitlements import tenant_execution_scope
from govoplan_core.core.worker_runtime import build_worker_platform_registry
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_core.settings import settings
with get_database().SessionLocal() as session: with get_database().SessionLocal() as session:
results = sync_due_caldav_sources(session, tenant_id=tenant_id, limit=limit) registry = build_worker_platform_registry(settings)
resolver = registry.tenant_entitlement_resolver()
admissions = (
(
resolver.admission(
session,
tenant_id=tenant_id,
module_id="calendar",
work_state="accepted",
),
)
if tenant_id is not None
else resolver.active_tenant_admissions(
session,
module_id="calendar",
work_state="accepted",
)
)
payload: list[dict[str, object]] = []
for admission in admissions:
if not admission.allowed:
payload.append(
{
"tenant_id": admission.tenant_id,
"status": "operator_action_required",
"operator_action": admission.payload(),
}
)
continue
with tenant_execution_scope(
resolver,
session,
tenant_id=admission.tenant_id,
work_state="accepted",
):
results = sync_due_sources(
session,
tenant_id=admission.tenant_id,
limit=limit,
)
payload.extend(
{
"tenant_id": admission.tenant_id,
"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
)
session.commit() session.commit()
return [ return payload
{
"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
]
+1009 -39
View File
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from govoplan_calendar.backend.caldav import (
CalDAVClient,
CalDAVError,
absolute_dav_url,
urllib_transport,
)
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CalDAVUrlSecurityTests(unittest.TestCase):
def test_transport_revalidates_dns_at_connection_time(self) -> None:
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
CalDAVError,
"non-public network",
):
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
socket_factory.assert_not_called()
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/calendars/ada/"
self.assertEqual(
absolute_dav_url(base_url, "/principals/users/ada/"),
"https://dav.example.test/principals/users/ada/",
)
with self.assertRaisesRegex(CalDAVError, "configured collection origin"):
absolute_dav_url(base_url, "https://evil.example.test/steal/")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
def test_object_href_rejects_userinfo_query_and_fragment(self) -> None:
client = CalDAVClient(collection_url="https://dav.example.test/calendars/ada")
with self.assertRaisesRegex(CalDAVError, "embedded credentials"):
client.object_url("https://user:secret@dav.example.test/calendars/ada/event.ics")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics?download=1")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics#fragment")
self.assertEqual(
client.object_url("/calendars/ada/event.ics"),
"https://dav.example.test/calendars/ada/event.ics",
)
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen.ics")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as redirect_url:
status, _headers, _body = urllib_transport(
"GET",
f"{redirect_url}/event.ics",
{"Authorization": "Bearer top-secret"},
None,
2,
)
self.assertEqual(status, 302)
self.assertEqual(forwarded_authorization, [])
def test_transport_preserves_same_origin_redirects(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/event.ics":
self.send_response(302)
self.send_header("Location", "/redirected.ics")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"calendar")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = urllib_transport(
"GET",
f"{source_url}/event.ics",
{"Authorization": "Bearer expected"},
None,
2,
)
self.assertEqual(status, 200)
self.assertEqual(body, b"calendar")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
if __name__ == "__main__":
unittest.main()
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.capabilities import (
SqlCalendarExternalProfileProvider,
SqlCalendarInvitationProvider,
SqlCalendarSchedulingProvider,
)
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest
from govoplan_calendar.backend.service import create_calendar
from govoplan_core.core.calendar import (
CalendarCapabilityError,
CalendarEventRequest,
CalendarExternalProfileRequest,
CalendarInvitationAttendeeRequest,
CalendarInvitationRequest,
)
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 CalendarSchedulingCapabilityTests(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.session = self.Session()
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Scheduling"),
)
self.provider = SqlCalendarSchedulingProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_event_request_validation_is_exposed_as_capability_error(self) -> None:
with self.assertRaises(CalendarCapabilityError):
self.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),
),
)
def test_hold_promotion_and_release_are_idempotent(self) -> None:
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
hold = self.provider.create_event(
self.session,
tenant_id="tenant-1",
user_id="user-1",
request=CalendarEventRequest(
calendar_id=self.calendar.id,
summary="Tentative hold",
status="TENTATIVE",
start_at=start,
end_at=start + timedelta(hours=1),
metadata={"scheduling_request_id": "request-1"},
),
)
promoted = self.provider.promote_event(
self.session,
tenant_id="tenant-1",
user_id="user-1",
event_id=hold.id,
request=CalendarEventRequest(
calendar_id=self.calendar.id,
summary="Confirmed meeting",
status="CONFIRMED",
start_at=start,
end_at=start + timedelta(hours=1),
metadata={"scheduling_request_id": "request-1"},
),
)
released = self.provider.release_event(
self.session,
tenant_id="tenant-1",
user_id="user-1",
event_id=promoted.id,
)
replayed = self.provider.release_event(
self.session,
tenant_id="tenant-1",
user_id="user-1",
event_id=promoted.id,
)
absent = self.provider.release_event(
self.session,
tenant_id="tenant-1",
user_id="user-1",
event_id="missing-event",
)
self.assertEqual(promoted.id, hold.id)
self.assertEqual("CONFIRMED", self.session.get(CalendarEvent, hold.id).status)
self.assertFalse(released.already_released)
self.assertTrue(replayed.already_released)
self.assertEqual("not_found", absent.external_state)
class CalendarExternalProfileCapabilityTests(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.session = self.Session()
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Open-Xchange"),
)
self.provider = SqlCalendarExternalProfileProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_open_xchange_profile_uses_caldav_and_retains_mapping_refs(self) -> None:
configured = self.provider.configure_profile(
self.session,
tenant_id="tenant-1",
user_id=None,
request=CalendarExternalProfileRequest(
profile_kind="open-xchange",
calendar_id=self.calendar.id,
endpoint_url="https://groupware.example.test/caldav/team/",
connector_profile_ref="connector:ox-main",
identity_mapping_ref="idm:ox-main",
resource_calendar_ref="ox-resource:room-42",
),
)
source = self.calendar.metadata_["sync_source_id"]
self.session.refresh(self.calendar)
self.assertEqual(configured.source_id, source)
self.assertEqual("caldav", configured.transport_kind)
self.assertEqual("resource", self.calendar.owner_type)
self.assertEqual("ox-resource:room-42", self.calendar.owner_id)
self.assertEqual("open_xchange", self.calendar.metadata_["integration_profile"])
def test_unsupported_profile_is_rejected(self) -> None:
with self.assertRaisesRegex(CalendarCapabilityError, "Unsupported"):
self.provider.configure_profile(
self.session,
tenant_id="tenant-1",
user_id=None,
request=CalendarExternalProfileRequest(
profile_kind="unknown",
calendar_id=self.calendar.id,
endpoint_url="https://groupware.example.test/caldav/team/",
),
)
class CalendarInvitationCapabilityTests(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.session = self.Session()
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Invitations"),
)
self.provider = SqlCalendarInvitationProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def request(self, *, summary: str = "Planning") -> CalendarInvitationRequest:
return CalendarInvitationRequest(
correlation_id="campaign-1:recipient-1",
source_module="campaigns",
source_resource_type="campaign_recipient",
source_resource_id="recipient-1",
calendar_id=self.calendar.id,
summary=summary,
start_at=datetime(2026, 8, 5, 9, tzinfo=timezone.utc),
end_at=datetime(2026, 8, 5, 10, tzinfo=timezone.utc),
organizer={
"value": "mailto:organizer@example.test",
"params": {"CN": ["Organizer"]},
},
attendees=(
CalendarInvitationAttendeeRequest(
address="ada@example.test",
name="Ada",
),
),
)
def test_upsert_and_response_reconciliation_preserve_correlation(self) -> None:
created = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=self.request(),
)
response = self.provider.record_response(
self.session,
tenant_id="tenant-1",
correlation_id=created.correlation_id,
attendee_address="ADA@example.test",
participation_status="accepted",
evidence={"transport": "ical-reply"},
)
updated = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=self.request(summary="Updated planning"),
)
loaded = self.provider.get_invitation(
self.session,
tenant_id="tenant-1",
correlation_id=created.correlation_id,
)
self.assertEqual(created.event_id, updated.event_id)
self.assertEqual("ACCEPTED", response.attendees[0]["params"]["PARTSTAT"][0])
self.assertEqual("ACCEPTED", updated.attendees[0]["params"]["PARTSTAT"][0])
self.assertEqual(updated, loaded)
self.assertFalse(updated.recurrence_supported)
self.assertTrue(updated.degraded_reasons)
def test_invitation_render_batch_lookup_and_summary(self) -> None:
request = self.request()
payload = self.provider.render_invitation(request)
created = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=request,
)
self.assertIn("METHOD:REQUEST", payload)
self.assertIn(f"UID:{created.uid}", payload)
self.assertEqual(
created,
self.provider.get_invitations(
self.session,
tenant_id="tenant-1",
correlation_ids=(created.correlation_id,),
)[created.correlation_id],
)
summary = self.provider.summarize_invitations(
self.session,
tenant_id="tenant-1",
source_module="campaigns",
source_resource_type="campaign_recipient",
source_resource_id="recipient-1",
)
self.assertEqual(1, summary["invitation_count"])
self.assertEqual(1, summary["pending_response_count"])
calendars = self.provider.list_calendars(
self.session,
tenant_id="tenant-1",
)
self.assertEqual((self.calendar.id,), tuple(item.id for item in calendars))
self.assertTrue(calendars[0].writable)
def test_icalendar_reply_updates_attendee_status(self) -> None:
created = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=self.request(),
)
reply = "\r\n".join(
(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"METHOD:REPLY",
"BEGIN:VEVENT",
f"UID:{created.uid}",
"DTSTART:20260805T090000Z",
"ATTENDEE;CN=Ada;PARTSTAT=TENTATIVE:mailto:ada@example.test",
"END:VEVENT",
"END:VCALENDAR",
"",
)
)
recorded = self.provider.record_icalendar_reply(
self.session,
tenant_id="tenant-1",
icalendar=reply,
evidence={"profile_id": "mail-profile-1", "uid": "42"},
)
sequence_after_first_reply = self.session.get(
CalendarEvent,
created.event_id,
).sequence
replayed = self.provider.record_icalendar_reply(
self.session,
tenant_id="tenant-1",
icalendar=reply,
evidence={"profile_id": "mail-profile-1", "uid": "42"},
)
self.assertEqual(1, len(recorded))
self.assertEqual(1, len(replayed))
self.assertEqual(
"TENTATIVE",
recorded[0].attendees[0]["params"]["PARTSTAT"][0],
)
self.assertEqual(
sequence_after_first_reply,
self.session.get(CalendarEvent, created.event_id).sequence,
)
def test_reply_must_match_an_existing_attendee(self) -> None:
created = self.provider.upsert_invitation(
self.session,
tenant_id="tenant-1",
user_id=None,
request=self.request(),
)
with self.assertRaisesRegex(CalendarCapabilityError, "not an attendee"):
self.provider.record_response(
self.session,
tenant_id="tenant-1",
uid=created.uid,
attendee_address="mallory@example.test",
participation_status="DECLINED",
)
if __name__ == "__main__":
unittest.main()
+597
View File
@@ -0,0 +1,597 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_calendar.backend.db.models import (
CalendarCollection,
CalendarEvent,
CalendarMigrationBatch,
CalendarMigrationResource,
CalendarOutboxOperation,
CalendarSyncCredential,
CalendarSyncSource,
CalendarViewPreference,
)
from govoplan_calendar.backend.dsar_provider import (
CALENDAR_DSAR_CAPABILITY,
CalendarDsarProvider,
)
from govoplan_calendar.backend.manifest import manifest
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
execute_data_subject_erasure,
plan_data_subject_erasure,
search_data_subject_request,
)
class _Registry:
def __init__(
self,
provider: CalendarDsarProvider,
*,
calendar_active: bool = True,
) -> None:
self.provider = provider
self.calendar_active = calendar_active
def capability_names(self):
return (CALENDAR_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "calendar"
def tenant_entitlement_resolver(self):
calendar_active = self.calendar_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("calendar",) if calendar_active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "calendar"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != CALENDAR_DSAR_CAPABILITY:
raise KeyError(name)
class CalendarDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(
bind=self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
DataSubjectRequest.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
CalendarViewPreference.__table__,
CalendarSyncSource.__table__,
CalendarSyncCredential.__table__,
CalendarOutboxOperation.__table__,
CalendarMigrationBatch.__table__,
CalendarMigrationResource.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
account = Account(
id="account-1",
email="subject@example.test",
normalized_email="subject@example.test",
display_name="Subject",
)
other_account = Account(
id="account-2",
email="other@example.test",
normalized_email="other@example.test",
display_name="Other",
)
self.user = User(
id="membership-1",
tenant_id="tenant-1",
account_id=account.id,
email="subject@example.test",
display_name="Subject",
)
other_user = User(
id="membership-2",
tenant_id="tenant-1",
account_id=other_account.id,
email="other@example.test",
display_name="Other",
)
self.collection = CalendarCollection(
id="calendar-subject",
tenant_id="tenant-1",
slug="subject-calendar",
name="Subject calendar",
description="Subject-owned calendar",
owner_type="user",
owner_id=self.user.id,
visibility="private",
created_by_user_id=self.user.id,
metadata_={"secret": "collection-secret-do-not-export"},
)
target_collection = CalendarCollection(
id="calendar-target",
tenant_id="tenant-1",
slug="target-calendar",
name="Target calendar",
owner_type="user",
owner_id=self.user.id,
visibility="private",
created_by_user_id=self.user.id,
)
other_collection = CalendarCollection(
id="calendar-other",
tenant_id="tenant-1",
slug="other-calendar",
name="Other calendar",
owner_type="user",
owner_id=other_user.id,
visibility="private",
created_by_user_id=other_user.id,
)
tenant_two_collection = CalendarCollection(
id="calendar-tenant-2",
tenant_id="tenant-2",
slug="tenant-two",
name="Tenant two secret calendar",
owner_type="tenant",
visibility="tenant",
)
self.event = CalendarEvent(
id="event-subject",
tenant_id="tenant-1",
calendar_id=self.collection.id,
uid="subject-event@example.test",
summary="Subject appointment",
description="Information visible to the subject",
location="Town hall",
start_at=now,
end_at=now,
organizer={
"value": "mailto:organizer@example.test",
"params": {"CN": ["Organizer"]},
},
attendees=[
{
"value": "mailto:Subject@Example.Test",
"params": {"CN": ["Subject"], "PARTSTAT": ["ACCEPTED"]},
},
{
"value": "mailto:other@example.test",
"params": {"CN": ["Unrelated Person"]},
},
],
categories=["citizen-service"],
reminders=[{"minutes": 15, "token": "reminder-secret-do-not-export"}],
attachments=[{"href": "/private/attachment-do-not-export"}],
source_kind="caldav",
source_href="/remote/event-do-not-export.ics",
etag="event-etag-do-not-export",
raw_ics="raw-ics-do-not-export",
icalendar={"private": "icalendar-object-do-not-export"},
metadata_={"secret": "event-secret-do-not-export"},
)
unrelated_event = CalendarEvent(
id="event-other",
tenant_id="tenant-1",
calendar_id=self.collection.id,
uid="unrelated@example.test",
summary="Unrelated event do not export",
description="Unrelated event body do not export",
start_at=now,
end_at=now,
organizer={"value": "mailto:other@example.test"},
attendees=[],
)
tenant_two_event = CalendarEvent(
id="event-tenant-2",
tenant_id="tenant-2",
calendar_id=tenant_two_collection.id,
uid="tenant-two@example.test",
summary="Tenant two event do not export",
start_at=now,
end_at=now,
organizer={"value": "mailto:subject@example.test"},
attendees=[],
)
self.source = CalendarSyncSource(
id="source-subject",
tenant_id="tenant-1",
calendar_id=self.collection.id,
source_kind="caldav",
collection_url="https://private.example.test/calendar-do-not-export",
display_name="Subject CalDAV",
auth_type="basic",
username="connector-user-do-not-export",
credential_ref="credential-ref-do-not-export",
sync_token="sync-token-do-not-export",
ctag="ctag-do-not-export",
last_status="failed",
last_error="provider-error-do-not-export",
metadata_={"secret": "source-secret-do-not-export"},
)
target_source = CalendarSyncSource(
id="source-target",
tenant_id="tenant-1",
calendar_id=target_collection.id,
source_kind="caldav",
collection_url="https://private.example.test/target-do-not-export",
display_name="Target CalDAV",
auth_type="none",
)
credential = CalendarSyncCredential(
id="credential-subject",
tenant_id="tenant-1",
credential_kind="basic",
label="Subject credential",
secret_encrypted="ciphertext-do-not-export",
created_by_user_id=self.user.id,
metadata_={"password": "credential-password-do-not-export"},
)
self.preference = CalendarViewPreference(
id="preference-subject",
tenant_id="tenant-1",
user_id=self.user.id,
dim_weekends=True,
workday_start_hour=8,
workday_end_hour=17,
)
operation = CalendarOutboxOperation(
id="operation-subject",
tenant_id="tenant-1",
source_id=self.source.id,
event_id=self.event.id,
operation_kind="put",
resource_href="/private/outbox-href-do-not-export",
payload_ics="outbox-payload-do-not-export",
payload_fingerprint="a" * 64,
expected_etag="expected-etag-do-not-export",
idempotency_key="idempotency-do-not-export",
status="succeeded",
available_at=now,
lease_token="lease-do-not-export",
remote_etag="remote-etag-do-not-export",
last_error="outbox-error-do-not-export",
metadata_={"secret": "outbox-secret-do-not-export"},
)
migration = CalendarMigrationBatch(
id="migration-subject",
tenant_id="tenant-1",
status="active",
phase="copying_destination",
source_calendar_id=self.collection.id,
target_calendar_id=target_collection.id,
source_sync_source_id=self.source.id,
target_sync_source_id=target_source.id,
total_resources=1,
total_events=1,
created_by_user_id=self.user.id,
authorization_evidence={"secret": "authorization-do-not-export"},
last_error="migration-error-do-not-export",
)
migration_resource = CalendarMigrationResource(
id="migration-resource-subject",
tenant_id="tenant-1",
batch_id=migration.id,
source_href="/source/private-do-not-export",
source_expected_etag="source-etag-do-not-export",
destination_href="/destination/private-do-not-export",
event_ids=[self.event.id],
status="copy_pending",
last_error="resource-error-do-not-export",
)
self.session.add_all(
[
account,
other_account,
self.user,
other_user,
self.collection,
target_collection,
other_collection,
tenant_two_collection,
self.event,
unrelated_event,
tenant_two_event,
self.source,
target_source,
credential,
self.preference,
operation,
migration,
migration_resource,
]
)
self.session.commit()
self.provider = CalendarDsarProvider()
self.subject = DsarSubjectRef(
membership_id=self.user.id,
email="subject@example.test",
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
provided_names = {item.name for item in manifest.provides_interfaces}
self.assertIn(CALENDAR_DSAR_CAPABILITY, provided_names)
provider = manifest.capability_factories[CALENDAR_DSAR_CAPABILITY](None)
self.assertIsInstance(provider, DsarProvider)
self.assertIn(
"calendar.privacy.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
def test_search_is_tenant_scoped_minimized_and_party_specific(self) -> None:
records = self._records()
resource_types = {record.resource_type for record in records}
self.assertTrue(
{
"calendar_collection",
"calendar_event",
"calendar_view_preference",
"calendar_sync_credential",
"calendar_sync_source",
"calendar_outbox_operation",
"calendar_migration_batch",
"calendar_migration_resource",
}.issubset(resource_types)
)
event = next(
record for record in records if record.resource_type == "calendar_event"
)
self.assertEqual(
"subject@example.test",
event.data["matching_attendees"][0]["email"],
)
self.assertEqual([], event.data["organizer"] or [])
serialized = repr([record.to_dict() for record in records])
for hidden in (
"event-other",
"Unrelated event do not export",
"Unrelated event body do not export",
"other@example.test",
"Unrelated Person",
"event-tenant-2",
"Tenant two event do not export",
"raw-ics-do-not-export",
"icalendar-object-do-not-export",
"/remote/event-do-not-export.ics",
"event-etag-do-not-export",
"/private/attachment-do-not-export",
"collection-secret-do-not-export",
"event-secret-do-not-export",
"reminder-secret-do-not-export",
"calendar-do-not-export",
"connector-user-do-not-export",
"credential-ref-do-not-export",
"sync-token-do-not-export",
"ctag-do-not-export",
"provider-error-do-not-export",
"source-secret-do-not-export",
"ciphertext-do-not-export",
"credential-password-do-not-export",
"outbox-href-do-not-export",
"outbox-payload-do-not-export",
"expected-etag-do-not-export",
"idempotency-do-not-export",
"lease-do-not-export",
"remote-etag-do-not-export",
"outbox-error-do-not-export",
"authorization-do-not-export",
"migration-error-do-not-export",
"/source/private-do-not-export",
"source-etag-do-not-export",
"/destination/private-do-not-export",
"resource-error-do-not-export",
):
self.assertNotIn(hidden, serialized)
def test_conflicting_email_references_fail_closed_for_attendee_data(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"calendar.email": "other@example.test"},
),
)
self.assertEqual((), records)
def test_plan_retains_evidence_and_only_executes_preference_deletion(self) -> None:
records = self._records()
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
self.assertTrue({"retain", "manual_review"}.issubset({a.kind for a in actions}))
self.assertTrue(
any(
action.action_id == "calendar:retain:calendar_event:event-subject"
for action in actions
)
)
self.assertEqual(
{"calendar:delete:calendar_view_preference:preference-subject"},
{action.action_id for action in actions if action.executable},
)
def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None:
action = self._preference_delete_action()
wrong_tenant = self.provider.execute_erasure(
self.session,
tenant_id="tenant-2",
subject=self.subject,
actions=(action,),
request_id="dsar-wrong-tenant",
)
self.assertEqual("blocked", wrong_tenant[0].status)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(action,),
request_id="dsar-calendar-1",
)
self.assertEqual("executed", first[0].status)
self.assertIsNone(self.session.get(CalendarViewPreference, self.preference.id))
repeated = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(action,),
request_id="dsar-calendar-1",
)
self.assertEqual("unchanged", repeated[0].status)
def test_execution_blocks_when_preference_owner_changed_after_planning(
self,
) -> None:
action = self._preference_delete_action()
self.preference.user_id = "membership-2"
self.session.flush()
result = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(action,),
request_id="dsar-stale",
)
self.assertEqual("blocked", result[0].status)
self.assertIsNotNone(
self.session.get(CalendarViewPreference, self.preference.id)
)
def test_core_workflow_discovers_active_provider_and_skips_it_when_disabled(
self,
) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CALENDAR-1",
request_kind="access_and_erasure",
subject=self.subject,
purpose="Respond to an authorized privacy request.",
legal_basis="Article 15 and 17 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
registry = _Registry(self.provider)
search_data_subject_request(
self.session,
registry=registry,
row=request,
expected_revision=1,
)
self.assertEqual("searched", request.status)
self.assertEqual(["calendar"], request.coverage["covered_modules"])
plan_data_subject_erasure(
self.session,
registry=registry,
row=request,
expected_revision=2,
)
executable_ids = [
action["action_id"]
for action in request.erasure_plan["actions"]
if action["executable"]
]
execute_data_subject_erasure(
self.session,
registry=registry,
row=request,
expected_revision=3,
action_ids=executable_ids,
)
self.assertEqual("completed", request.status)
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CALENDAR-DISABLED",
request_kind="access",
subject=self.subject,
purpose="Verify disabled-module coverage.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, calendar_active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(0, disabled.search_result["record_count"])
self.assertEqual(
[CALENDAR_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
def _records(self):
return self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
def _preference_delete_action(self):
return next(
action
for action in self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=self._records(),
)
if action.resource_type == "calendar_view_preference" and action.executable
)
if __name__ == "__main__":
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
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
from govoplan_calendar.backend.router import _require_worker_principal
from fastapi import HTTPException
@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_worker_routes_reject_interactive_principals(self) -> None:
class Principal:
auth_method = "session"
with self.assertRaises(HTTPException) as raised:
_require_worker_principal(Principal()) # type: ignore[arg-type]
self.assertEqual(403, raised.exception.status_code)
Principal.auth_method = "service_account"
_require_worker_principal(Principal()) # type: ignore[arg-type]
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()
+63 -1
View File
@@ -3,11 +3,40 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent from govoplan_calendar.backend.ical import ICalendarError, _expand_event_occurrences, expand_event_occurrences, event_to_ics, parse_vevent
class ICalendarParsingTests(unittest.TestCase): class ICalendarParsingTests(unittest.TestCase):
def test_recurrence_limit_rejects_without_materializing_between(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
event = SimpleNamespace(
uid="bounded@example.test", start_at=start, end_at=None, duration_seconds=None, all_day=False,
rrule={"FREQ": "SECONDLY", "COUNT": "121"}, rdate=[], exdate=[],
)
with patch("dateutil.rrule.rruleset.between", side_effect=AssertionError("Unbounded allocation")):
with self.assertRaisesRegex(ICalendarError, "result limit"):
_expand_event_occurrences(
event, start, datetime(2026, 7, 1, 0, 2, tzinfo=timezone.utc), limit=1,
)
def test_invalid_worker_ack_is_rejected(self) -> None:
from govoplan_core.security.worker_payload import encode_worker_payload
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
event = SimpleNamespace(
uid="expected@example.test", start_at=start, end_at=None, all_day=False,
rrule={"FREQ": "DAILY", "COUNT": "1"}, rdate=[], exdate=[],
)
wrong = encode_worker_payload({"occurrences": [[{
"uid": "different@example.test", "recurrence_id": "20260701T000000Z",
"start_at": start, "end_at": None, "all_day": False,
}]]})
with patch("govoplan_core.security.bounded_process.run_bounded_operation", return_value=wrong):
with self.assertRaisesRegex(ICalendarError, "invalid result"):
expand_event_occurrences(event, start, start)
def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None: def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None:
payload = """BEGIN:VCALENDAR payload = """BEGIN:VCALENDAR
VERSION:2.0 VERSION:2.0
@@ -92,6 +121,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 +194,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
@@ -0,0 +1,125 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_calendar.backend.manifest import get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class CalendarInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in get_manifest().documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
expected = {
"calendar.navigation",
"calendar.page",
"calendar.page.sidebar",
"calendar.page.agenda",
"calendar.page.workspace",
"calendar.event-editor",
"calendar.collection-editor",
"calendar.sync-status",
"calendar.outbox",
"calendar.migration",
"calendar.settings.preferences",
"calendar.quick_access.agenda",
"calendar.widget.upcoming",
}
self.assertEqual(expected, set(surfaces))
self.assertEqual("calendar.page", surfaces["calendar.page.sidebar"].parent_id)
self.assertEqual(
"calendar.page.sidebar", surfaces["calendar.page.agenda"].parent_id
)
self.assertEqual("calendar.page", surfaces["calendar.page.workspace"].parent_id)
self.assertEqual("calendar.page", surfaces["calendar.event-editor"].parent_id)
self.assertEqual(
"calendar.page.sidebar", surfaces["calendar.collection-editor"].parent_id
)
self.assertEqual(
"calendar.collection-editor", surfaces["calendar.sync-status"].parent_id
)
self.assertEqual("calendar.sync-status", surfaces["calendar.outbox"].parent_id)
self.assertEqual(
"calendar.sync-status", surfaces["calendar.migration"].parent_id
)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
events = topics["calendar.manage-calendars-and-events"]
sources = topics["calendar.external-sources-and-sync"]
recovery = topics["calendar.outbound-change-recovery"]
self.assertIn("calendar.event-editor", events.metadata["help_contexts"])
self.assertIn("save_event", events.metadata["consequence_classes"])
self.assertIn("delete_event", events.metadata["consequence_classes"])
self.assertIn("calendar.collection-editor", sources.metadata["help_contexts"])
self.assertIn("force_full_sync", sources.metadata["consequence_classes"])
self.assertIn("execute_remote_move", sources.metadata["consequence_classes"])
self.assertIn("calendar.outbox", recovery.metadata["help_contexts"])
self.assertIn("reconcile_outbox", recovery.metadata["consequence_classes"])
quick_tool = get_manifest().frontend.quick_access_tools[0]
self.assertEqual(("calendar.event",), quick_tool.returned_reference_kinds)
self.assertEqual("calendar.quick_access.agenda", quick_tool.help_context_id)
self.assertEqual("/calendar", quick_tool.full_page_path)
def test_quick_access_is_bounded_temporal_and_owner_launched(self) -> None:
quick_access = (
REPO_ROOT / "webui/src/features/calendar/CalendarQuickAccess.tsx"
).read_text(encoding="utf-8")
page = (REPO_ROOT / "webui/src/features/calendar/CalendarPage.tsx").read_text(
encoding="utf-8"
)
self.assertIn("const AGENDA_LIMIT = 7", quick_access)
self.assertIn("launchContext.temporalContext", quick_access)
self.assertIn("limit: AGENDA_LIMIT", quick_access)
self.assertIn('kind: "event"', quick_access)
self.assertIn("quickAccessLaunchState(launchContext)", quick_access)
self.assertIn('parameters.get("quickAction") !== "create-event"', page)
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
event_dialog = (
REPO_ROOT / "webui/src/features/calendar/CalendarEventDialog.tsx"
).read_text(encoding="utf-8")
collection_dialog = (
REPO_ROOT / "webui/src/features/calendar/CalendarCollectionDialogs.tsx"
).read_text(encoding="utf-8")
settings_panel = (
REPO_ROOT / "webui/src/features/calendar/CalendarSettingsPanel.tsx"
).read_text(encoding="utf-8")
for component in (
"ActionBlockerHint",
"ConfirmDialog",
"titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}",
"useUnsavedDraftGuard",
):
self.assertIn(component, event_dialog)
for component in (
"ActionBlockerHint",
"titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}",
"useUnsavedDraftGuard",
):
self.assertIn(component, collection_dialog)
for component in (
"titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}",
"useUnsavedDraftGuard",
):
self.assertIn(component, settings_panel)
if __name__ == "__main__":
unittest.main()
+1880
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from govoplan_calendar.backend.ews import (
EwsAdapterError,
ews_find_item_body,
parse_ews_calendar_items,
)
from govoplan_calendar.backend.graph import graph_event_payload
class ProviderAdapterTests(unittest.TestCase):
def test_graph_event_fixture_maps_provider_fields(self) -> None:
payload = graph_event_payload(
{
"id": "graph-event-1",
"iCalUId": "uid-1@example.test",
"@odata.etag": 'W/"etag-1"',
"type": "occurrence",
"subject": "Planning",
"body": {"content": "Agenda"},
"start": {
"dateTime": "2026-07-08T09:00:00",
"timeZone": "Europe/Berlin",
},
"end": {
"dateTime": "2026-07-08T10:30:00",
"timeZone": "Europe/Berlin",
},
"organizer": {
"emailAddress": {
"name": "Ada",
"address": "ada@example.test",
}
},
"attendees": [
{
"type": "required",
"emailAddress": {
"name": "Lin",
"address": "lin@example.test",
},
}
],
"isReminderOn": True,
"reminderMinutesBeforeStart": 15,
}
)
self.assertEqual(payload["uid"], "uid-1@example.test")
self.assertEqual(payload["recurrence_id"], "graph-event-1")
self.assertEqual(
payload["start_at"],
datetime(2026, 7, 8, 7, 0, tzinfo=timezone.utc),
)
self.assertEqual(payload["duration_seconds"], 5_400)
self.assertEqual(
payload["attendees"][0]["email"],
"lin@example.test",
)
self.assertEqual(
payload["reminders"][0]["trigger_minutes_before"],
15,
)
def test_ews_calendar_fixture_maps_item_and_escapes_mailbox(self) -> None:
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:RequiredAttendees><t:Attendee><t:Mailbox>
<t:Name>Ada</t:Name>
<t:EmailAddress>ada@example.test</t:EmailAddress>
</t:Mailbox></t:Attendee></t:RequiredAttendees>
</t:CalendarItem></t:Items>
</m:RootFolder></m:FindItemResponseMessage>
</m:ResponseMessages></m:FindItemResponse></s:Body>
</s:Envelope>"""
items = parse_ews_calendar_items(response_xml)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["href"], "ews-event-1")
self.assertEqual(items[0]["etag"], "ews-etag-1")
self.assertEqual(
items[0]["attendees"][0]["email"],
"ada@example.test",
)
request_body = ews_find_item_body(
start=datetime(2026, 7, 1, tzinfo=timezone.utc),
end=datetime(2026, 8, 1, tzinfo=timezone.utc),
mailbox='ops&"<@example.test',
)
self.assertIn("ops&amp;&quot;&lt;@example.test", request_body)
def test_ews_parser_rejects_invalid_xml(self) -> None:
with self.assertRaisesRegex(EwsAdapterError, "Invalid EWS"):
parse_ews_calendar_items("<not-closed>")
if __name__ == "__main__":
unittest.main()
+200
View File
@@ -0,0 +1,200 @@
from __future__ import annotations
from datetime import UTC, datetime
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.db.models import CalendarOutboxOperation, CalendarSyncSource
from govoplan_calendar.backend.manifest import (
CALDAV_PROVIDER_ID,
EWS_PROVIDER_ID,
GRAPH_PROVIDER_ID,
ICS_PROVIDER_ID,
OPEN_XCHANGE_PROVIDER_ID,
manifest,
)
from govoplan_calendar.backend.provider_state import (
caldav_provider_states,
ews_provider_states,
graph_provider_states,
ics_provider_states,
open_xchange_provider_states,
)
from govoplan_calendar.backend.schemas import (
CalendarCalDavSourceCreateRequest,
CalendarCollectionCreateRequest,
)
from govoplan_calendar.backend.service import create_caldav_source, create_calendar
from govoplan_core.core.provider_governance import ExternalProviderStateContext
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 CalendarProviderStateTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
create_scope_tables(self.engine)
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
self.session = self.Session()
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
self.source = create_caldav_source(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=self.calendar.id,
collection_url="https://dav.example.test/cal",
),
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_state_projects_health_freshness_and_unresolved_outcomes(self) -> None:
self.source.last_status = "ok"
self.source.last_synced_at = datetime.now(UTC)
self.session.flush()
healthy = caldav_provider_states(
ExternalProviderStateContext(
session=self.session,
tenant_id="tenant-1",
)
)[0]
self.assertEqual("healthy", healthy.health)
self.assertEqual("current", healthy.freshness)
self.assertEqual("clear", healthy.conflict)
self.assertEqual("ready", healthy.recovery)
self.assertEqual(
f"calendar:sync-source:{self.source.id}",
healthy.binding_ref,
)
self.assertNotIn("dav.example.test", str(healthy.to_dict()))
self.session.add(
CalendarOutboxOperation(
tenant_id="tenant-1",
source_id=self.source.id,
operation_kind="put",
resource_href="event.ics",
idempotency_key="a" * 64,
status="conflict",
)
)
self.session.flush()
conflicted = caldav_provider_states(
ExternalProviderStateContext(
session=self.session,
tenant_id="tenant-1",
)
)[0]
self.assertEqual("warning", conflicted.health)
self.assertEqual("pending", conflicted.conflict)
self.assertEqual("attention", conflicted.recovery)
def test_state_is_tenant_bounded_and_manifest_registered(self) -> None:
self.assertEqual(
(),
caldav_provider_states(
ExternalProviderStateContext(
session=self.session,
tenant_id="tenant-2",
)
),
)
self.assertEqual(
{CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID, OPEN_XCHANGE_PROVIDER_ID},
{item.id for item in manifest.external_providers},
)
self.assertEqual(
{CALDAV_PROVIDER_ID, ICS_PROVIDER_ID, GRAPH_PROVIDER_ID, EWS_PROVIDER_ID, OPEN_XCHANGE_PROVIDER_ID},
{
item.provider_id
for item in manifest.external_provider_state_providers
},
)
def test_read_only_sources_have_distinct_secret_free_provider_state(self) -> None:
now = datetime.now(UTC)
sources = (
("ics", "https://feeds.example.test/team.ics", "none"),
("graph", "https://graph.microsoft.com/v1.0/me/calendar/events", "bearer"),
("ews", "https://exchange.example.test/EWS/Exchange.asmx", "basic"),
)
for source_kind, collection_url, auth_type in sources:
self.session.add(
CalendarSyncSource(
tenant_id="tenant-1",
calendar_id=self.calendar.id,
source_kind=source_kind,
collection_url=collection_url,
auth_type=auth_type,
sync_enabled=True,
sync_direction="read_only",
last_status="ok",
last_synced_at=now,
)
)
self.session.flush()
states = (
ics_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0],
graph_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0],
ews_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0],
)
self.assertTrue(all(item.authority_mode == "external_mirror" for item in states))
self.assertTrue(all(item.health == "healthy" for item in states))
rendered = str([item.to_dict() for item in states])
self.assertNotIn("feeds.example.test", rendered)
self.assertNotIn("exchange.example.test", rendered)
def test_open_xchange_profile_has_distinct_provider_state(self) -> None:
self.source.metadata_ = {
"integration_profile": "open_xchange",
"connector_profile_ref": "connector:secret-detail",
"identity_mapping_ref": "idm:secret-detail",
}
self.source.last_status = "ok"
self.source.last_synced_at = datetime.now(UTC)
self.session.flush()
self.assertEqual(
(),
caldav_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
),
)
state = open_xchange_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual(OPEN_XCHANGE_PROVIDER_ID, state.provider_id)
self.assertEqual("healthy", state.health)
self.assertNotIn("secret-detail", str(state.to_dict()))
if __name__ == "__main__":
unittest.main()
+413
View File
@@ -0,0 +1,413 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from sqlalchemy import create_engine, create_mock_engine, event as sqlalchemy_event
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Query, Session, sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import (
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventOccurrenceUpdateRequest,
CalendarViewPreferencesUpdateRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
_bounded_occurrence_candidates,
OCCURRENCE_DETAIL_FIELDS,
create_calendar,
create_event,
delete_event,
delete_event_occurrence,
get_calendar_view_preferences,
list_event_occurrences,
list_freebusy,
update_calendar_view_preferences,
update_event_occurrence,
)
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 CalendarRecurrenceAndPreferenceTests(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.session = self.Session()
self.session.add(
Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
)
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Calendar"),
)
self.session.flush()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def recurring_master(self) -> CalendarEvent:
return create_event(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id,
uid="series@example.test",
summary="Planning",
start_at=datetime(
2026, 7, 8, 9, 0, tzinfo=timezone.utc
),
end_at=datetime(
2026, 7, 8, 10, 0, tzinfo=timezone.utc
),
rrule={"FREQ": "WEEKLY", "COUNT": "3"},
),
)
def test_freebusy_returns_all_1001_hourly_occurrences(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="dense@example.test", summary="Dense series",
start_at=start, end_at=start + timedelta(minutes=1),
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
),
)
self.session.commit()
busy = list_freebusy(
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
start_at=start, end_at=start + timedelta(days=43),
)
self.assertEqual(1001, len(busy))
self.assertEqual(start + timedelta(hours=1000), busy[-1]["start_at"])
def test_freebusy_over_budget_fails_without_partial_busy_slots(self) -> None:
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="excessive@example.test", summary="Excessive series",
start_at=start, end_at=start + timedelta(seconds=1),
rrule={"FREQ": "MINUTELY", "COUNT": "10001"},
),
)
self.session.commit()
with self.assertRaisesRegex(CalendarError, "could not complete"):
list_freebusy(
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
start_at=start, end_at=start + timedelta(days=8),
)
self.assertEqual(1, self.session.query(CalendarEvent).count())
def test_full_expansion_byte_cap_and_lightweight_complete_freebusy(self) -> None:
from govoplan_calendar.backend import service
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="heavy@example.test", summary="Heavy series",
description="x" * 65536, start_at=start, end_at=start + timedelta(minutes=1),
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
),
)
self.session.commit()
calendar_id = self.calendar.id
self.session.expunge_all()
with patch.object(service, "expanded_event_response", wraps=service.expanded_event_response) as expand:
with self.assertRaisesRegex(CalendarError, "response exceeds its byte limit"):
list_event_occurrences(self.session, tenant_id="tenant-1", start_at=start, end_at=start + timedelta(days=43), limit=1)
self.assertLess(expand.call_count, 70)
statements = []
loaded = []
def capture(conn, cursor, statement, parameters, context, executemany):
statements.append(statement)
def capture_loaded(session, instance):
if isinstance(instance, CalendarEvent):
loaded.append(instance)
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
sqlalchemy_event.listen(self.session, "loaded_as_persistent", capture_loaded)
try:
with patch.object(service, "event_response", side_effect=AssertionError("Full event data is unnecessary for availability")):
busy = list_freebusy(self.session, tenant_id="tenant-1", calendar_ids=[calendar_id], start_at=start, end_at=start + timedelta(days=43))
self.assertEqual(1001, len(busy))
self.assertEqual([], loaded)
for field in ("description", "raw_ics", "icalendar", "metadata", "attendees", "attachments"):
self.assertNotIn(f"calendar_events.{field}", "\n".join(statements))
finally:
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
sqlalchemy_event.remove(self.session, "loaded_as_persistent", capture_loaded)
def test_aggregate_sql_projection_hides_over_budget_values_before_driver_decoding(self) -> None:
from govoplan_calendar.backend import service
first = self.recurring_master()
second = create_event(
self.session, tenant_id="tenant-1", user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id, uid="second@example.test", summary="Second",
start_at=first.start_at, end_at=first.end_at, rrule={"FREQ": "DAILY", "COUNT": "2"},
),
)
first.description = second.description = "x" * 1500
self.session.commit()
self.session.expunge_all()
captured = []
def capture(conn, cursor, statement, parameters, context, executemany):
captured.append((statement, parameters))
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
try:
with patch.object(service, "MAX_OCCURRENCE_PROJECTION_BYTES", 3000), patch.object(service, "expand_events_occurrences") as worker:
with self.assertRaisesRegex(CalendarError, "candidate projection exceeds its byte limit"):
list_event_occurrences(
self.session, tenant_id="tenant-1", start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
worker.assert_not_called()
finally:
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
self.assertEqual(1, len(captured))
statement, parameters = captured[0]
self.assertIn("sum(", statement)
self.assertIn("OVER (ORDER BY", statement)
with self.engine.connect() as connection:
raw = list(connection.exec_driver_sql(statement, parameters))
self.assertEqual(2, len(raw))
self.assertIsNotNone(raw[0][1])
self.assertTrue(all(value is None for value in raw[1][1:]))
def test_sql_projection_compiles_postgresql_without_binary_json_casts(self) -> None:
statements = []
session = Session(bind=create_mock_engine("postgresql://", lambda *args, **kwargs: None))
def inspect_query(query):
statements.append(str(query.statement.compile(dialect=postgresql.dialect())))
return iter(())
with patch.object(Query, "__iter__", inspect_query):
self.assertEqual([], _bounded_occurrence_candidates(
session.query(CalendarEvent).filter(CalendarEvent.tenant_id == "tenant-1").limit(2001),
OCCURRENCE_DETAIL_FIELDS, [4096],
))
self.assertIn("octet_length(CAST(calendar_events.icalendar AS TEXT))", statements[0])
self.assertIn("CASE WHEN", statements[0])
self.assertIn("OVER (ORDER BY", statements[0])
self.assertNotIn("BYTEA", statements[0])
def test_candidate_budget_is_not_bypassed_by_response_limit(self) -> None:
self.recurring_master()
self.session.commit()
with patch("govoplan_calendar.backend.service.MAX_OCCURRENCE_CANDIDATES", 0):
with self.assertRaisesRegex(CalendarError, "Too many recurring series"):
list_event_occurrences(
self.session, tenant_id="tenant-1", limit=1,
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
def test_admission_rejection_precedes_database_projection(self) -> None:
from govoplan_core.security.bounded_process import ProcessBudgetError
with patch("govoplan_core.security.bounded_process.bounded_operation_admission", side_effect=ProcessBudgetError("busy")):
with patch.object(self.session, "query") as query:
with self.assertRaisesRegex(CalendarError, "could not complete"):
list_event_occurrences(
self.session, tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
query.assert_not_called()
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
self,
) -> None:
master = self.recurring_master()
override = update_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
payload=CalendarEventOccurrenceUpdateRequest(
recurrence_id="20260715T090000Z",
summary="Moved planning",
start_at=datetime(
2026, 7, 15, 13, 0, tzinfo=timezone.utc
),
end_at=datetime(
2026, 7, 15, 14, 30, tzinfo=timezone.utc
),
),
)
cancelled = delete_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
recurrence_id="20260722T090000Z",
)
self.session.commit()
events = list_event_occurrences(
self.session,
tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
self.assertEqual(
[event["summary"] for event in events],
["Planning", "Moved planning"],
)
self.assertEqual(events[1]["id"], override.id)
self.assertEqual(events[1]["series_event_id"], master.id)
self.assertTrue(events[1]["is_override"])
self.assertEqual(
events[1]["start_at"],
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
)
self.assertEqual(cancelled.status, "CANCELLED")
busy = list_freebusy(
self.session,
tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
self.assertEqual(len(busy), 2)
self.assertEqual(
busy[1]["start_at"],
datetime(2026, 7, 15, 13, 0, tzinfo=timezone.utc),
)
def test_deleting_series_removes_its_overrides(self) -> None:
master = self.recurring_master()
update_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
payload=CalendarEventOccurrenceUpdateRequest(
recurrence_id="20260715T090000Z",
summary="Override",
),
)
delete_event(
self.session,
tenant_id="tenant-1",
event_id=master.id,
)
active = (
self.session.query(CalendarEvent)
.filter(CalendarEvent.deleted_at.is_(None))
.count()
)
self.assertEqual(active, 0)
def test_all_day_occurrence_retains_date_recurrence_id(self) -> None:
master = create_event(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id,
uid="all-day-series@example.test",
summary="All-day planning",
start_at=datetime(
2026, 7, 8, tzinfo=timezone.utc
),
end_at=datetime(
2026, 7, 9, tzinfo=timezone.utc
),
all_day=True,
rrule={"FREQ": "WEEKLY", "COUNT": "2"},
),
)
events = list_event_occurrences(
self.session,
tenant_id="tenant-1",
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
)
self.assertEqual(
[event["recurrence_id"] for event in events],
["20260708", "20260715"],
)
override = update_event_occurrence(
self.session,
tenant_id="tenant-1",
user_id=None,
series_event_id=master.id,
payload=CalendarEventOccurrenceUpdateRequest(
recurrence_id=events[1]["recurrence_id"],
summary="Moved all-day planning",
),
)
self.assertEqual(override.recurrence_id, "20260715")
def test_calendar_preferences_have_defaults_and_durable_user_overrides(
self,
) -> None:
defaults = get_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
)
self.assertTrue(defaults["dim_weekends"])
self.assertEqual(defaults["overridden_fields"], [])
updated = update_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=CalendarViewPreferencesUpdateRequest(
dim_weekends=False,
workday_start_hour=8,
workday_end_hour=18,
),
)
self.session.commit()
reloaded = get_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
)
self.assertEqual(updated, reloaded)
self.assertFalse(reloaded["dim_weekends"])
self.assertEqual(reloaded["workday_start_hour"], 8)
self.assertEqual(reloaded["workday_end_hour"], 18)
self.assertEqual(
set(reloaded["overridden_fields"]),
{"dim_weekends", "workday_start_hour", "workday_end_hour"},
)
def test_calendar_preferences_reject_inverted_workday(self) -> None:
with self.assertRaisesRegex(CalendarError, "end hour"):
update_calendar_view_preferences(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=CalendarViewPreferencesUpdateRequest(
workday_start_hour=18,
workday_end_hour=8,
),
)
if __name__ == "__main__":
unittest.main()
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.db.models import CalendarMigrationBatch
from govoplan_calendar.backend.migrations_saga import (
cancel_calendar_migration_batch,
get_calendar_migration_batch,
)
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import (
CalendarCalDavSourceCreateRequest,
CalendarCollectionCreateRequest,
CalendarCollectionDeleteRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import (
CalendarError,
create_caldav_source,
create_calendar,
create_event,
delete_calendar,
update_event,
)
from govoplan_core.db.base import Base, utcnow
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
from tests.test_outbox import StatefulCalDAVClient
class CalendarRemoteMoveTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
create_scope_tables(self.engine)
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
self.session = self.Session()
self.session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
self.calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote source"),
)
self.source = create_caldav_source(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=self.calendar.id,
collection_url="https://dav.example.test/source",
),
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def create_event(self, uid: str):
return create_event(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=self.calendar.id,
uid=uid,
summary="Planning",
start_at=datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 8, 10, 0, tzinfo=timezone.utc),
),
)
def create_target(self):
calendar = create_calendar(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote target"),
)
source = create_caldav_source(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/target",
),
)
self.session.commit()
return calendar, source
def deliver_source_events(self, *events):
self.session.commit()
client = StatefulCalDAVClient()
result = dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
client_factory=lambda _session, _source: client,
)
self.assertEqual(result["succeeded"], len(events))
for event in events:
self.assertEqual(event.source_kind, "caldav")
self.assertIsNotNone(event.source_href)
self.assertIsNotNone(event.etag)
return client
def start_move(
self,
target_calendar,
*,
make_target_default: bool = False,
) -> CalendarMigrationBatch:
batch = delete_calendar(
self.session,
tenant_id="tenant-1",
calendar_id=self.calendar.id,
payload=CalendarCollectionDeleteRequest(
event_action="move",
target_calendar_id=target_calendar.id,
external_action="remote_move",
destructive_confirmation="MOVE REMOTE EVENTS",
evidence_note="Approved migration window 42",
make_target_default=make_target_default,
),
)
self.assertIsInstance(batch, CalendarMigrationBatch)
self.session.commit()
return batch
def clients(self, source_client, target_client):
return lambda _session, source: (
source_client if source.id == self.source.id else target_client
)
def test_all_resources_are_copied_before_conditional_source_delete(self) -> None:
first = self.create_event("move-first@example.test")
second = self.create_event("move-second@example.test")
source_client = self.deliver_source_events(first, second)
target_calendar, target_source = self.create_target()
target_client = StatefulCalDAVClient()
self.source.conflict_policy = "overwrite"
target_source.conflict_policy = "overwrite"
self.calendar.is_default = True
self.session.commit()
batch = self.start_move(target_calendar, make_target_default=True)
result = dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
client_factory=self.clients(source_client, target_client),
)
self.assertEqual(result["failed"], 0)
batch = get_calendar_migration_batch(
self.session, tenant_id="tenant-1", batch_id=batch.id
)
self.assertEqual(batch.status, "completed")
self.assertEqual(len(target_client.resources), 2)
self.assertEqual(source_client.resources, {})
self.assertEqual(len(source_client.deletes), 2)
self.assertTrue(all(item["etag"] for item in source_client.deletes))
self.assertTrue(all(item["overwrite"] is False for item in source_client.deletes))
self.assertTrue(all(item["overwrite"] is False for item in target_client.puts))
self.assertIsNotNone(self.calendar.deleted_at)
self.assertIsNotNone(self.source.deleted_at)
self.assertEqual(first.calendar_id, target_calendar.id)
self.assertNotIn("calendar_migration", first.metadata_ or {})
self.assertIsNone(target_source.deleted_at)
self.assertTrue(target_calendar.is_default)
def test_partial_copy_failure_never_deletes_source(self) -> None:
first = self.create_event("partial-first@example.test")
second = self.create_event("partial-second@example.test")
source_client = self.deliver_source_events(first, second)
target_calendar, _target_source = self.create_target()
target_client = StatefulCalDAVClient()
target_client.fail_before_write = True
batch = self.start_move(target_calendar)
dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
client_factory=self.clients(source_client, target_client),
)
batch = get_calendar_migration_batch(
self.session, tenant_id="tenant-1", batch_id=batch.id
)
self.assertEqual(batch.phase, "copying_destination")
self.assertEqual(source_client.deletes, [])
self.assertEqual(len(source_client.resources), 2)
self.assertIsNone(self.calendar.deleted_at)
def test_uid_collision_is_rejected_before_mutation(self) -> None:
source_event = self.create_event("collision@example.test")
self.deliver_source_events(source_event)
target_calendar, target_source = self.create_target()
target_event = create_event(
self.session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=target_calendar.id,
uid=source_event.uid,
summary="Existing target",
start_at=datetime(2026, 7, 9, 9, 0, tzinfo=timezone.utc),
),
)
self.session.commit()
target_client = StatefulCalDAVClient()
dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
client_factory=lambda _session, _source: target_client,
)
with self.assertRaisesRegex(CalendarError, "already contains UID"):
self.start_move(target_calendar)
self.session.rollback()
self.assertEqual(source_event.calendar_id, self.calendar.id)
self.assertEqual(target_event.calendar_id, target_calendar.id)
self.assertIsNone(self.calendar.deleted_at)
self.assertIsNotNone(target_source.id)
def test_cancel_before_source_delete_retains_both_remote_copies(self) -> None:
event = self.create_event("cancel@example.test")
source_client = self.deliver_source_events(event)
target_calendar, _target_source = self.create_target()
target_client = StatefulCalDAVClient()
self.calendar.is_default = True
self.session.commit()
batch = self.start_move(target_calendar, make_target_default=True)
batch = cancel_calendar_migration_batch(
self.session,
tenant_id="tenant-1",
batch_id=batch.id,
evidence_note="Operator cancelled approved migration",
user_id=None,
api_key_id=None,
)
self.session.commit()
self.assertEqual(batch.status, "cancel_requested")
dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
client_factory=self.clients(source_client, target_client),
)
batch = get_calendar_migration_batch(
self.session, tenant_id="tenant-1", batch_id=batch.id
)
self.assertEqual(batch.status, "cancelled")
self.assertEqual(source_client.deletes, [])
self.assertIsNone(self.calendar.deleted_at)
self.assertTrue(self.source.sync_enabled)
self.assertEqual(len(target_client.resources), 1)
self.assertTrue(self.calendar.is_default)
self.assertFalse(target_calendar.is_default)
def test_concurrent_event_edit_is_blocked(self) -> None:
event = self.create_event("locked@example.test")
self.deliver_source_events(event)
target_calendar, _target_source = self.create_target()
self.start_move(target_calendar)
with self.assertRaisesRegex(CalendarError, "blocked while"):
update_event(
self.session,
tenant_id="tenant-1",
user_id=None,
event_id=event.id,
payload=CalendarEventUpdateRequest(summary="Unsafe edit"),
)
self.session.rollback()
def test_source_etag_change_becomes_reconciliation_conflict(self) -> None:
event = self.create_event("etag-conflict@example.test")
source_client = self.deliver_source_events(event)
target_calendar, _target_source = self.create_target()
target_client = StatefulCalDAVClient()
batch = self.start_move(target_calendar)
first = dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
limit=1,
client_factory=self.clients(source_client, target_client),
)
self.assertEqual(first["succeeded"], 1)
href = next(iter(source_client.resources))
ics, _etag = source_client.resources[href]
source_client.resources[href] = (ics, '"concurrent-edit"')
dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
client_factory=self.clients(source_client, target_client),
)
batch = get_calendar_migration_batch(
self.session, tenant_id="tenant-1", batch_id=batch.id
)
self.assertEqual(batch.status, "blocked")
self.assertEqual(batch.phase, "source_delete_conflict")
self.assertIn(href, source_client.resources)
self.assertIsNone(self.calendar.deleted_at)
def test_crash_after_destination_write_is_reconciled_without_duplicate_put(self) -> None:
event = self.create_event("crash-recovery@example.test")
source_client = self.deliver_source_events(event)
target_calendar, _target_source = self.create_target()
target_client = StatefulCalDAVClient()
target_client.fail_after_write = True
batch = self.start_move(target_calendar)
first = dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
limit=1,
client_factory=self.clients(source_client, target_client),
)
self.assertEqual(first["succeeded"], 1)
target_client.fail_after_write = False
dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
now=utcnow() + timedelta(seconds=10),
client_factory=self.clients(source_client, target_client),
)
batch = get_calendar_migration_batch(
self.session, tenant_id="tenant-1", batch_id=batch.id
)
self.assertEqual(batch.status, "completed")
self.assertEqual(len(target_client.puts), 1)
def test_crash_after_source_delete_is_reconciled_without_duplicate_delete(self) -> None:
event = self.create_event("delete-crash@example.test")
source_client = self.deliver_source_events(event)
target_calendar, _target_source = self.create_target()
target_client = StatefulCalDAVClient()
batch = self.start_move(target_calendar)
copied = dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
limit=1,
client_factory=self.clients(source_client, target_client),
)
self.assertEqual(copied["succeeded"], 1)
source_client.fail_after_write = True
deleted = dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
limit=1,
client_factory=self.clients(source_client, target_client),
)
self.assertEqual(deleted["succeeded"], 1)
source_client.fail_after_write = False
dispatch_calendar_outbox(
self.session,
tenant_id="tenant-1",
client_factory=self.clients(source_client, target_client),
)
batch = get_calendar_migration_batch(
self.session, tenant_id="tenant-1", batch_id=batch.id
)
self.assertEqual(batch.status, "completed")
self.assertEqual(len(source_client.deletes), 1)
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, User
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
from govoplan_calendar.backend.search_source import (
CalendarSearchSource,
PROVIDER_ID,
RESOURCE_TYPE,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_core.db.base import Base
class CalendarSearchSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite://")
Base.metadata.create_all(
self.engine,
tables=(
Account.__table__,
User.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
),
)
self.session = Session(self.engine)
start = datetime(2026, 8, 5, 9, tzinfo=timezone.utc)
self.session.add_all(
(
Account(
id="account-1",
email="one@example.test",
normalized_email="one@example.test",
),
User(
id="user-1",
tenant_id="tenant-1",
account_id="account-1",
email="one@example.test",
),
CalendarCollection(
id="calendar-1",
tenant_id="tenant-1",
slug="private",
name="Private calendar",
owner_type="user",
owner_id="user-1",
visibility="private",
created_by_user_id="user-1",
),
CalendarEvent(
id="event-1",
tenant_id="tenant-1",
calendar_id="calendar-1",
uid="event-1@example.test",
summary="Permit review",
description="Review monthly permits",
start_at=start,
end_at=start + timedelta(hours=1),
),
)
)
self.session.commit()
self.source = CalendarSearchSource()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_private_calendar_is_visible_only_to_current_owner(self) -> None:
page = self.source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="rebuild-1",
),
)
self.assertEqual(("event-1",), tuple(doc.resource_id for doc in page.documents))
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="calendar",
resource_type=RESOURCE_TYPE,
resource_id="event-1",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
self.assertTrue(
self.source.authorize(
self.session,
_principal("user-1"),
requests=(request,),
)[reference.key]
)
self.assertFalse(
self.source.authorize(
self.session,
_principal("user-2"),
requests=(request,),
)[reference.key]
)
def _principal(user_id: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=f"account-{user_id}",
membership_id=user_id,
tenant_id="tenant-1",
scopes=frozenset({"calendar:event:read"}),
),
account=SimpleNamespace(id=f"account-{user_id}"),
user=SimpleNamespace(id=user_id),
)
if __name__ == "__main__":
unittest.main()
+149 -1
View File
@@ -5,8 +5,20 @@ from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, User
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent
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,
list_event_occurrences,
list_events,
)
from govoplan_core.db.base import Base
class FakeSession: class FakeSession:
@@ -65,6 +77,142 @@ 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_event_queries_apply_visible_calendar_fence_and_limit(self) -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(
engine,
tables=(
Account.__table__,
User.__table__,
CalendarCollection.__table__,
CalendarEvent.__table__,
),
)
session = Session(engine)
start = datetime(2026, 8, 19, 9, tzinfo=timezone.utc)
try:
session.add_all(
(
CalendarCollection(
id="visible-calendar",
tenant_id="tenant-1",
slug="visible",
name="Visible",
visibility="tenant",
),
CalendarCollection(
id="private-calendar",
tenant_id="tenant-1",
slug="private",
name="Private",
visibility="private",
owner_type="user",
owner_id="other-user",
),
CalendarEvent(
id="visible-event",
tenant_id="tenant-1",
calendar_id="visible-calendar",
uid="visible@example.test",
summary="Visible event",
start_at=start,
end_at=start + timedelta(hours=1),
),
CalendarEvent(
id="private-event",
tenant_id="tenant-1",
calendar_id="private-calendar",
uid="private@example.test",
summary="Private event",
start_at=start + timedelta(hours=2),
end_at=start + timedelta(hours=3),
),
)
)
session.commit()
events = list_events(
session,
tenant_id="tenant-1",
visible_calendar_ids=("visible-calendar",),
limit=1,
)
occurrences = list_event_occurrences(
session,
tenant_id="tenant-1",
start_at=start - timedelta(hours=1),
end_at=start + timedelta(days=1),
visible_calendar_ids=("visible-calendar",),
limit=1,
)
self.assertEqual(["visible-event"], [event.id for event in events])
self.assertEqual(["visible-event"], [event["id"] for event in occurrences])
finally:
session.close()
engine.dispose()
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()
+348
View File
@@ -0,0 +1,348 @@
from __future__ import annotations
import json
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.schemas import CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarSyncSourceCreateRequest
from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, soft_delete_unseen_source_events, sync_source
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
class CalendarSyncSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session_with_calendar(self):
session = self.Session()
self.sessions.append(session)
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
session.flush()
return session, calendar
def test_ics_subscription_sync_imports_events_and_blocks_local_mutation(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ics",
calendar_id=calendar.id,
collection_url="https://calendar.example.test/feed.ics",
sync_direction="two_way",
),
)
ics = """BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:ics-1@example.test
DTSTART:20260708T090000Z
DTEND:20260708T100000Z
SUMMARY:ICS item
END:VEVENT
END:VCALENDAR
"""
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {"etag": '"feed-1"'}, ics)):
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(refreshed.sync_direction, "inbound")
self.assertEqual(stats.created, 1)
self.assertEqual(event.source_kind, "ics")
self.assertEqual(event.summary, "ICS item")
with self.assertRaisesRegex(CalendarError, "inbound-only"):
create_event(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=calendar.id,
summary="Local edit",
start_at=datetime(2026, 7, 8, 12, tzinfo=timezone.utc),
),
)
def test_ics_not_modified_sync_clears_error_and_reschedules(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ics",
calendar_id=calendar.id,
collection_url="https://calendar.example.test/feed.ics",
),
)
source.ctag = '"feed-1"'
source.last_status = "error"
source.last_error = "previous failure"
with patch("govoplan_calendar.backend.service.http_request", return_value=(304, {}, "")):
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
self.assertEqual(refreshed.last_status, "ok")
self.assertIsNone(refreshed.last_error)
self.assertIsNotNone(refreshed.last_synced_at)
self.assertIsNotNone(refreshed.next_sync_at)
self.assertEqual(stats.created, 0)
self.assertEqual(stats.updated, 0)
self.assertEqual(stats.deleted, 0)
def test_ics_sync_failure_records_error_and_reschedules(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ics",
calendar_id=calendar.id,
collection_url="https://calendar.example.test/feed.ics",
),
)
with patch("govoplan_calendar.backend.service.http_request", side_effect=RuntimeError("network down")):
with self.assertRaisesRegex(RuntimeError, "network down"):
sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
self.assertEqual(source.last_status, "error")
self.assertIn("network down", source.last_error or "")
self.assertIsNotNone(source.last_attempt_at)
self.assertIsNotNone(source.next_sync_at)
def test_graph_sync_imports_delta_events(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [
{
"id": "graph-event-1",
"iCalUId": "graph-uid-1",
"subject": "Graph item",
"start": {"dateTime": "2026-07-08T09:00:00", "timeZone": "UTC"},
"end": {"dateTime": "2026-07-08T10:00:00", "timeZone": "UTC"},
"@odata.etag": "graph-etag-1",
}
],
"@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendar/events/delta?$deltatoken=next",
}
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, json.dumps(payload))) as request:
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(stats.created, 1)
self.assertEqual(refreshed.sync_token, payload["@odata.deltaLink"])
self.assertEqual(event.source_kind, "graph")
self.assertEqual(event.source_href, "graph-event-1")
self.assertEqual(event.summary, "Graph item")
self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"])
def test_graph_sync_rejects_cross_origin_continuation_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.nextLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_graph_sync_rejects_cross_origin_delta_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.deltaLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_ews_sync_imports_calendar_view_items(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ews",
calendar_id=calendar.id,
collection_url="https://exchange.example.test/EWS/Exchange.asmx",
auth_type="basic",
username="ada",
password="secret",
),
)
response_xml = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
<s:Body>
<m:FindItemResponse>
<m:ResponseMessages>
<m:FindItemResponseMessage ResponseClass="Success">
<m:RootFolder>
<t:Items>
<t:CalendarItem>
<t:ItemId Id="ews-event-1" ChangeKey="ews-etag-1" />
<t:Subject>EWS item</t:Subject>
<t:Start>2026-07-08T09:00:00Z</t:Start>
<t:End>2026-07-08T10:00:00Z</t:End>
<t:IsAllDayEvent>false</t:IsAllDayEvent>
<t:UID>ews-uid-1</t:UID>
</t:CalendarItem>
</t:Items>
</m:RootFolder>
</m:FindItemResponseMessage>
</m:ResponseMessages>
</m:FindItemResponse>
</s:Body>
</s:Envelope>"""
with patch("govoplan_calendar.backend.service.http_request", return_value=(200, {}, response_xml)) as request:
_refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
event = session.query(CalendarEvent).one()
self.assertEqual(stats.created, 1)
self.assertEqual(event.source_kind, "ews")
self.assertEqual(event.source_href, "ews-event-1")
self.assertEqual(event.summary, "EWS item")
self.assertTrue(request.call_args.kwargs["body"].startswith("<?xml"))
def test_full_sync_cleanup_soft_deletes_unseen_events_in_batches(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ics",
calendar_id=calendar.id,
collection_url="https://calendar.example.test/events.ics",
),
)
for index in range(3):
session.add(
CalendarEvent(
tenant_id="tenant-1",
calendar_id=calendar.id,
uid=f"event-{index}@example.test",
recurrence_id=None,
summary=f"Event {index}",
status="CONFIRMED",
transparency="OPAQUE",
classification="PUBLIC",
start_at=datetime(2026, 7, 8, 9 + index, tzinfo=timezone.utc),
all_day=False,
attendees=[],
categories=[],
rdate=[],
exdate=[],
reminders=[],
attachments=[],
related_to=[],
source_kind="ics",
source_href=f"ics-event-{index}",
icalendar={},
)
)
session.flush()
deleted = soft_delete_unseen_source_events(
session,
source=source,
seen_hrefs={"ics-event-1"},
batch_size=1,
)
active_hrefs = {
event.source_href
for event in session.query(CalendarEvent).filter(CalendarEvent.deleted_at.is_(None)).all()
}
self.assertEqual(deleted, 2)
self.assertEqual(active_hrefs, {"ics-event-1"})
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.tasks import sync_due_caldav_sources_task
from govoplan_core.core.module_entitlements import TenantModuleAdmission
class CalendarTaskEntitlementTests(unittest.TestCase):
def test_disabled_tenant_preserves_due_sync_for_operator(self) -> None:
session = SimpleNamespace(commit=lambda: None)
@contextmanager
def session_scope():
yield session
admission = TenantModuleAdmission(
tenant_id="tenant-1",
module_id="calendar",
revision=2,
work_state="accepted",
allowed=False,
disposition="operator_action_required",
reason="Calendar is unavailable.",
)
resolver = SimpleNamespace(admission=lambda *_args, **_kwargs: admission)
registry = SimpleNamespace(
tenant_entitlement_resolver=lambda: resolver,
)
database = SimpleNamespace(SessionLocal=session_scope)
with (
patch(
"govoplan_core.core.worker_runtime.build_worker_platform_registry",
return_value=registry,
),
patch(
"govoplan_core.db.session.get_database",
return_value=database,
),
patch(
"govoplan_calendar.backend.service.sync_due_sources"
) as sync_due,
):
result = sync_due_caldav_sources_task.run("tenant-1", 10)
sync_due.assert_not_called()
self.assertEqual("operator_action_required", result[0]["status"])
if __name__ == "__main__":
unittest.main()
+12 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/calendar-webui", "name": "@govoplan/calendar-webui",
"version": "0.1.5", "version": "0.1.24",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,15 +13,19 @@
}, },
"./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",
"test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.5", "@govoplan/core-webui": "^0.1.44",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1", "react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^7.3.6"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+431 -5
View File
@@ -51,10 +51,217 @@ export type CalendarEvent = {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
instance_id?: string | null;
series_event_id?: string | null;
is_occurrence?: boolean;
is_override?: boolean;
}; };
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;
credential_envelope_id?: 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;
credential_ref?: 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;
credential_ref?: 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 CalendarCredentialEnvelope = {
id: string;
scope_type: string;
scope_id?: string | null;
name: string;
description?: string | null;
credential_kind: string;
public_data: Record<string, unknown>;
secret_keys: string[];
secret_configured: boolean;
allowed_modules: string[];
inherit_to_lower_scopes: boolean;
is_active: boolean;
revision: string;
};
export type CalendarCredentialEnvelopeListResponse = {
credentials: CalendarCredentialEnvelope[];
};
export type CalendarSyncSourceSyncPayload = {
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 CalendarOutboxActionAvailability = {
allowed: boolean;
reason?: string | null;
};
export type CalendarOutboxOperation = {
id: string;
source_id: string;
event_id?: string | null;
operation_kind: "put" | "delete" | string;
resource_href: string;
status: string;
attempt_count: number;
max_attempts: number;
available_at: string;
last_attempt_at?: string | null;
lease_expires_at?: string | null;
completed_at?: string | null;
reconciled_at?: string | null;
last_error?: string | null;
created_at: string;
updated_at: string;
actions: Record<string, CalendarOutboxActionAvailability>;
};
export type CalendarOutboxOperationListResponse = {
operations: CalendarOutboxOperation[];
};
export type CalendarMigrationResource = {
id: string;
source_href: string;
destination_href: string;
event_ids: string[];
status: string;
destination_operation_id?: string | null;
destination_operation_status?: string | null;
source_delete_operation_id?: string | null;
source_delete_operation_status?: string | null;
last_error?: string | null;
};
export type CalendarMigrationBatch = {
id: string;
migration_kind: string;
status: string;
phase: string;
source_calendar_id: string;
target_calendar_id: string;
source_sync_source_id: string;
target_sync_source_id: string;
total_resources: number;
copied_resources: number;
deleted_source_resources: number;
conflict_count: number;
total_events: number;
last_error?: string | null;
can_cancel: boolean;
authorization_evidence: Record<string, unknown>;
cancellation_evidence?: Record<string, unknown> | null;
created_by_user_id?: string | null;
created_by_api_key_id?: string | null;
created_at: string;
updated_at: string;
completed_at?: string | null;
resources: CalendarMigrationResource[];
};
export type CalendarMigrationBatchListResponse = {
migrations: CalendarMigrationBatch[];
};
export type CalendarCollectionCreatePayload = { export type CalendarCollectionCreatePayload = {
name: string; name: string;
@@ -71,28 +278,72 @@ 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;
destructive_confirmation?: string | null;
evidence_note?: string | 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>;
export type CalendarViewPreferences = {
dim_weekends: boolean;
dim_off_hours: boolean;
workday_start_hour: number;
workday_end_hour: number;
continuous_virtualization: boolean;
continuous_overscan_weeks: number;
alternate_continuous_months: boolean;
overridden_fields: string[];
defaults: Record<string, boolean | number>;
};
export type CalendarViewPreferencesUpdatePayload = Partial<
Pick<
CalendarViewPreferences,
| "dim_weekends"
| "dim_off_hours"
| "workday_start_hour"
| "workday_end_hour"
| "continuous_virtualization"
| "continuous_overscan_weeks"
| "alternate_continuous_months"
>
>;
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> { export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars"); return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
@@ -110,18 +361,147 @@ 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 listCalendarCredentials(settings: ApiSettings, sourceId?: string | null): Promise<CalendarCredentialEnvelopeListResponse> {
const search = new URLSearchParams();
if (sourceId) search.set("source_id", sourceId);
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarCredentialEnvelopeListResponse>(settings, `/api/v1/calendar/credentials${suffix}`);
}
export function discoverCalDavCalendars(settings: ApiSettings, payload: CalendarCalDavDiscoveryPayload): Promise<CalendarCalDavDiscoveryResponse> {
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 listCalendarOutbox(
settings: ApiSettings,
params: { source_id?: string; status?: string; limit?: number } = {},
): Promise<CalendarOutboxOperationListResponse> {
const search = new URLSearchParams();
if (params.source_id) search.set("source_id", params.source_id);
if (params.status) search.set("status", params.status);
if (params.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarOutboxOperationListResponse>(settings, `/api/v1/calendar/caldav/outbox${suffix}`);
}
export function recoverCalendarOutboxOperation(
settings: ApiSettings,
operationId: string,
action: "retry" | "reconcile" | "discard",
): Promise<CalendarOutboxOperation> {
return apiFetch<CalendarOutboxOperation>(settings, `/api/v1/calendar/caldav/outbox/${operationId}/${action}`, {
method: "POST",
});
}
export function listCalendarMigrations(
settings: ApiSettings,
params: { calendar_id?: string; limit?: number } = {},
): Promise<CalendarMigrationBatchListResponse> {
const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
if (params.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarMigrationBatchListResponse>(settings, `/api/v1/calendar/migrations${suffix}`);
}
export function getCalendarMigration(
settings: ApiSettings,
batchId: string,
): Promise<CalendarMigrationBatch> {
return apiFetch<CalendarMigrationBatch>(settings, `/api/v1/calendar/migrations/${batchId}`);
}
export function cancelCalendarMigration(
settings: ApiSettings,
batchId: string,
evidenceNote: string,
): Promise<CalendarMigrationBatch> {
return apiFetch<CalendarMigrationBatch>(settings, `/api/v1/calendar/migrations/${batchId}/cancel`, {
method: "POST",
body: JSON.stringify({ evidence_note: evidenceNote }),
});
}
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; expand_recurring?: boolean; limit?: number } = {}
): Promise<CalendarEventListResponse> { ): Promise<CalendarEventListResponse> {
const search = new URLSearchParams(); const search = new URLSearchParams();
if (params.calendar_id) search.set("calendar_id", params.calendar_id); if (params.calendar_id) search.set("calendar_id", params.calendar_id);
if (params.start_at) search.set("start_at", params.start_at); if (params.start_at) search.set("start_at", params.start_at);
if (params.end_at) search.set("end_at", params.end_at); if (params.end_at) search.set("end_at", params.end_at);
if (params.expand_recurring) search.set("expand_recurring", "true");
if (params.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : ""; const suffix = search.toString() ? `?${search.toString()}` : "";
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 getCalendarEvent(settings: ApiSettings, eventId: string): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(settings, `/api/v1/calendar/events/${eventId}`);
}
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) });
} }
@@ -133,3 +513,49 @@ export function updateCalendarEvent(settings: ApiSettings, eventId: string, payl
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> { export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" }); return apiFetch<void>(settings, `/api/v1/calendar/events/${eventId}`, { method: "DELETE" });
} }
export function updateCalendarEventOccurrence(
settings: ApiSettings,
seriesEventId: string,
recurrenceId: string,
payload: CalendarEventUpdatePayload
): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(
settings,
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
{
method: "PATCH",
body: JSON.stringify({ ...payload, recurrence_id: recurrenceId })
}
);
}
export function deleteCalendarEventOccurrence(
settings: ApiSettings,
seriesEventId: string,
recurrenceId: string
): Promise<CalendarEvent> {
return apiFetch<CalendarEvent>(
settings,
`/api/v1/calendar/events/${seriesEventId}/occurrence`,
{
method: "DELETE",
body: JSON.stringify({ recurrence_id: recurrenceId })
}
);
}
export function getCalendarViewPreferences(settings: ApiSettings): Promise<CalendarViewPreferences> {
return apiFetch<CalendarViewPreferences>(settings, "/api/v1/calendar/preferences/view");
}
export function updateCalendarViewPreferences(
settings: ApiSettings,
payload: CalendarViewPreferencesUpdatePayload
): Promise<CalendarViewPreferences> {
return apiFetch<CalendarViewPreferences>(
settings,
"/api/v1/calendar/preferences/view",
{ method: "PATCH", body: JSON.stringify(payload) }
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,634 @@
import {
useMemo,
useState,
type FormEvent,
} from "react";
import { Trash2 } from "lucide-react";
import { FormGrid, DialogForm,
ActionBlockerHint,
Button,
ConfirmDialog,
DateField,
Dialog,
DocumentationHelpLink,
SegmentedControl,
TimeField,
ToggleSwitch,
i18nMessage,
useUnsavedDraftGuard,
} from "@govoplan/core-webui";
import type {
CalendarCollection,
CalendarEvent,
CalendarEventCreatePayload,
} from "../../api/calendar";
import {
addDays,
addHours,
addMinutesToTime,
calendarDraftKey,
errorText,
localDateTime,
startOfDay,
toInputDate,
toInputTime,
} from "./calendarViewModel";
import {
CALENDAR_DOCUMENTATION,
CALENDAR_I18N,
} from "./interfacePatterns";
type CalendarEventEndMode = "end" | "duration";
type CalendarEventEditScope = "occurrence" | "series";
type CalendarEventAdvancedPayload = Pick<
CalendarEventCreatePayload,
| "organizer"
| "attendees"
| "categories"
| "rrule"
| "rdate"
| "exdate"
| "reminders"
| "attachments"
| "related_to"
| "icalendar"
| "metadata"
>;
export function CalendarEventDialog({
calendars,
defaultCalendarId,
event,
editScope,
canChooseSeries,
seriesLoaded,
focusDate,
saving,
canWrite,
canDelete,
onEditScopeChange,
onCancel,
onSave,
onDelete
}: {
calendars: CalendarCollection[];
defaultCalendarId: string;
event: CalendarEvent | null;
editScope: CalendarEventEditScope;
canChooseSeries: boolean;
seriesLoaded: boolean;
focusDate: Date;
saving: boolean;
canWrite: boolean;
canDelete: boolean;
onEditScopeChange: (scope: CalendarEventEditScope) => void;
onCancel: () => void;
onSave: (
payload: CalendarEventCreatePayload,
event: CalendarEvent | null,
editScope: CalendarEventEditScope
) => Promise<boolean>;
onDelete: (
event: CalendarEvent,
editScope: CalendarEventEditScope
) => Promise<void>;
}) {
const initialStart = event ? new Date(event.start_at) : focusDate;
const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1);
const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd;
const initialDurationSeconds = event?.duration_seconds ?? Math.max(3600, Math.round((initialEnd.getTime() - initialStart.getTime()) / 1000));
const [summary, setSummary] = useState(event?.summary ?? "");
const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId);
const [description, setDescription] = useState(event?.description ?? "");
const [location, setLocation] = useState(event?.location ?? "");
const [startDate, setStartDate] = useState(toInputDate(initialStart));
const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd));
const [startTime, setStartTime] = useState(toInputTime(initialStart));
const [endTime, setEndTime] = useState(toInputTime(initialEnd));
const [allDay, setAllDay] = useState(event?.all_day ?? false);
const [endMode, setEndMode] = useState<CalendarEventEndMode>(event && eventUsesDuration(event) ? "duration" : "end");
const [durationSeconds, setDurationSeconds] = useState(String(initialDurationSeconds));
const [uid, setUid] = useState(event?.uid ?? "");
const [recurrenceId, setRecurrenceId] = useState(event?.recurrence_id ?? "");
const [sequence, setSequence] = useState(String(event?.sequence ?? 0));
const [status, setStatus] = useState(event?.status ?? "CONFIRMED");
const [transparency, setTransparency] = useState(event?.transparency ?? "OPAQUE");
const [classification, setClassification] = useState(event?.classification ?? "PUBLIC");
const [timezone, setTimezone] = useState(event?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
const [categoriesText, setCategoriesText] = useState((event?.categories ?? []).join(", "));
const [organizerJson, setOrganizerJson] = useState(jsonTextareaValue(event?.organizer ?? null));
const [attendeesJson, setAttendeesJson] = useState(jsonTextareaValue(event?.attendees ?? []));
const [rruleText, setRruleText] = useState(event?.rrule ? serializeRRuleText(event.rrule) : "");
const [rdateJson, setRdateJson] = useState(jsonTextareaValue(event?.rdate ?? []));
const [exdateJson, setExdateJson] = useState(jsonTextareaValue(event?.exdate ?? []));
const [remindersJson, setRemindersJson] = useState(jsonTextareaValue(event?.reminders ?? []));
const [attachmentsJson, setAttachmentsJson] = useState(jsonTextareaValue(event?.attachments ?? []));
const [relatedToJson, setRelatedToJson] = useState(jsonTextareaValue(event?.related_to ?? []));
const [sourceKind, setSourceKind] = useState(event?.source_kind ?? "local");
const [sourceHref, setSourceHref] = useState(event?.source_href ?? "");
const [etag, setEtag] = useState(event?.etag ?? "");
const [icalendarJson, setICalendarJson] = useState(jsonTextareaValue(event?.icalendar ?? {}));
const [metadataJson, setMetadataJson] = useState(jsonTextareaValue(event?.metadata ?? {}));
const [formError, setFormError] = useState("");
const [confirmingDelete, setConfirmingDelete] = useState(false);
const formId = "calendar-event-form";
const eventDraft = {
summary,
calendarId,
description,
location,
startDate,
endDate,
startTime,
endTime,
allDay,
endMode,
durationSeconds,
uid,
recurrenceId,
sequence,
status,
transparency,
classification,
timezone,
categoriesText,
organizerJson,
attendeesJson,
rruleText,
rdateJson,
exdateJson,
remindersJson,
attachmentsJson,
relatedToJson,
sourceKind,
sourceHref,
etag,
icalendarJson,
metadataJson
};
const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []);
const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey;
const saveDisabledReason = saving
? CALENDAR_I18N.saving
: !canWrite
? CALENDAR_I18N.readOnly
: !summary.trim()
? CALENDAR_I18N.eventTitleRequired
: undefined;
useUnsavedDraftGuard({
dirty: eventDirty,
onSave: saveCurrent,
onDiscard: onCancel
});
function handleAllDayChange(next: boolean) {
setAllDay(next);
if (next) {
setStartTime("00:00");
setEndTime("00:00");
} else if (startTime === "00:00" && endTime === "00:00") {
setStartTime("09:00");
setEndTime("10:00");
}
}
function handleStartDateChange(next: string) {
setStartDate(next);
if (endDate < next) setEndDate(next);
}
function handleStartTimeChange(next: string) {
setStartTime(next);
if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60));
}
async function saveCurrent(): Promise<boolean> {
setFormError("");
const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime);
const parsedDurationSeconds = Math.max(0, Number(durationSeconds) || 0);
const usesDuration = !allDay && endMode === "duration";
const endAt = allDay ?
addDays(localDateTime(endDate, "00:00"), 1) :
usesDuration ?
new Date(startAt.getTime() + parsedDurationSeconds * 1000) :
localDateTime(endDate, endTime);
if (usesDuration && parsedDurationSeconds <= 0) {
setFormError("i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7");
return false;
}
if (endAt <= startAt) {
setFormError(allDay ? "i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865" : "i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831");
return false;
}
let advanced: CalendarEventAdvancedPayload;
try {
advanced = {
organizer: parseJsonObjectOrNull(organizerJson, "i18n:govoplan-calendar.organizer.debd1720"),
attendees: parseJsonArray(attendeesJson, "i18n:govoplan-calendar.attendees.a45a0962"),
categories: commaSeparatedValues(categoriesText),
rrule: parseRRuleInput(rruleText),
rdate: parseJsonArray(rdateJson, "RDATE"),
exdate: parseJsonArray(exdateJson, "EXDATE"),
reminders: parseJsonArray(remindersJson, "i18n:govoplan-calendar.reminders.ae8c3939"),
attachments: parseJsonArray(attachmentsJson, "i18n:govoplan-calendar.attachments.6771ade6"),
related_to: parseJsonArray(relatedToJson, "i18n:govoplan-calendar.related_to.0e7989ff"),
icalendar: withDurationMode(parseJsonObject(icalendarJson, "i18n:govoplan-calendar.icalendar.f388476d"), usesDuration),
metadata: parseJsonObject(metadataJson, "i18n:govoplan-calendar.metadata.251edc0e")
};
} catch (err) {
setFormError(errorText(err));
return false;
}
const payload: CalendarEventCreatePayload = {
calendar_id: calendarId,
summary,
description: description || null,
location: location || null,
sequence: Math.max(0, Number(sequence) || 0),
status,
transparency,
classification,
start_at: startAt.toISOString(),
end_at: endAt.toISOString(),
duration_seconds: usesDuration ? parsedDurationSeconds : null,
all_day: allDay,
timezone: timezone.trim() || null,
source_kind: sourceKind.trim() || "local",
source_href: sourceHref.trim() || null,
etag: etag.trim() || null,
...advanced
};
if (!event) {
if (uid.trim()) payload.uid = uid.trim();
if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim();
}
return onSave(payload, event, editScope);
}
function submit(formEvent: FormEvent<HTMLFormElement>) {
formEvent.preventDefault();
void saveCurrent();
}
function requestDelete() {
if (!event) return;
setConfirmingDelete(true);
}
return (
<>
<Dialog
open
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}
className="calendar-vevent-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{event && canDelete &&
<Button type="button" variant="danger" helpContextId="calendar.event-editor" helpModuleId="calendar" onClick={requestDelete} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
<Trash2 size={16} /> i18n:govoplan-calendar.delete.f6fdbe48
</Button>
}
</div>
<div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button>
</div>
</>
}>
<DialogForm id={formId} className="calendar-dialog-form" onSubmit={submit}>
{!canWrite && (
<ActionBlockerHint
tone="info"
reason={{
summary: CALENDAR_I18N.readOnly,
requiredAction: "Ask a calendar manager for event write permission."
}}
documentation={CALENDAR_DOCUMENTATION}
/>
)}
{canChooseSeries && (
<SegmentedControl
ariaLabel="Recurring event edit scope"
value={editScope}
onChange={onEditScopeChange}
width="fill"
size="equal"
disabled={saving}
options={[
{ id: "occurrence", label: "This occurrence" },
{
id: "series",
label: seriesLoaded ? "Whole series" : "Loading series",
disabled: !seriesLoaded
}
]}
/>
)}
{formError && <p className="calendar-form-error">{formError}</p>}
<label>
<span>i18n:govoplan-calendar.title.768e0c1c</span>
<input value={summary} onChange={(item) => setSummary(item.target.value)} required maxLength={500} autoFocus disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.calendar.adab5090</span>
<select value={calendarId} onChange={(item) => setCalendarId(item.target.value)} required disabled={saving || !canWrite}>
{calendars.map((calendar) =>
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
)}
</select>
</label>
<label>
<span>i18n:govoplan-calendar.description.55f8ebc8</span>
<textarea value={description} onChange={(item) => setDescription(item.target.value)} rows={4} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.location.d219c681</span>
<input value={location} onChange={(item) => setLocation(item.target.value)} maxLength={500} disabled={saving || !canWrite} />
</label>
<ToggleSwitch label="i18n:govoplan-calendar.whole_day.951c82d1" checked={allDay} disabled={saving || !canWrite} onChange={handleAllDayChange} />
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.start_date.ff99f5b5</span>
<DateField value={startDate} onChange={handleStartDateChange} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.start_time.88d8206d</span>
<TimeField value={startTime} onChange={handleStartTimeChange} disabled={saving || !canWrite || allDay} />
</label>
</FormGrid>
{!allDay &&
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.end_mode.5a06de37</span>
<select value={endMode} onChange={(item) => setEndMode(item.target.value as CalendarEventEndMode)} disabled={saving || !canWrite}>
<option value="end">i18n:govoplan-calendar.end_time.cd7800da</option>
<option value="duration">i18n:govoplan-calendar.duration.1370004d</option>
</select>
</label>
{endMode === "duration" &&
<label>
<span>i18n:govoplan-calendar.duration_seconds.19d42eeb</span>
<input type="number" min={1} step={60} value={durationSeconds} onChange={(item) => setDurationSeconds(item.target.value)} required disabled={saving || !canWrite} />
</label>
}
</FormGrid>
}
{(allDay || endMode === "end") &&
<FormGrid columns={2} gap="compact" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.end_date.89d10cd6</span>
<DateField value={endDate} min={startDate} onChange={setEndDate} required disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.end_time.cd7800da</span>
<TimeField value={endTime} min={!allDay && startDate === endDate ? startTime : undefined} onChange={setEndTime} disabled={saving || !canWrite || allDay} />
</label>
</FormGrid>
}
<details className="calendar-advanced-settings calendar-vevent-details">
<summary>i18n:govoplan-calendar.vevent.9cf5be75</summary>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.identity.7e5a975b</h3>
<FormGrid columns={3} gap="small" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.uid.d946adf5</span>
<input value={uid} onChange={(item) => setUid(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.recurrence_id.e0b780ba</span>
<input value={recurrenceId} onChange={(item) => setRecurrenceId(item.target.value)} maxLength={255} disabled={saving || !canWrite || Boolean(event)} />
</label>
<label>
<span>i18n:govoplan-calendar.sequence.5c8f4e0e</span>
<input type="number" min={0} step={1} value={sequence} onChange={(item) => setSequence(item.target.value)} disabled={saving || !canWrite} />
</label>
</FormGrid>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.state.a7250206</h3>
<FormGrid columns={3} gap="small" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.status.bae7d5be</span>
<select value={status} onChange={(item) => setStatus(item.target.value)} disabled={saving || !canWrite}>
<option value="CONFIRMED">i18n:govoplan-calendar.confirmed.0542404a</option>
<option value="TENTATIVE">i18n:govoplan-calendar.tentative.d19f9022</option>
<option value="CANCELLED">i18n:govoplan-calendar.cancelled.5587b0af</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.transparency.7cb338a9</span>
<select value={transparency} onChange={(item) => setTransparency(item.target.value)} disabled={saving || !canWrite}>
<option value="OPAQUE">i18n:govoplan-calendar.opaque.3e1d0194</option>
<option value="TRANSPARENT">i18n:govoplan-calendar.transparent.ea4efcae</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.class.41ff354b</span>
<select value={classification} onChange={(item) => setClassification(item.target.value)} disabled={saving || !canWrite}>
<option value="PUBLIC">i18n:govoplan-calendar.public.d1785ca2</option>
<option value="PRIVATE">i18n:govoplan-calendar.private.b0b7ba46</option>
<option value="CONFIDENTIAL">i18n:govoplan-calendar.confidential.84c9cc88</option>
</select>
</label>
<label>
<span>i18n:govoplan-calendar.timezone.d1f7dc89</span>
<input value={timezone} onChange={(item) => setTimezone(item.target.value)} maxLength={100} disabled={saving || !canWrite} />
</label>
<label className="calendar-dialog-wide">
<span>i18n:govoplan-calendar.categories.6ccb6007</span>
<input value={categoriesText} onChange={(item) => setCategoriesText(item.target.value)} disabled={saving || !canWrite} />
</label>
</FormGrid>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
<FormGrid columns={2} gap="small" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.organizer_json.3add6f9f</span>
<textarea value={organizerJson} onChange={(item) => setOrganizerJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attendees_json.aeb487bb</span>
<textarea value={attendeesJson} onChange={(item) => setAttendeesJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</FormGrid>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.recurrence.f7ad40f5</h3>
<label>
<span>i18n:govoplan-calendar.rrule.c7b2f8a3</span>
<input value={rruleText} onChange={(item) => setRruleText(item.target.value)} disabled={saving || !canWrite} />
</label>
<FormGrid columns={2} gap="small" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.rdate_json.5b51fca4</span>
<textarea value={rdateJson} onChange={(item) => setRdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.exdate_json.7d0c538d</span>
<textarea value={exdateJson} onChange={(item) => setExdateJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</FormGrid>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.related.917df91e</h3>
<FormGrid columns={3} gap="small" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.reminders_json.ca25e08f</span>
<textarea value={remindersJson} onChange={(item) => setRemindersJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.attachments_json.d91abf3c</span>
<textarea value={attachmentsJson} onChange={(item) => setAttachmentsJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.related_to_json.2d4e8f59</span>
<textarea value={relatedToJson} onChange={(item) => setRelatedToJson(item.target.value)} rows={5} disabled={saving || !canWrite} />
</label>
</FormGrid>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.source.6da13add</h3>
<FormGrid columns={3} gap="small" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.source_kind.7eda9bc4</span>
<input value={sourceKind} onChange={(item) => setSourceKind(item.target.value)} maxLength={30} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.source_href.819fa147</span>
<input value={sourceHref} onChange={(item) => setSourceHref(item.target.value)} maxLength={1000} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.etag.11d00f6e</span>
<input value={etag} onChange={(item) => setEtag(item.target.value)} maxLength={255} disabled={saving || !canWrite} />
</label>
</FormGrid>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
<FormGrid columns={2} gap="small" collapseAt="narrow">
<label>
<span>i18n:govoplan-calendar.icalendar_json.fb6cc33e</span>
<textarea value={icalendarJson} onChange={(item) => setICalendarJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
<label>
<span>i18n:govoplan-calendar.metadata_json.b0e4c283</span>
<textarea value={metadataJson} onChange={(item) => setMetadataJson(item.target.value)} rows={8} disabled={saving || !canWrite} />
</label>
</FormGrid>
</section>
</details>
</DialogForm>
</Dialog>
<ConfirmDialog
open={Boolean(event && confirmingDelete)}
title="i18n:govoplan-calendar.delete_event_title"
message={i18nMessage(editScope === "occurrence"
? "i18n:govoplan-calendar.delete_event_occurrence_message"
: "i18n:govoplan-calendar.delete_event_series_message", {
value0: event?.summary || "Event"
})}
confirmLabel="i18n:govoplan-calendar.delete.f6fdbe48"
tone="danger"
busy={saving}
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => event && void onDelete(event, editScope)}
/>
</>);
}
function jsonTextareaValue(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function parseJsonObject(text: string, label: string): Record<string, unknown> {
const value = parseJsonText(text, label, {});
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object.`);
return value;
}
function parseJsonObjectOrNull(text: string, label: string): Record<string, unknown> | null {
if (!text.trim()) return null;
const value = parseJsonText(text, label, null);
if (value === null) return null;
if (!isPlainObject(value)) throw new Error(`${label} must be a JSON object or null.`);
return value;
}
function parseJsonArray(text: string, label: string): Record<string, unknown>[] {
const value = parseJsonText(text, label, []);
if (!Array.isArray(value)) throw new Error(`${label} must be a JSON array.`);
return value.map((item, index) => {
if (!isPlainObject(item)) throw new Error(`${label} item ${index + 1} must be a JSON object.`);
return item;
});
}
function parseJsonText(text: string, label: string, fallback: unknown): unknown {
const trimmed = text.trim();
if (!trimmed) return fallback;
try {
return JSON.parse(trimmed) as unknown;
} catch (err) {
throw new Error(`${label} contains invalid JSON: ${errorText(err)}`);
}
}
function parseRRuleInput(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
if (!trimmed) return null;
if (trimmed.startsWith("{")) return parseJsonObject(trimmed, "RRULE");
const result: Record<string, unknown> = {};
for (const item of trimmed.split(";")) {
const [rawKey, ...rawValueParts] = item.split("=");
const key = rawKey.trim().toUpperCase();
const rawValue = rawValueParts.join("=").trim();
if (!key || !rawValue) continue;
const values = rawValue.split(",").map((value) => value.trim()).filter(Boolean);
result[key] = values.length > 1 ? values : values[0] ?? rawValue;
}
return Object.keys(result).length ? result : null;
}
function serializeRRuleText(value: Record<string, unknown>): string {
return Object.entries(value).
map(([key, item]) => `${key.toUpperCase()}=${Array.isArray(item) ? item.join(",") : String(item)}`).
join(";");
}
function commaSeparatedValues(text: string): string[] {
return text.split(",").map((item) => item.trim()).filter(Boolean);
}
function withDurationMode(value: Record<string, unknown>, usesDuration: boolean): Record<string, unknown> {
const next = { ...value };
const standard = isPlainObject(next.standard) ? { ...next.standard } : {};
if (usesDuration) {
standard.uses_duration = true;
} else {
delete standard.uses_duration;
}
if (Object.keys(standard).length > 0) {
next.standard = standard;
} else {
delete next.standard;
}
return next;
}
function eventUsesDuration(event: CalendarEvent): boolean {
const standard = isPlainObject(event.icalendar?.standard) ? event.icalendar.standard : null;
return standard?.uses_duration === true;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCw, XCircle } from "lucide-react";
import {
ActionToolbar,
Button,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
StatusBadge,
type ApiSettings,
} from "@govoplan/core-webui";
import {
cancelCalendarMigration,
getCalendarMigration,
type CalendarMigrationBatch,
} from "../../api/calendar";
import { dateTimeLabel, errorText } from "./calendarViewModel";
import {
CALENDAR_I18N,
CALENDAR_SOURCE_DOCUMENTATION,
} from "./interfacePatterns";
export function CalendarMigrationDialog({
settings,
batchId,
onClose,
onChanged,
}: {
settings: ApiSettings;
batchId: string;
onClose: () => void;
onChanged: () => void;
}) {
const [batch, setBatch] = useState<CalendarMigrationBatch | null>(null);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [cancellationEvidence, setCancellationEvidence] = useState("");
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
const load = useCallback(async () => {
try {
const next = await getCalendarMigration(settings, batchId);
setBatch(next);
setError("");
if (next.status === "completed" || next.status === "cancelled") {
onChangedRef.current();
}
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}, [batchId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!batch || !["active", "blocked", "cancel_requested"].includes(batch.status)) {
return undefined;
}
const timer = window.setInterval(() => void load(), 3000);
return () => window.clearInterval(timer);
}, [batch, load]);
const progress = useMemo(() => {
if (!batch?.total_resources) return 0;
const completedSteps = batch.copied_resources + batch.deleted_source_resources;
return Math.round((completedSteps / (batch.total_resources * 2)) * 100);
}, [batch]);
async function cancelMigration() {
if (!batch?.can_cancel || cancellationEvidence.trim().length < 10) return;
setWorking(true);
setError("");
try {
const next = await cancelCalendarMigration(
settings,
batch.id,
cancellationEvidence.trim(),
);
setBatch(next);
setCancellationEvidence("");
onChanged();
} catch (err) {
setError(errorText(err));
} finally {
setWorking(false);
}
}
return (
<Dialog
open
title="Remote calendar move"
titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}
className="calendar-migration-dialog"
closeDisabled={working}
onClose={onClose}
footer={
<>
<Button type="button" onClick={() => void load()} disabled={loading || working}>
<RefreshCw size={16} /> Refresh
</Button>
<Button type="button" onClick={onClose} disabled={working}>Close</Button>
</>
}
>
{loading && !batch ? (
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
) : (
<div className="calendar-migration-body">
{error && (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
)}
{batch && (
<>
<ActionToolbar surface="section-header" className="calendar-migration-heading">
<div>
<strong>{phaseLabel(batch.phase)}</strong>
<span>Updated {dateTimeLabel(new Date(batch.updated_at))}</span>
</div>
<StatusBadge status={batch.status} label={statusLabel(batch.status)} />
</ActionToolbar>
<progress value={progress} max={100} aria-label="Remote move progress" />
<dl className="calendar-migration-summary">
<div><dt>Events</dt><dd>{batch.total_events}</dd></div>
<div><dt>Copied</dt><dd>{batch.copied_resources} / {batch.total_resources}</dd></div>
<div><dt>Source deleted</dt><dd>{batch.deleted_source_resources} / {batch.total_resources}</dd></div>
<div><dt>Conflicts</dt><dd>{batch.conflict_count}</dd></div>
</dl>
{typeof batch.authorization_evidence.note === "string" && (
<p className="calendar-form-note">
Authorization evidence: {batch.authorization_evidence.note}
</p>
)}
{batch.last_error && <p className="calendar-migration-error">{batch.last_error}</p>}
<ol className="calendar-migration-resources">
{batch.resources.map((resource) => (
<li key={resource.id}>
<div>
<StatusBadge status={resource.status} label={statusLabel(resource.status)} />
<code title={resource.source_href}>{resource.source_href}</code>
</div>
<span>
Copy: {statusLabel(resource.destination_operation_status || "pending")}; source: {statusLabel(resource.source_delete_operation_status || "pending")}
</span>
{resource.last_error && <p>{resource.last_error}</p>}
</li>
))}
</ol>
{batch.can_cancel && (
<section className="calendar-migration-cancel">
<label>
<span>Cancellation evidence</span>
<textarea
value={cancellationEvidence}
onChange={(event) => setCancellationEvidence(event.target.value)}
minLength={10}
maxLength={2000}
rows={3}
placeholder="Record why the source must be retained."
/>
</label>
<Button
type="button"
variant="danger"
onClick={() => void cancelMigration()}
disabled={working || cancellationEvidence.trim().length < 10}
disabledReason={working ? CALENDAR_I18N.saving : cancellationEvidence.trim().length < 10 ? CALENDAR_I18N.cancellationEvidenceRequired : undefined}
>
<XCircle size={16} /> Cancel remote move
</Button>
</section>
)}
</>
)}
</div>
)}
</Dialog>
);
}
function phaseLabel(value: string): string {
return value.replace(/_/g, " ").replace(/^./, (letter: string) => letter.toUpperCase());
}
function statusLabel(value: string): string {
return value.replace(/_/g, " ");
}
@@ -0,0 +1,226 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
import { ActionToolbar,
ActionBlockerHint,
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
SegmentedControl,
StatusBadge,
type ApiSettings,
} from "@govoplan/core-webui";
import {
listCalendarOutbox,
recoverCalendarOutboxOperation,
type CalendarCollection,
type CalendarOutboxOperation,
type CalendarSyncSource,
} from "../../api/calendar";
import { dateTimeLabel, errorText } from "./calendarViewModel";
import {
CALENDAR_I18N,
CALENDAR_RECOVERY_DOCUMENTATION,
} from "./interfacePatterns";
type OutboxFilter = "unresolved" | "all";
type RecoveryAction = "retry" | "reconcile" | "discard";
const RESOLVED_STATUSES = new Set(["succeeded", "superseded", "cancelled"]);
export function CalendarOutboxDialog({
settings,
calendar,
source,
onClose,
}: {
settings: ApiSettings;
calendar: CalendarCollection;
source: CalendarSyncSource;
onClose: () => void;
}) {
const [operations, setOperations] = useState<CalendarOutboxOperation[]>([]);
const [filter, setFilter] = useState<OutboxFilter>("unresolved");
const [loading, setLoading] = useState(true);
const [busyOperationId, setBusyOperationId] = useState("");
const [error, setError] = useState("");
const [discardOperation, setDiscardOperation] = useState<CalendarOutboxOperation | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await listCalendarOutbox(settings, {
source_id: source.id,
limit: 100,
});
setOperations(response.operations);
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}, [settings, source.id]);
useEffect(() => {
void load();
}, [load]);
const visibleOperations = useMemo(
() => filter === "all"
? operations
: operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)),
[filter, operations],
);
const unresolvedCount = operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)).length;
const conflictCount = operations.filter((operation) => ["conflict", "dead"].includes(operation.status)).length;
async function recover(operation: CalendarOutboxOperation, action: RecoveryAction) {
setBusyOperationId(operation.id);
setError("");
try {
await recoverCalendarOutboxOperation(settings, operation.id, action);
setDiscardOperation(null);
await load();
} catch (err) {
setError(errorText(err));
} finally {
setBusyOperationId("");
}
}
return (
<>
<Dialog
open
title="i18n:govoplan-calendar.outbound_changes.7038a839"
titleHelp={<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />}
className="calendar-outbox-dialog"
closeDisabled={Boolean(busyOperationId)}
onClose={onClose}
footer={
<>
<Button type="button" onClick={() => void load()} disabled={loading || Boolean(busyOperationId)} disabledReason={loading ? CALENDAR_I18N.loading : busyOperationId ? CALENDAR_I18N.saving : undefined}>
<RefreshCw size={16} className={loading ? "calendar-sync-spin" : undefined} /> i18n:govoplan-calendar.refresh.56e3badc
</Button>
<Button type="button" onClick={onClose} disabled={Boolean(busyOperationId)}>
i18n:govoplan-core.close.bbfa773e
</Button>
</>
}
>
<div className="calendar-outbox-body">
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<ActionToolbar justify="between" className="calendar-outbox-toolbar">
<SegmentedControl<OutboxFilter>
value={filter}
ariaLabel="i18n:govoplan-calendar.outbox_filter.8305af40"
onChange={setFilter}
options={[
{ id: "unresolved", label: "i18n:govoplan-calendar.unresolved.11c41de4" },
{ id: "all", label: "i18n:govoplan-calendar.all_history.8829c44e" },
]}
/>
<dl className="calendar-outbox-summary">
<div><dt>i18n:govoplan-calendar.unresolved.11c41de4</dt><dd>{unresolvedCount}</dd></div>
<div><dt>i18n:govoplan-calendar.conflicts_dead.31252c3a</dt><dd>{conflictCount}</dd></div>
<div><dt>i18n:govoplan-calendar.shown.498e85a1</dt><dd>{visibleOperations.length}</dd></div>
</dl>
</ActionToolbar>
{loading && !operations.length
? <p className="calendar-form-note">i18n:govoplan-calendar.loading_outbound_changes.3fc59656</p>
: visibleOperations.length
? (
<ol className="calendar-outbox-list">
{visibleOperations.map((operation) => (
<li key={operation.id} className="calendar-outbox-item">
<div className="calendar-outbox-item-heading">
<div>
<StatusBadge status={operation.status} />
<strong>{operation.operation_kind.toUpperCase()}</strong>
</div>
<time dateTime={operation.updated_at}>{dateTimeLabel(new Date(operation.updated_at))}</time>
</div>
<code title={operation.resource_href}>{operation.resource_href}</code>
<dl className="calendar-outbox-item-facts">
<div><dt>i18n:govoplan-calendar.attempts.448fa12e</dt><dd>{operation.attempt_count}/{operation.max_attempts}</dd></div>
<div><dt>i18n:govoplan-calendar.available.17bc146b</dt><dd>{dateTimeLabel(new Date(operation.available_at))}</dd></div>
</dl>
{operation.last_error && <p className="calendar-outbox-error">{operation.last_error}</p>}
<RecoveryActions
operation={operation}
busy={busyOperationId === operation.id}
onRecover={(action) => action === "discard"
? setDiscardOperation(operation)
: void recover(operation, action)}
/>
</li>
))}
</ol>
)
: <p className="calendar-form-note">i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64</p>}
{operations.length === 100 && (
<p className="calendar-form-note">i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365</p>
)}
</div>
</Dialog>
<ConfirmDialog
open={Boolean(discardOperation)}
title="i18n:govoplan-calendar.discard_local_desired_state.952f3be1"
message="i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d"
confirmLabel="i18n:govoplan-calendar.discard_change.85474ec4"
tone="danger"
busy={Boolean(busyOperationId)}
onCancel={() => setDiscardOperation(null)}
onConfirm={() => discardOperation && void recover(discardOperation, "discard")}
/>
</>
);
}
function RecoveryActions({
operation,
busy,
onRecover,
}: {
operation: CalendarOutboxOperation;
busy: boolean;
onRecover: (action: RecoveryAction) => void;
}) {
const available = (["retry", "reconcile", "discard"] as const).filter(
(action) => operation.actions[action]?.allowed,
);
const unavailableReason = Object.values(operation.actions).find((action) => action.reason)?.reason;
return (
<div className="calendar-outbox-recovery">
{available.length > 0 && (
<div className="calendar-outbox-actions">
{available.includes("retry") && (
<Button type="button" onClick={() => onRecover("retry")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
<RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c
</Button>
)}
{available.includes("reconcile") && (
<Button type="button" onClick={() => onRecover("reconcile")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
<ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64
</Button>
)}
{available.includes("discard") && (
<Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
<Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911
</Button>
)}
</div>
)}
{!available.length && unavailableReason && (
<ActionBlockerHint
tone="info"
reason={{ summary: unavailableReason }}
documentation={CALENDAR_RECOVERY_DOCUMENTATION}
/>
)}
</div>
);
}
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,200 @@
import { CalendarDays, ExternalLink, MapPin, Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import {
Button,
DismissibleAlert,
LoadingFrame,
SelectionList,
SelectionListItem,
SelectionListItemContent,
hasScope,
quickAccessLaunchState,
useDashboardWidgetData,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import { listCalendarEvents, type CalendarEvent } from "../../api/calendar";
const AGENDA_DAYS = 21;
const AGENDA_LIMIT = 7;
type Props = Pick<
QuickAccessToolRenderContext,
"settings" | "auth" | "launchContext" | "complete" | "close"
>;
/**
* Calendar-owned, range- and result-bounded agenda. The API applies tenant,
* scope, and collection-visibility checks before any event reaches the rail.
*/
export default function CalendarQuickAccess({
settings,
auth,
launchContext,
complete,
close
}: Props) {
const [selectedKey, setSelectedKey] = useState("");
const rangeStart = useMemo(
() => agendaStart(launchContext.temporalContext),
[
launchContext.temporalContext.validAt,
launchContext.temporalContext.validityMode
]
);
const rangeEnd = useMemo(() => {
const end = new Date(rangeStart);
end.setDate(end.getDate() + AGENDA_DAYS);
return end;
}, [rangeStart]);
const load = useCallback(async () => {
const response = await listCalendarEvents(settings, {
start_at: rangeStart.toISOString(),
end_at: rangeEnd.toISOString(),
expand_recurring: true,
limit: AGENDA_LIMIT
});
return response.events.filter(
(event) => event.status.toUpperCase() !== "CANCELLED"
);
}, [rangeEnd, rangeStart, settings]);
const { data: events, loading, error } = useDashboardWidgetData(load, 0);
const items = events ?? [];
const selected = useMemo(
() => items.find((event) => eventKey(event) === selectedKey) ?? items[0] ?? null,
[items, selectedKey]
);
const canCreate = hasScope(auth, "calendar:event:write");
useEffect(() => {
if (!selectedKey && items[0]) setSelectedKey(eventKey(items[0]));
if (selectedKey && !items.some((event) => eventKey(event) === selectedKey)) {
setSelectedKey(items[0] ? eventKey(items[0]) : "");
}
}, [items, selectedKey]);
function selectForHost(event: CalendarEvent) {
complete({
contractVersion: "1",
outcome: "completed",
action: "selected",
reference: {
ownerModule: "calendar",
kind: "event",
objectId: eventKey(event),
tenantId: event.tenant_id,
label: event.summary,
version: `${event.sequence}:${event.updated_at}`,
path: calendarFocusPath(event.start_at)
}
});
}
return (
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548">
{error ? (
<DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>
) : null}
<p className="calendar-quick-range">
i18n:govoplan-calendar.quick_access_range: {rangeLabel(rangeStart, rangeEnd)}
</p>
{items.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-calendar.agenda.891e9d6d">
{items.map((event) => (
<SelectionListItem
key={eventKey(event)}
selected={selected ? eventKey(event) === eventKey(selected) : false}
onClick={() => setSelectedKey(eventKey(event))}
>
<SelectionListItemContent
leading={<CalendarDays size={16} aria-hidden="true" />}
title={event.summary}
description={eventTimeLabel(event)}
/>
</SelectionListItem>
))}
</SelectionList>
) : !loading && !error ? (
<p className="muted">i18n:govoplan-calendar.no_events.e339ba73</p>
) : null}
{selected ? (
<section className="calendar-quick-detail" aria-label="i18n:govoplan-calendar.quick_access_event_details">
<strong>{selected.summary}</strong>
<span>{eventTimeLabel(selected)}</span>
{selected.location ? (
<span><MapPin size={13} aria-hidden="true" /> {selected.location}</span>
) : null}
{selected.description ? <p>{selected.description}</p> : null}
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => selectForHost(selected)}>
i18n:govoplan-calendar.quick_access_select_event
</Button>
<Link
className="btn btn-secondary"
to={calendarFocusPath(selected.start_at)}
state={quickAccessLaunchState(launchContext)}
onClick={() => selectForHost(selected)}
>
<ExternalLink size={15} aria-hidden="true" />
i18n:govoplan-calendar.quick_access_open_calendar
</Link>
</div>
</section>
) : null}
{canCreate ? (
<div className="dashboard-contribution-footer">
<Link
className="btn btn-secondary"
to={calendarCreatePath(rangeStart)}
state={quickAccessLaunchState(launchContext)}
onClick={close}
>
<Plus size={15} aria-hidden="true" />
i18n:govoplan-calendar.new_event.2ef3795c
</Link>
</div>
) : null}
</LoadingFrame>
);
}
function agendaStart(
context: QuickAccessToolRenderContext["launchContext"]["temporalContext"]
): Date {
if (context.validityMode === "at" && context.validAt) {
const parsed = new Date(context.validAt);
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return new Date();
}
function eventKey(event: CalendarEvent): string {
return event.instance_id || event.id;
}
function calendarFocusPath(startAt: string): string {
return `/calendar?focusDate=${encodeURIComponent(startAt)}`;
}
function calendarCreatePath(startAt: Date): string {
return `/calendar?quickAction=create-event&startAt=${encodeURIComponent(startAt.toISOString())}`;
}
function eventTimeLabel(event: CalendarEvent): string {
const start = new Date(event.start_at);
if (event.all_day) {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(start);
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(start);
}
function rangeLabel(start: Date, end: Date): string {
const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
return `${formatter.format(start)} ${formatter.format(end)}`;
}
@@ -0,0 +1,264 @@
import { useEffect, useMemo, useState } from "react";
import { Save } from "lucide-react";
import { FormGrid, ContentGrid,
Button,
Card,
DismissibleAlert,
DocumentationHelpLink,
FormField,
ToggleSwitch,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
getCalendarViewPreferences,
updateCalendarViewPreferences,
type CalendarViewPreferences
} from "../../api/calendar";
import {
CALENDAR_DOCUMENTATION,
CALENDAR_I18N,
} from "./interfacePatterns";
type CalendarPreferenceDraft = Pick<
CalendarViewPreferences,
| "dim_weekends"
| "dim_off_hours"
| "workday_start_hour"
| "workday_end_hour"
| "continuous_virtualization"
| "continuous_overscan_weeks"
| "alternate_continuous_months"
>;
const FALLBACK_DRAFT: CalendarPreferenceDraft = {
dim_weekends: true,
dim_off_hours: true,
workday_start_hour: 6,
workday_end_hour: 20,
continuous_virtualization: true,
continuous_overscan_weeks: 6,
alternate_continuous_months: true
};
export default function CalendarSettingsPanel({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const [loaded, setLoaded] = useState<CalendarPreferenceDraft | null>(null);
const [draft, setDraft] = useState<CalendarPreferenceDraft>(FALLBACK_DRAFT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [tone, setTone] = useState<"success" | "warning">("success");
const dirty = useMemo(
() => loaded !== null && JSON.stringify(loaded) !== JSON.stringify(draft),
[draft, loaded]
);
useEffect(() => {
void loadPreferences();
}, [auth.user.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function loadPreferences() {
setLoading(true);
setMessage("");
try {
const response = await getCalendarViewPreferences(settings);
const next = preferenceDraft(response);
setLoaded(next);
setDraft(next);
} catch (error) {
setTone("warning");
setMessage(errorText(error));
} finally {
setLoading(false);
}
}
async function savePreferences(): Promise<boolean> {
if (draft.workday_end_hour <= draft.workday_start_hour) {
setTone("warning");
setMessage("Workday end must be after workday start.");
return false;
}
setSaving(true);
setMessage("");
try {
const response = await updateCalendarViewPreferences(settings, draft);
const next = preferenceDraft(response);
setLoaded(next);
setDraft(next);
setTone("success");
setMessage("Calendar preferences saved.");
window.dispatchEvent(
new CustomEvent("govoplan:calendar-preferences-changed")
);
return true;
} catch (error) {
setTone("warning");
setMessage(errorText(error));
return false;
} finally {
setSaving(false);
}
}
const disabled = loading || saving;
const saveDisabledReason = loading
? CALENDAR_I18N.loading
: saving
? CALENDAR_I18N.saving
: !dirty
? CALENDAR_I18N.noChanges
: undefined;
useUnsavedDraftGuard({
dirty,
onSave: savePreferences,
onDiscard: () => setDraft(loaded ?? FALLBACK_DRAFT)
});
return (
<ContentGrid columns={2} collapseAt="workspace" className="calendar-settings-panel">
<Card title="Calendar display" titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}>
<FormGrid columns={1} collapseAt="standard" className="">
<ToggleSwitch
label="Dim weekends"
help="Use a quieter background for Saturday and Sunday in week views."
checked={draft.dim_weekends}
disabled={disabled}
onChange={(dim_weekends) =>
setDraft((current) => ({ ...current, dim_weekends }))
}
/>
<ToggleSwitch
label="Dim hours outside the workday"
checked={draft.dim_off_hours}
disabled={disabled}
onChange={(dim_off_hours) =>
setDraft((current) => ({ ...current, dim_off_hours }))
}
/>
<FormGrid columns={2} gap="small" collapseAt="narrow">
<FormField label="Workday starts">
<input
type="number"
min={0}
max={23}
value={draft.workday_start_hour}
disabled={disabled}
onChange={(event) =>
setDraft((current) => ({
...current,
workday_start_hour: Number(event.target.value)
}))
}
/>
</FormField>
<FormField label="Workday ends">
<input
type="number"
min={1}
max={24}
value={draft.workday_end_hour}
disabled={disabled}
onChange={(event) =>
setDraft((current) => ({
...current,
workday_end_hour: Number(event.target.value)
}))
}
/>
</FormField>
</FormGrid>
</FormGrid>
</Card>
<Card title="Continuous view">
<FormGrid columns={1} collapseAt="standard" className="">
<ToggleSwitch
label="Virtualize distant weeks"
help="Keep the continuous calendar responsive by rendering only nearby weeks."
checked={draft.continuous_virtualization}
disabled={disabled}
onChange={(continuous_virtualization) =>
setDraft((current) => ({
...current,
continuous_virtualization
}))
}
/>
<FormField
label="Overscan weeks"
help="Additional weeks rendered above and below the viewport."
>
<input
type="number"
min={2}
max={20}
value={draft.continuous_overscan_weeks}
disabled={disabled || !draft.continuous_virtualization}
onChange={(event) =>
setDraft((current) => ({
...current,
continuous_overscan_weeks: Number(event.target.value)
}))
}
/>
</FormField>
<ToggleSwitch
label="Alternate month backgrounds"
checked={draft.alternate_continuous_months}
disabled={disabled}
onChange={(alternate_continuous_months) =>
setDraft((current) => ({
...current,
alternate_continuous_months
}))
}
/>
<div className="button-row compact-actions">
<Button
type="button"
variant="primary"
disabled={Boolean(saveDisabledReason)}
disabledReason={saveDisabledReason}
onClick={() => void savePreferences()}
>
<Save size={16} /> {saving ? "Saving" : "Save preferences"}
</Button>
</div>
{message && (
<DismissibleAlert tone={tone} resetKey={message} floating>
{message}
</DismissibleAlert>
)}
</FormGrid>
</Card>
</ContentGrid>
);
}
function preferenceDraft(
preferences: CalendarViewPreferences
): CalendarPreferenceDraft {
return {
dim_weekends: preferences.dim_weekends,
dim_off_hours: preferences.dim_off_hours,
workday_start_hour: preferences.workday_start_hour,
workday_end_hour: preferences.workday_end_hour,
continuous_virtualization: preferences.continuous_virtualization,
continuous_overscan_weeks: preferences.continuous_overscan_weeks,
alternate_continuous_months: preferences.alternate_continuous_months
};
}
function errorText(error: unknown): string {
return error instanceof Error
? error.message
: "Calendar preferences could not be loaded.";
}
@@ -0,0 +1,667 @@
import {
useEffect,
useMemo,
useRef,
type DragEvent as ReactDragEvent,
} from "react";
import { i18nMessage } from "@govoplan/core-webui";
import type { CalendarEvent } from "../../api/calendar";
import {
HOUR_ROW_HEIGHT,
INITIAL_SCROLL_HOUR,
calendarEventColorStyle,
calendarEventInstanceId,
chunk,
continuousVirtualWindow,
dayKey,
dayMonthLabel,
dropSlotMinuteOfDay,
eventTimeLabel,
isWeekend,
layoutTimedEventsForDay,
minuteOfDayLabel,
monthYearLabel,
sameDay,
sameMonth,
timedEventStyle,
timedOverflowStyle,
timeGridStyle,
weekdayLong,
type CalendarDropTarget,
type CalendarMode,
type CalendarResizeEdge,
type CalendarTimeDropTarget,
type CalendarViewPreferences,
type ContinuousViewport,
} from "./calendarViewModel";
type CalendarViewCallbacks = {
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventDragEnd: () => void;
onEventDragOverDay: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnDay: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
};
type CalendarWeekRowsProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
focusDate: Date;
variant: "month" | "continuous";
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
viewport?: ContinuousViewport;
};
export function CalendarWeekRows({
days,
eventsByDay,
calendarColorById,
focusDate,
variant,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
viewport,
onEventSelect,
onEventHover,
onEventDragStart,
onEventDragEnd,
onEventDragOverDay,
onDropTargetLeave,
onEventDropOnDay,
}: CalendarWeekRowsProps) {
const weeks = chunk(days, 7);
const virtualWindow =
variant === "continuous" && preferences.continuousVirtualization
? continuousVirtualWindow(
weeks.length,
viewport ?? { scrollTop: 0, height: 0 },
preferences.continuousOverscanWeeks,
)
: {
start: 0,
end: weeks.length,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
const visibleWeeks = weeks.slice(
virtualWindow.start,
virtualWindow.end,
);
const eventLimit = variant === "month" ? 5 : 3;
return (
<div className={`calendar-week-rows is-${variant}`}>
<div className="calendar-weekday-header">
{[
"i18n:govoplan-calendar.mon.24b2a099",
"i18n:govoplan-calendar.tue.529541bb",
"i18n:govoplan-calendar.wed.23408b19",
"i18n:govoplan-calendar.thu.3593ccd9",
"i18n:govoplan-calendar.fri.bbd6e32e",
"i18n:govoplan-calendar.sat.6b782d41",
"i18n:govoplan-calendar.sun.48c98cab",
].map((label) => (
<span key={label}>{label}</span>
))}
</div>
{virtualWindow.topSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.topSpacerHeight }}
aria-hidden="true"
/>
)}
{visibleWeeks.map((week) => (
<div className="calendar-week-row" key={dayKey(week[0])}>
{week.map((day) => {
const key = dayKey(day);
const dayEvents = eventsByDay.get(key) ?? [];
const outsideFocusMonth =
variant === "month" && !sameMonth(day, focusDate);
return (
<section
key={key}
className={[
"calendar-day-cell",
outsideFocusMonth ? "is-muted" : "",
sameDay(day, new Date()) ? "is-today" : "",
dropTarget?.kind === "day" &&
dropTarget.key === key
? "is-drop-target"
: "",
variant === "continuous" &&
preferences.alternateContinuousMonths
? day.getMonth() % 2 === 0
? "is-even-month"
: "is-odd-month"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day)
: undefined
}
>
<header>
<span className="calendar-day-number">
{day.getDate()}
</span>
{day.getDate() === 1 && (
<small>{monthYearLabel(day)}</small>
)}
</header>
<div className="calendar-event-stack">
{dayEvents.slice(0, eventLimit).map((event) => (
<CalendarEventChip
key={calendarEventInstanceId(event)}
event={event}
color={calendarColorById.get(event.calendar_id)}
canDrag={canWrite}
dragging={draggingEventId === calendarEventInstanceId(event)}
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
{dayEvents.length > eventLimit && (
<span className="calendar-more-events">
+{dayEvents.length - eventLimit}
</span>
)}
</div>
</section>
);
})}
</div>
))}
{virtualWindow.bottomSpacerHeight > 0 && (
<div
className="calendar-week-spacer"
style={{ height: virtualWindow.bottomSpacerHeight }}
aria-hidden="true"
/>
)}
</div>
);
}
type CalendarTimeGridProps = CalendarViewCallbacks & {
days: Date[];
eventsByDay: Map<string, CalendarEvent[]>;
calendarColorById: Map<string, string>;
mode: CalendarMode;
preferences: CalendarViewPreferences;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarDropTarget;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
export function CalendarTimeGrid({
days,
eventsByDay,
calendarColorById,
mode,
preferences,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverDay,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnDay,
onEventDropOnTime,
}: CalendarTimeGridProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const hours = Array.from({ length: 24 }, (_item, index) => index);
const columns = `72px repeat(${days.length}, minmax(132px, 1fr))`;
const dimWeekends = preferences.dimWeekends && mode === "week";
const gridStyle = timeGridStyle(columns, preferences);
const allDayEventsByDay = days.map((day) => ({
key: dayKey(day),
date: day,
events: (eventsByDay.get(dayKey(day)) ?? []).filter(
(event) => event.all_day,
),
}));
const hasAllDayEvents = allDayEventsByDay.some(
(day) => day.events.length > 0,
);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop =
INITIAL_SCROLL_HOUR * HOUR_ROW_HEIGHT;
}
}, [days.length, days[0]?.getTime()]);
return (
<div className="calendar-time-view">
<div
className="calendar-time-header"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-time-corner" />
{days.map((day) => (
<header
key={dayKey(day)}
className={[
sameDay(day, new Date()) ? "is-today" : "",
dimWeekends && isWeekend(day) ? "is-weekend" : "",
]
.filter(Boolean)
.join(" ")}
>
<strong>{weekdayLong(day)}</strong>
<span>{dayMonthLabel(day)}</span>
</header>
))}
</div>
{hasAllDayEvents && (
<div
className="calendar-all-day-strip"
style={{ gridTemplateColumns: columns }}
>
<div className="calendar-all-day-label">
i18n:govoplan-calendar.all_day.11457433
</div>
{allDayEventsByDay.map((day) => (
<div
key={day.key}
className={[
"calendar-all-day-cell",
dimWeekends && isWeekend(day.date)
? "is-weekend"
: "",
dropTarget?.kind === "day" &&
dropTarget.key === day.key
? "is-drop-target"
: "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverDay(event, day.date)
: undefined
}
onDragLeave={
canWrite ? onDropTargetLeave : undefined
}
onDrop={
canWrite
? (event) => onEventDropOnDay(event, day.date)
: undefined
}
>
{day.events.map((event) => (
<CalendarEventChip
key={calendarEventInstanceId(event)}
event={event}
color={calendarColorById.get(event.calendar_id)}
compact
canDrag={canWrite}
dragging={draggingEventId === calendarEventInstanceId(event)}
linkedHover={hoveredEventId === calendarEventInstanceId(event)}
onSelect={onEventSelect}
onHover={onEventHover}
onDragStart={onEventDragStart}
onDragEnd={onEventDragEnd}
/>
))}
</div>
))}
</div>
)}
<div className="calendar-time-scroll" ref={scrollRef}>
<div className="calendar-time-grid" style={gridStyle}>
<div className="calendar-hour-label-column">
{hours.map((hour) => (
<div className="calendar-hour-label" key={hour}>
{String(hour).padStart(2, "0")}:00
</div>
))}
</div>
{days.map((day) => (
<TimedDayColumn
key={dayKey(day)}
day={day}
events={eventsByDay.get(dayKey(day)) ?? []}
calendarColorById={calendarColorById}
dimWeekend={dimWeekends && isWeekend(day)}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={
dropTarget?.kind === "time" &&
dropTarget.key === dayKey(day)
? dropTarget
: null
}
onEventSelect={onEventSelect}
onEventHover={onEventHover}
onEventDragStart={onEventDragStart}
onEventResizeDragStart={onEventResizeDragStart}
onEventDragEnd={onEventDragEnd}
onEventDragOverTime={onEventDragOverTime}
onDropTargetLeave={onDropTargetLeave}
onEventDropOnTime={onEventDropOnTime}
/>
))}
</div>
</div>
</div>
);
}
type TimedDayColumnProps = {
day: Date;
events: CalendarEvent[];
calendarColorById: Map<string, string>;
dimWeekend: boolean;
canWrite: boolean;
draggingEventId: string;
hoveredEventId: string;
dropTarget: CalendarTimeDropTarget | null;
onEventSelect: (event: CalendarEvent) => void;
onEventHover: (eventId: string) => void;
onEventDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onEventResizeDragStart: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
edge: CalendarResizeEdge,
) => void;
onEventDragEnd: () => void;
onEventDragOverTime: (
dragEvent: ReactDragEvent<HTMLElement>,
day: Date,
) => void;
onDropTargetLeave: (
dragEvent: ReactDragEvent<HTMLElement>,
) => void;
onEventDropOnTime: (
dropEvent: ReactDragEvent<HTMLElement>,
day: Date,
minuteOfDay: number,
) => void;
};
function TimedDayColumn({
day,
events,
calendarColorById,
dimWeekend,
canWrite,
draggingEventId,
hoveredEventId,
dropTarget,
onEventSelect,
onEventHover,
onEventDragStart,
onEventResizeDragStart,
onEventDragEnd,
onEventDragOverTime,
onDropTargetLeave,
onEventDropOnTime,
}: TimedDayColumnProps) {
const layout = useMemo(
() => layoutTimedEventsForDay(events, day),
[day, events],
);
return (
<div
className={[
"calendar-day-time-column",
dimWeekend ? "is-weekend" : "",
dropTarget ? "is-drop-target" : "",
]
.filter(Boolean)
.join(" ")}
onDragOver={
canWrite
? (event) => onEventDragOverTime(event, day)
: undefined
}
onDragLeave={canWrite ? onDropTargetLeave : undefined}
onDrop={
canWrite
? (event) =>
onEventDropOnTime(event, day, dropSlotMinuteOfDay(event))
: undefined
}
>
{dropTarget && (
<div
className="calendar-time-drop-marker"
style={{
top: `${
(dropTarget.minuteOfDay / 60) * HOUR_ROW_HEIGHT
}px`,
}}
aria-hidden="true"
>
<span>{minuteOfDayLabel(dropTarget.minuteOfDay)}</span>
</div>
)}
{layout.items.map((item) => (
<div
key={calendarEventInstanceId(item.event)}
className={[
"calendar-timed-event",
draggingEventId === calendarEventInstanceId(item.event) ? "is-dragging" : "",
hoveredEventId === calendarEventInstanceId(item.event)
? "is-linked-hover"
: "",
]
.filter(Boolean)
.join(" ")}
style={timedEventStyle(
item,
calendarColorById.get(item.event.calendar_id),
)}
draggable={canWrite}
onMouseEnter={() => onEventHover(calendarEventInstanceId(item.event))}
onMouseLeave={() => onEventHover("")}
onDragStart={
canWrite
? (event) => onEventDragStart(event, item.event)
: undefined
}
onDragEnd={canWrite ? onEventDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
{canWrite && (
<span
className="calendar-event-resize-handle is-start"
draggable
title="i18n:govoplan-calendar.change_start_time.06eea3d3"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "start")
}
onDragEnd={onEventDragEnd}
/>
)}
<button
type="button"
className="calendar-timed-event-content"
onClick={() => onEventSelect(item.event)}
onFocus={() => onEventHover(calendarEventInstanceId(item.event))}
onBlur={() => onEventHover("")}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: item.event.summary },
)}
>
<EventInlineLabel event={item.event} />
</button>
{canWrite && (
<span
className="calendar-event-resize-handle is-end"
draggable
title="i18n:govoplan-calendar.change_end_time.db66ef4b"
onDragStart={(event) =>
onEventResizeDragStart(event, item.event, "end")
}
onDragEnd={onEventDragEnd}
/>
)}
</div>
))}
{layout.overflows.map((overflow) => (
<button
key={overflow.key}
type="button"
className="calendar-timed-overflow"
style={timedOverflowStyle(overflow)}
title={overflow.events
.map((event) => event.summary)
.join(", ")}
onClick={() => onEventSelect(overflow.events[0])}
>
+{overflow.events.length}
</button>
))}
</div>
);
}
type CalendarEventChipProps = {
event: CalendarEvent;
color?: string | null;
compact?: boolean;
canDrag?: boolean;
dragging?: boolean;
linkedHover?: boolean;
onSelect: (event: CalendarEvent) => void;
onHover?: (eventId: string) => void;
onDragStart?: (
dragEvent: ReactDragEvent<HTMLElement>,
event: CalendarEvent,
) => void;
onDragEnd?: () => void;
};
function CalendarEventChip({
event,
color,
compact = false,
canDrag = false,
dragging = false,
linkedHover = false,
onSelect,
onHover,
onDragStart,
onDragEnd,
}: CalendarEventChipProps) {
return (
<button
type="button"
className={[
compact
? "calendar-event-chip is-compact"
: "calendar-event-chip",
dragging ? "is-dragging" : "",
linkedHover ? "is-linked-hover" : "",
]
.filter(Boolean)
.join(" ")}
style={calendarEventColorStyle(color)}
draggable={canDrag}
onClick={() => onSelect(event)}
onMouseEnter={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
onMouseLeave={onHover ? () => onHover("") : undefined}
onFocus={onHover ? () => onHover(calendarEventInstanceId(event)) : undefined}
onBlur={onHover ? () => onHover("") : undefined}
onDragStart={
canDrag && onDragStart
? (dragEvent) => onDragStart(dragEvent, event)
: undefined
}
onDragEnd={canDrag ? onDragEnd : undefined}
title={i18nMessage(
"i18n:govoplan-calendar.edit_value.fad75899",
{ value0: event.summary },
)}
>
<EventInlineLabel event={event} />
</button>
);
}
export function EventInlineLabel({
event,
}: {
event: CalendarEvent;
}) {
return (
<span className="calendar-event-line">
<span className="calendar-event-time">
{eventTimeLabel(event)}
</span>
<span className="calendar-event-separator">-</span>
<strong>{event.summary}</strong>
</span>
);
}
@@ -0,0 +1,111 @@
import { useCallback } from "react";
import { CalendarDays, MapPin } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listCalendarEvents,
type CalendarEvent
} from "../../api/calendar";
export default function UpcomingEventsWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const daysAhead = numberSetting(configuration.daysAhead, 14, 1, 90);
const showLocation = configuration.showLocation !== false;
const load = useCallback(async () => {
const start = new Date();
const end = new Date(start);
end.setDate(end.getDate() + daysAhead);
const response = await listCalendarEvents(settings, {
start_at: start.toISOString(),
end_at: end.toISOString(),
expand_recurring: true
});
return [...response.events]
.filter((event) => event.status.toUpperCase() !== "CANCELLED")
.sort(
(left, right) =>
new Date(left.start_at).getTime() - new Date(right.start_at).getTime()
)
.slice(0, maxItems);
}, [daysAhead, maxItems, settings]);
const { data: events, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading upcoming calendar events">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText={`No events in the next ${daysAhead} days.`}
items={(events ?? []).map((event) => ({
id: event.instance_id || event.id,
title: event.summary,
detail:
showLocation && event.location ? (
<>
<MapPin size={12} aria-hidden="true" /> {event.location}
</>
) : undefined,
meta: eventTimeLabel(event),
leading: <CalendarDays size={17} aria-hidden="true" />,
to: "/calendar"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/calendar">
Open calendar
</Link>
</div>
</LoadingFrame>
);
}
function eventTimeLabel(event: CalendarEvent): string {
const start = new Date(event.start_at);
if (event.all_day) {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short"
}).format(start);
}
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(start);
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}
@@ -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,932 @@
import type {
CSSProperties,
DragEvent as ReactDragEvent,
} from "react";
import type {
CalendarEvent,
CalendarEventDeltaResponse,
} from "../../api/calendar";
export type CalendarMode =
| "continuous"
| "month"
| "week"
| "workweek"
| "day";
export type Range = { start: Date; end: Date };
export type AgendaGroup = {
key: string;
date: Date;
events: CalendarEvent[];
};
export type ContinuousViewport = { scrollTop: number; height: number };
export type CalendarDayDropTarget = { kind: "day"; key: string };
export type CalendarTimeDropTarget = {
kind: "time";
key: string;
minuteOfDay: number;
};
export type CalendarDropTarget =
| CalendarDayDropTarget
| CalendarTimeDropTarget
| null;
export type CalendarDragAction =
| { kind: "move"; event: CalendarEvent }
| { kind: "resize-start"; event: CalendarEvent }
| { kind: "resize-end"; event: CalendarEvent };
export type CalendarResizeEdge = "start" | "end";
export type CalendarViewPreferences = {
dimWeekends: boolean;
dimOffHours: boolean;
workdayStartHour: number;
workdayEndHour: number;
continuousVirtualization: boolean;
continuousOverscanWeeks: number;
alternateContinuousMonths: boolean;
};
export type TimedEventLayoutItem = TimedEventSegment & {
column: number;
columns: number;
};
export type TimedOverflowLayoutItem = {
key: string;
top: number;
column: number;
columns: number;
events: CalendarEvent[];
};
type TimedEventSegment = {
event: CalendarEvent;
start: Date;
end: Date;
top: number;
height: number;
};
export const HOUR_ROW_HEIGHT = 64;
export const INITIAL_SCROLL_HOUR = 7;
export const CONTINUOUS_WEEK_ROW_HEIGHT = 92;
export const DEFAULT_CALENDAR_COLOR = "#5aa99b";
const MAX_TIMED_EVENT_COLUMNS = 3;
const CALENDAR_MODE_STORAGE_KEY = "govoplan.calendar.mode";
const CALENDAR_VIEW_PREFERENCES_STORAGE_KEY =
"govoplan.calendar.viewPreferences";
export const DEFAULT_CALENDAR_VIEW_PREFERENCES: CalendarViewPreferences = {
dimWeekends: true,
dimOffHours: true,
workdayStartHour: 6,
workdayEndHour: 20,
continuousVirtualization: true,
continuousOverscanWeeks: 6,
alternateContinuousMonths: true,
};
export function calendarEventInstanceId(event: CalendarEvent): string {
return event.instance_id || event.id;
}
export function rangeForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Range {
if (mode === "month") {
const start = startOfWeek(startOfMonth(focusDate));
return { start, end: addDays(start, 42) };
}
if (mode === "day") {
return {
start: startOfDay(focusDate),
end: addDays(startOfDay(focusDate), 1),
};
}
if (mode === "continuous") {
const start = addDays(
startOfWeek(focusDate),
-continuousWeeks.before * 7,
);
const end = addDays(
startOfWeek(focusDate),
continuousWeeks.after * 7,
);
return { start, end };
}
const start = startOfWeek(focusDate);
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
}
export function daysForMode(
mode: CalendarMode,
focusDate: Date,
continuousWeeks: { before: number; after: number },
): Date[] {
const range = rangeForMode(mode, focusDate, continuousWeeks);
return daysBetween(range.start, range.end);
}
export function continuousEventWindow(
days: Date[],
viewport: ContinuousViewport,
options: {overscanWeeks?: number;minimumWeeks?: number;stepWeeks?: number;} = {},
): Range {
if (days.length === 0) {
const now = startOfDay(new Date());
return { start: now, end: addDays(now, 7) };
}
const totalWeeks = Math.max(1, Math.ceil(days.length / 7));
const overscanWeeks = Math.max(0, Math.floor(options.overscanWeeks ?? 2));
const minimumWeeks = Math.max(1, Math.floor(options.minimumWeeks ?? 10));
const stepWeeks = Math.max(1, Math.floor(options.stepWeeks ?? 4));
const firstVisibleWeek = Math.max(
0,
Math.min(totalWeeks - 1, Math.floor(viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT)),
);
const visibleWeeks = Math.max(
1,
Math.ceil(Math.max(viewport.height, CONTINUOUS_WEEK_ROW_HEIGHT) / CONTINUOUS_WEEK_ROW_HEIGHT),
);
const desiredWeeks = Math.min(
totalWeeks,
Math.max(minimumWeeks, visibleWeeks + overscanWeeks * 2),
);
let startWeek = Math.max(
0,
Math.floor(Math.max(0, firstVisibleWeek - overscanWeeks) / stepWeeks) * stepWeeks,
);
if (startWeek + desiredWeeks > totalWeeks) {
startWeek = Math.max(0, totalWeeks - desiredWeeks);
}
const endWeek = Math.min(totalWeeks, startWeek + desiredWeeks);
return {
start: days[startWeek * 7],
end: addDays(days[Math.min(days.length - 1, endWeek * 7 - 1)], 1),
};
}
export function groupEventsByDay(
events: CalendarEvent[],
): Map<string, CalendarEvent[]> {
const grouped = new Map<string, CalendarEvent[]>();
for (const event of events) {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : start;
let cursor = startOfDay(start);
let last =
event.all_day && event.end_at
? addDays(startOfDay(end), -1)
: startOfDay(end);
if (last < cursor) last = cursor;
while (cursor <= last) {
const key = dayKey(cursor);
grouped.set(key, [...(grouped.get(key) ?? []), event]);
cursor = addDays(cursor, 1);
}
}
for (const [key, items] of grouped.entries()) {
grouped.set(
key,
items.sort(
(left, right) =>
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary),
),
);
}
return grouped;
}
export function agendaGroupsForDays(
days: Date[],
eventsByDay: Map<string, CalendarEvent[]>,
): AgendaGroup[] {
const groups: AgendaGroup[] = [];
for (const day of days) {
const key = dayKey(day);
const events = (eventsByDay.get(key) ?? [])
.slice()
.sort(compareAgendaEvents);
if (events.length > 0) groups.push({ key, date: day, events });
}
return groups;
}
export function eventTimeLabel(event: CalendarEvent): string {
if (event.all_day) return "i18n:govoplan-calendar.all_day.11457433";
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
}
export function dateTimeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(value);
}
export function headingForMode(
mode: CalendarMode,
focusDate: Date,
range: Range,
): string {
if (mode === "day") return dayMonthYearLabel(focusDate);
if (mode === "month") return monthYearLabel(focusDate);
return `${dayMonthYearLabel(range.start)} - ${dayMonthYearLabel(
addDays(range.end, -1),
)}`;
}
export function chunk<T>(items: T[], size: number): T[][] {
const rows: T[][] = [];
for (let index = 0; index < items.length; index += size) {
rows.push(items.slice(index, index + size));
}
return rows;
}
export function startOfDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
export function startOfWeek(value: Date): Date {
const day = value.getDay() || 7;
return addDays(startOfDay(value), 1 - day);
}
export function startOfMonth(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), 1);
}
export function addDays(value: Date, days: number): Date {
const next = new Date(value);
next.setDate(next.getDate() + days);
return next;
}
export function addMonths(value: Date, months: number): Date {
const next = new Date(value);
next.setMonth(next.getMonth() + months);
return next;
}
export function addHours(value: Date, hours: number): Date {
const next = new Date(value);
next.setHours(next.getHours() + hours);
return next;
}
export function addMinutes(value: Date, minutes: number): Date {
const next = new Date(value);
next.setMinutes(next.getMinutes() + minutes);
return next;
}
export function daysBetween(start: Date, end: Date): Date[] {
const days: Date[] = [];
for (
let cursor = startOfDay(start);
cursor < end;
cursor = addDays(cursor, 1)
) {
days.push(cursor);
}
return days;
}
export function dayKey(value: Date): string {
return toInputDate(value);
}
export function sameDay(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
export function sameMonth(left: Date, right: Date): boolean {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth()
);
}
export function toInputDate(value: Date): string {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(
2,
"0",
)}-${String(value.getDate()).padStart(2, "0")}`;
}
export function toInputTime(value: Date): string {
return `${String(value.getHours()).padStart(2, "0")}:${String(
value.getMinutes(),
).padStart(2, "0")}`;
}
export function localDateTime(date: string, time: string): Date {
return new Date(`${date}T${time || "00:00"}:00`);
}
export function loadCalendarViewPreferences(): CalendarViewPreferences {
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW_PREFERENCES;
try {
const raw = window.localStorage.getItem(
CALENDAR_VIEW_PREFERENCES_STORAGE_KEY,
);
if (!raw) return DEFAULT_CALENDAR_VIEW_PREFERENCES;
const parsed = JSON.parse(raw) as Partial<CalendarViewPreferences>;
let workdayStartHour = clampNumber(
parsed.workdayStartHour,
0,
23,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour,
);
let workdayEndHour = clampNumber(
parsed.workdayEndHour,
1,
24,
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour,
);
if (workdayEndHour <= workdayStartHour) {
workdayStartHour =
DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayStartHour;
workdayEndHour = DEFAULT_CALENDAR_VIEW_PREFERENCES.workdayEndHour;
}
return {
dimWeekends:
typeof parsed.dimWeekends === "boolean"
? parsed.dimWeekends
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimWeekends,
dimOffHours:
typeof parsed.dimOffHours === "boolean"
? parsed.dimOffHours
: DEFAULT_CALENDAR_VIEW_PREFERENCES.dimOffHours,
workdayStartHour,
workdayEndHour,
continuousVirtualization:
typeof parsed.continuousVirtualization === "boolean"
? parsed.continuousVirtualization
: DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousVirtualization,
continuousOverscanWeeks: clampNumber(
parsed.continuousOverscanWeeks,
2,
20,
DEFAULT_CALENDAR_VIEW_PREFERENCES.continuousOverscanWeeks,
),
alternateContinuousMonths:
typeof parsed.alternateContinuousMonths === "boolean"
? parsed.alternateContinuousMonths
: DEFAULT_CALENDAR_VIEW_PREFERENCES.alternateContinuousMonths,
};
} catch {
return DEFAULT_CALENDAR_VIEW_PREFERENCES;
}
}
export function loadCalendarMode(): CalendarMode {
if (typeof window === "undefined") return "continuous";
try {
const storedMode = window.localStorage.getItem(
CALENDAR_MODE_STORAGE_KEY,
);
return isCalendarMode(storedMode) ? storedMode : "continuous";
} catch {
return "continuous";
}
}
export function saveCalendarMode(mode: CalendarMode): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(CALENDAR_MODE_STORAGE_KEY, mode);
} catch {
// Storage can be unavailable in locked-down browsers.
}
}
export function normalizeHexColor(
value: string | null | undefined,
): string | null {
if (!value) return null;
const trimmed = value.trim();
if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) {
return trimmed.slice(0, 7).toLowerCase();
}
return /^#[0-9a-fA-F]{6}$/.test(trimmed)
? trimmed.toLowerCase()
: null;
}
export function calendarEventColorStyle(
color: string | null | undefined,
): CSSProperties {
const normalizedColor =
normalizeHexColor(color) || DEFAULT_CALENDAR_COLOR;
return {
"--calendar-event-color": normalizedColor,
"--calendar-event-bg": mixHexWithWhite(normalizedColor, 0.88),
"--calendar-event-border": mixHexWithWhite(normalizedColor, 0.58),
} as CSSProperties;
}
export function continuousVirtualWindow(
weekCount: number,
viewport: ContinuousViewport,
overscanWeeks: number,
): {
start: number;
end: number;
topSpacerHeight: number;
bottomSpacerHeight: number;
} {
if (weekCount === 0) {
return {
start: 0,
end: 0,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
};
}
const visibleStart = Math.floor(
viewport.scrollTop / CONTINUOUS_WEEK_ROW_HEIGHT,
);
const visibleCount = Math.max(
1,
Math.ceil(
(viewport.height || CONTINUOUS_WEEK_ROW_HEIGHT * 8) /
CONTINUOUS_WEEK_ROW_HEIGHT,
),
);
const start = Math.max(0, visibleStart - overscanWeeks);
const end = Math.min(
weekCount,
visibleStart + visibleCount + overscanWeeks,
);
return {
start,
end,
topSpacerHeight: start * CONTINUOUS_WEEK_ROW_HEIGHT,
bottomSpacerHeight: Math.max(
0,
(weekCount - end) * CONTINUOUS_WEEK_ROW_HEIGHT,
),
};
}
export function timeGridStyle(
columns: string,
preferences: CalendarViewPreferences,
): CSSProperties {
return {
gridTemplateColumns: columns,
"--calendar-work-start-y": `${
preferences.workdayStartHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-work-end-y": `${
preferences.workdayEndHour * HOUR_ROW_HEIGHT
}px`,
"--calendar-off-hours-opacity": preferences.dimOffHours ? "1" : "0",
} as CSSProperties;
}
export function moveEventToDay(
event: CalendarEvent,
day: Date,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const start = new Date(event.start_at);
const end = event.end_at ? new Date(event.end_at) : null;
if (event.all_day) {
const durationDays = Math.max(
1,
Math.round(
daysBetweenCount(
startOfDay(start),
end ? startOfDay(end) : addDays(startOfDay(start), 1),
),
),
);
const startAt = startOfDay(day);
return {
startAt,
endAt: addDays(startAt, durationDays),
allDay: true,
};
}
const startAt = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
start.getHours(),
start.getMinutes(),
);
const durationMs = eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function moveEventToTime(
event: CalendarEvent,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const snappedMinute = Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(minuteOfDay)),
);
const startAt = dateAtMinuteOfDay(day, snappedMinute);
const durationMs = event.all_day
? 60 * 60 * 1000
: eventDurationMs(event, 60 * 60 * 1000);
return {
startAt,
endAt: new Date(startAt.getTime() + durationMs),
allDay: false,
};
}
export function resizeEventToTime(
event: CalendarEvent,
edge: CalendarResizeEdge,
day: Date,
minuteOfDay: number,
): { startAt: Date; endAt: Date | null; allDay: boolean } {
const target = dateAtMinuteOfDay(
day,
Math.min(23 * 60 + 45, Math.max(0, snapMinute(minuteOfDay))),
);
const currentStart = new Date(event.start_at);
const fallbackEnd = addMinutes(currentStart, 30);
const currentEnd =
event.end_at && new Date(event.end_at) > currentStart
? new Date(event.end_at)
: fallbackEnd;
const minimumDurationMs = 15 * 60_000;
if (edge === "start") {
const latestStart = new Date(
currentEnd.getTime() - minimumDurationMs,
);
return {
startAt: target < latestStart ? target : latestStart,
endAt: currentEnd,
allDay: false,
};
}
const earliestEnd = new Date(
currentStart.getTime() + minimumDurationMs,
);
return {
startAt: currentStart,
endAt: target > earliestEnd ? target : earliestEnd,
allDay: false,
};
}
export function dropMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
const rect = event.currentTarget.getBoundingClientRect();
const offset = Math.min(
Math.max(event.clientY - rect.top, 0),
HOUR_ROW_HEIGHT * 24,
);
return Math.floor((offset / HOUR_ROW_HEIGHT) * 60);
}
export function dropSlotMinuteOfDay(
event: ReactDragEvent<HTMLElement>,
): number {
return Math.min(
23 * 60 + 45,
Math.max(0, snapMinute(dropMinuteOfDay(event))),
);
}
export function minuteOfDayLabel(value: number): string {
const minute = Math.min(23 * 60 + 45, Math.max(0, value));
return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(
minute % 60,
).padStart(2, "0")}`;
}
export function isWeekend(value: Date): boolean {
const day = value.getDay();
return day === 0 || day === 6;
}
export function daysBetweenCount(start: Date, end: Date): number {
return Math.round(
(startOfDay(end).getTime() - startOfDay(start).getTime()) / 86_400_000,
);
}
export function addMinutesToTime(
time: string,
minutes: number,
): string {
const [hourPart, minutePart] = time.split(":");
const total = Math.min(
23 * 60 + 59,
Math.max(
0,
Number(hourPart || 0) * 60 + Number(minutePart || 0) + minutes,
),
);
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
total % 60,
).padStart(2, "0")}`;
}
export function layoutTimedEventsForDay(
events: CalendarEvent[],
day: Date,
): {
items: TimedEventLayoutItem[];
overflows: TimedOverflowLayoutItem[];
} {
const segments = timedSegmentsForDay(events, day);
const clusters = clusterTimedSegments(segments);
const items: TimedEventLayoutItem[] = [];
const overflows: TimedOverflowLayoutItem[] = [];
for (const cluster of clusters) {
const columnEnds: Date[] = [];
const assigned = cluster.map((segment) => {
let column = columnEnds.findIndex((end) => end <= segment.start);
if (column === -1) column = columnEnds.length;
columnEnds[column] = segment.end;
return { ...segment, column };
});
const totalColumns = Math.max(1, columnEnds.length);
const visibleColumns =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? MAX_TIMED_EVENT_COLUMNS
: totalColumns;
const overflowColumn = MAX_TIMED_EVENT_COLUMNS - 1;
const hidden =
totalColumns > MAX_TIMED_EVENT_COLUMNS
? assigned.filter((item) => item.column >= overflowColumn)
: [];
for (const item of assigned) {
if (hidden.includes(item)) continue;
items.push({ ...item, columns: visibleColumns });
}
if (hidden.length > 0) {
overflows.push({
key: `${dayKey(day)}-${calendarEventInstanceId(hidden[0].event)}-overflow`,
top: Math.min(...hidden.map((item) => item.top)),
column: overflowColumn,
columns: MAX_TIMED_EVENT_COLUMNS,
events: hidden.map((item) => item.event),
});
}
}
return { items, overflows };
}
export function timedEventStyle(
item: TimedEventLayoutItem,
color?: string | null,
): CSSProperties {
return {
top: `${item.top}px`,
height: `${item.height}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
...calendarEventColorStyle(color),
};
}
export function timedOverflowStyle(
item: TimedOverflowLayoutItem,
): CSSProperties {
return {
top: `${item.top}px`,
left: `calc(${(item.column * 100) / item.columns}% + 4px)`,
width: `calc(${100 / item.columns}% - 8px)`,
};
}
export function monthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(value);
}
export function dayMonthLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
}).format(value);
}
export function dayMonthYearLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function agendaDateLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(value);
}
export function weekdayLong(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
}).format(value);
}
export function calendarDraftKey(value: unknown): string {
return JSON.stringify(value);
}
export function mergeCalendarEventDelta(
current: CalendarEvent[],
response: CalendarEventDeltaResponse,
): CalendarEvent[] {
if (response.full) {
return response.events.slice().sort(compareCalendarEvents);
}
const rows = new Map(current.map((event) => [event.id, event]));
for (const deleted of response.deleted) {
if (
!deleted.resource_type ||
deleted.resource_type === "calendar_event"
) {
rows.delete(deleted.id);
}
}
for (const event of response.events) rows.set(event.id, event);
return Array.from(rows.values()).sort(compareCalendarEvents);
}
export function errorText(error: unknown): string {
if (error instanceof Error) return error.message;
return "i18n:govoplan-calendar.calendar_request_failed.ae8a54f4";
}
function isCalendarMode(value: unknown): value is CalendarMode {
return (
value === "continuous" ||
value === "month" ||
value === "week" ||
value === "workweek" ||
value === "day"
);
}
function mixHexWithWhite(hex: string, whiteRatio: number): string {
const normalized = normalizeHexColor(hex) || DEFAULT_CALENDAR_COLOR;
const ratio = Math.min(1, Math.max(0, whiteRatio));
const red = Number.parseInt(normalized.slice(1, 3), 16);
const green = Number.parseInt(normalized.slice(3, 5), 16);
const blue = Number.parseInt(normalized.slice(5, 7), 16);
return `rgb(${Math.round(red * (1 - ratio) + 255 * ratio)}, ${Math.round(
green * (1 - ratio) + 255 * ratio,
)}, ${Math.round(blue * (1 - ratio) + 255 * ratio)})`;
}
function eventDurationMs(event: CalendarEvent, fallback: number): number {
if (!event.end_at) return fallback;
const start = new Date(event.start_at);
const end = new Date(event.end_at);
return Math.max(30 * 60 * 1000, end.getTime() - start.getTime());
}
function dateAtMinuteOfDay(day: Date, minuteOfDay: number): Date {
return new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
Math.floor(minuteOfDay / 60),
minuteOfDay % 60,
);
}
function snapMinute(value: number): number {
return Math.round(value / 15) * 15;
}
function clampNumber(
value: unknown,
min: number,
max: number,
fallback: number,
): number {
if (typeof value !== "number" || Number.isNaN(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
function timedSegmentsForDay(
events: CalendarEvent[],
day: Date,
): TimedEventSegment[] {
const dayStart = startOfDay(day);
const dayEnd = addDays(dayStart, 1);
return events
.filter((event) => !event.all_day)
.map((event): TimedEventSegment | null => {
const eventStart = new Date(event.start_at);
const rawEventEnd = event.end_at
? new Date(event.end_at)
: addMinutes(eventStart, 30);
const eventEnd =
rawEventEnd > eventStart ? rawEventEnd : addMinutes(eventStart, 30);
const start = eventStart < dayStart ? dayStart : eventStart;
const end = eventEnd > dayEnd ? dayEnd : eventEnd;
if (end <= start) return null;
const startMinutes = minutesBetween(dayStart, start);
const durationMinutes = minutesBetween(start, end);
return {
event,
start,
end,
top: Math.max(0, (startMinutes / 60) * HOUR_ROW_HEIGHT),
height: Math.max(
26,
(durationMinutes / 60) * HOUR_ROW_HEIGHT,
),
};
})
.filter(
(segment): segment is TimedEventSegment => segment !== null,
)
.sort(
(left, right) =>
left.start.getTime() - right.start.getTime() ||
right.end.getTime() - left.end.getTime(),
);
}
function clusterTimedSegments(
segments: TimedEventSegment[],
): TimedEventSegment[][] {
const clusters: TimedEventSegment[][] = [];
let current: TimedEventSegment[] = [];
let currentEnd: Date | null = null;
for (const segment of segments) {
if (!current.length || (currentEnd && segment.start < currentEnd)) {
current.push(segment);
if (!currentEnd || segment.end.getTime() > currentEnd.getTime()) {
currentEnd = segment.end;
}
continue;
}
clusters.push(current);
current = [segment];
currentEnd = segment.end;
}
if (current.length) clusters.push(current);
return clusters;
}
function minutesBetween(start: Date, end: Date): number {
return Math.max(0, (end.getTime() - start.getTime()) / 60_000);
}
function compareAgendaEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
if (left.all_day !== right.all_day) return left.all_day ? -1 : 1;
return (
left.start_at.localeCompare(right.start_at) ||
left.summary.localeCompare(right.summary)
);
}
function timeLabel(value: Date): string {
return new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
minute: "2-digit",
}).format(value);
}
function compareCalendarEvents(
left: CalendarEvent,
right: CalendarEvent,
): number {
return (
new Date(left.start_at).getTime() -
new Date(right.start_at).getTime() ||
left.summary.localeCompare(right.summary) ||
left.id.localeCompare(right.id)
);
}
@@ -0,0 +1,33 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const CALENDAR_DOCUMENTATION = {
topicId: "calendar.manage-calendars-and-events",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const CALENDAR_SOURCE_DOCUMENTATION = {
topicId: "calendar.external-sources-and-sync",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const CALENDAR_RECOVERY_DOCUMENTATION = {
topicId: "calendar.outbound-change-recovery",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const CALENDAR_I18N = {
loading: "i18n:govoplan-calendar.reason.loading",
saving: "i18n:govoplan-calendar.reason.saving",
syncing: "i18n:govoplan-calendar.reason.syncing",
readOnly: "i18n:govoplan-calendar.reason.read_only",
calendarWriteRequired: "i18n:govoplan-calendar.reason.calendar_write_required",
calendarAdminRequired: "i18n:govoplan-calendar.reason.calendar_admin_required",
syncPermissionRequired: "i18n:govoplan-calendar.reason.sync_permission_required",
migrationLocked: "i18n:govoplan-calendar.reason.migration_locked",
targetCalendarRequired: "i18n:govoplan-calendar.reason.target_calendar_required",
eventTitleRequired: "i18n:govoplan-calendar.reason.event_title_required",
calendarNameRequired: "i18n:govoplan-calendar.reason.calendar_name_required",
sourceDetailsRequired: "i18n:govoplan-calendar.reason.source_details_required",
noChanges: "i18n:govoplan-calendar.reason.no_changes",
cancellationEvidenceRequired: "i18n:govoplan-calendar.reason.cancellation_evidence_required"
} as const;
+490
View File
@@ -0,0 +1,490 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-calendar.surface.navigation": "Calendar navigation",
"i18n:govoplan-calendar.surface.page": "Calendar workspace",
"i18n:govoplan-calendar.surface.sidebar": "Calendar list",
"i18n:govoplan-calendar.surface.agenda": "Agenda",
"i18n:govoplan-calendar.surface.workspace": "Calendar view",
"i18n:govoplan-calendar.surface.event_editor": "Event editor",
"i18n:govoplan-calendar.surface.collection_editor": "Calendar source editor",
"i18n:govoplan-calendar.surface.sync_status": "Synchronization status",
"i18n:govoplan-calendar.surface.outbox": "Outbound changes",
"i18n:govoplan-calendar.surface.migration": "Remote calendar move",
"i18n:govoplan-calendar.reason.loading": "Calendar data is loading.",
"i18n:govoplan-calendar.reason.saving": "A calendar change is already being saved.",
"i18n:govoplan-calendar.reason.syncing": "This calendar is already synchronizing.",
"i18n:govoplan-calendar.reason.read_only": "You can view this calendar, but your account cannot change events.",
"i18n:govoplan-calendar.reason.calendar_write_required": "Calendar write permission is required.",
"i18n:govoplan-calendar.reason.calendar_admin_required": "Calendar administration permission is required.",
"i18n:govoplan-calendar.reason.sync_permission_required": "Calendar import permission is required to synchronize this source.",
"i18n:govoplan-calendar.reason.migration_locked": "Calendar changes are locked while the remote move is active or unresolved.",
"i18n:govoplan-calendar.reason.target_calendar_required": "Create or select a calendar before adding an event.",
"i18n:govoplan-calendar.reason.event_title_required": "Enter an event title before saving.",
"i18n:govoplan-calendar.reason.calendar_name_required": "Enter a calendar name before saving.",
"i18n:govoplan-calendar.reason.source_details_required": "Complete the required source URL and credential fields before saving.",
"i18n:govoplan-calendar.reason.no_changes": "There are no unsaved changes.",
"i18n:govoplan-calendar.reason.cancellation_evidence_required": "Record at least 10 characters of cancellation evidence.",
"i18n:govoplan-calendar.delete_event_title": "Delete event?",
"i18n:govoplan-calendar.delete_event_occurrence_message": "Delete the selected occurrence of {value0}? A remote deletion is queued when the calendar is synchronized.",
"i18n:govoplan-calendar.delete_event_series_message": "Delete the whole series {value0}? A remote deletion is queued when the calendar is synchronized.",
"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.quick_access_description": "Upcoming events across visible calendars.",
"i18n:govoplan-calendar.quick_access_range": "Agenda range",
"i18n:govoplan-calendar.quick_access_event_details": "Event details",
"i18n:govoplan-calendar.quick_access_select_event": "Select event",
"i18n:govoplan-calendar.quick_access_open_calendar": "Open in 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.connector_profile_reference.e7697602": "Connector profile reference",
"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_group_mapping_reference.40c3da81": "Identity/group mapping reference",
"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.open_xchange_dav_url.9d1adeaf": "Open-Xchange DAV URL",
"i18n:govoplan-calendar.open_xchange_source.4cc03862": "Open-Xchange source",
"i18n:govoplan-calendar.open_xchange.637e5b7b": "Open-Xchange",
"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.resource_calendar_reference.4df67054": "Resource calendar reference",
"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.all_history.8829c44e": "All history",
"i18n:govoplan-calendar.attempts.448fa12e": "Attempts",
"i18n:govoplan-calendar.available.17bc146b": "Available",
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Conflicts / dead",
"i18n:govoplan-calendar.discard.23a76911": "Discard",
"i18n:govoplan-calendar.discard_change.85474ec4": "Discard change",
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Discard local desired state?",
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Discarding cancels this local outbound change and its unresolved predecessors. The next full sync accepts the remote state, so local changes may be lost.",
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Loading outbound changes...",
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "No outbound changes match this filter.",
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Only the 100 most recent outbound changes are shown.",
"i18n:govoplan-calendar.outbound_changes.7038a839": "Outbound changes",
"i18n:govoplan-calendar.outbox_filter.8305af40": "Outbound change filter",
"i18n:govoplan-calendar.reconcile.6c595f64": "Reconcile",
"i18n:govoplan-calendar.retry.3fda8f1c": "Retry",
"i18n:govoplan-calendar.shown.498e85a1": "Shown",
"i18n:govoplan-calendar.unresolved.11c41de4": "Unresolved",
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
},
"de": {
"i18n:govoplan-calendar.surface.navigation": "Kalendernavigation",
"i18n:govoplan-calendar.surface.page": "Kalenderarbeitsbereich",
"i18n:govoplan-calendar.surface.sidebar": "Kalenderliste",
"i18n:govoplan-calendar.surface.agenda": "Agenda",
"i18n:govoplan-calendar.surface.workspace": "Kalenderansicht",
"i18n:govoplan-calendar.surface.event_editor": "Termineditor",
"i18n:govoplan-calendar.surface.collection_editor": "Kalenderquellen bearbeiten",
"i18n:govoplan-calendar.surface.sync_status": "Synchronisierungsstatus",
"i18n:govoplan-calendar.surface.outbox": "Ausgehende Änderungen",
"i18n:govoplan-calendar.surface.migration": "Entfernten Kalender verschieben",
"i18n:govoplan-calendar.reason.loading": "Kalenderdaten werden geladen.",
"i18n:govoplan-calendar.reason.saving": "Eine Kalenderänderung wird bereits gespeichert.",
"i18n:govoplan-calendar.reason.syncing": "Dieser Kalender wird bereits synchronisiert.",
"i18n:govoplan-calendar.reason.read_only": "Sie können diesen Kalender anzeigen, aber Ihr Konto darf Termine nicht ändern.",
"i18n:govoplan-calendar.reason.calendar_write_required": "Die Berechtigung zum Bearbeiten von Kalendern ist erforderlich.",
"i18n:govoplan-calendar.reason.calendar_admin_required": "Die Berechtigung zur Kalenderverwaltung ist erforderlich.",
"i18n:govoplan-calendar.reason.sync_permission_required": "Zum Synchronisieren dieser Quelle ist die Kalender-Importberechtigung erforderlich.",
"i18n:govoplan-calendar.reason.migration_locked": "Kalenderänderungen sind gesperrt, solange die entfernte Verschiebung aktiv oder ungeklärt ist.",
"i18n:govoplan-calendar.reason.target_calendar_required": "Erstellen oder wählen Sie einen Kalender, bevor Sie einen Termin hinzufügen.",
"i18n:govoplan-calendar.reason.event_title_required": "Geben Sie vor dem Speichern einen Termintitel ein.",
"i18n:govoplan-calendar.reason.calendar_name_required": "Geben Sie vor dem Speichern einen Kalendernamen ein.",
"i18n:govoplan-calendar.reason.source_details_required": "Vervollständigen Sie vor dem Speichern die erforderlichen Quellen- und Zugangsdaten.",
"i18n:govoplan-calendar.reason.no_changes": "Es gibt keine ungespeicherten Änderungen.",
"i18n:govoplan-calendar.reason.cancellation_evidence_required": "Dokumentieren Sie die Abbruchbegründung mit mindestens 10 Zeichen.",
"i18n:govoplan-calendar.delete_event_title": "Termin löschen?",
"i18n:govoplan-calendar.delete_event_occurrence_message": "Das ausgewählte Vorkommen von {value0} löschen? Bei einem synchronisierten Kalender wird eine entfernte Löschung eingeplant.",
"i18n:govoplan-calendar.delete_event_series_message": "Die gesamte Serie {value0} löschen? Bei einem synchronisierten Kalender wird eine entfernte Löschung eingeplant.",
"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.quick_access_description": "Anstehende Termine aus den sichtbaren Kalendern.",
"i18n:govoplan-calendar.quick_access_range": "Agendazeitraum",
"i18n:govoplan-calendar.quick_access_event_details": "Termindetails",
"i18n:govoplan-calendar.quick_access_select_event": "Termin auswählen",
"i18n:govoplan-calendar.quick_access_open_calendar": "Im Kalender öffnen",
"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.connector_profile_reference.e7697602": "Connector-Profilreferenz",
"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_group_mapping_reference.40c3da81": "Identitäts-/Gruppenzuordnungsreferenz",
"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.open_xchange_dav_url.9d1adeaf": "Open-Xchange-DAV-URL",
"i18n:govoplan-calendar.open_xchange_source.4cc03862": "Open-Xchange-Quelle",
"i18n:govoplan-calendar.open_xchange.637e5b7b": "Open-Xchange",
"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.resource_calendar_reference.4df67054": "Ressourcenkalenderreferenz",
"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.all_history.8829c44e": "Gesamter Verlauf",
"i18n:govoplan-calendar.attempts.448fa12e": "Versuche",
"i18n:govoplan-calendar.available.17bc146b": "Verfügbar",
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Konflikte / endgültig fehlgeschlagen",
"i18n:govoplan-calendar.discard.23a76911": "Verwerfen",
"i18n:govoplan-calendar.discard_change.85474ec4": "Änderung verwerfen",
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Lokalen Sollzustand verwerfen?",
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Das Verwerfen bricht diese lokale ausgehende Änderung und ihre ungelösten Vorgänger ab. Die nächste vollständige Synchronisierung übernimmt den entfernten Stand; lokale Änderungen können verloren gehen.",
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Ausgehende Änderungen werden geladen...",
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "Keine ausgehenden Änderungen entsprechen diesem Filter.",
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Es werden nur die 100 neuesten ausgehenden Änderungen angezeigt.",
"i18n:govoplan-calendar.outbound_changes.7038a839": "Ausgehende Änderungen",
"i18n:govoplan-calendar.outbox_filter.8305af40": "Filter für ausgehende Änderungen",
"i18n:govoplan-calendar.reconcile.6c595f64": "Abgleichen",
"i18n:govoplan-calendar.retry.3fda8f1c": "Erneut versuchen",
"i18n:govoplan-calendar.shown.498e85a1": "Angezeigt",
"i18n:govoplan-calendar.unresolved.11c41de4": "Ungelöst",
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
}
};
+2
View File
@@ -1,2 +1,4 @@
export { calendarModule as default, calendarModule } from "./module"; export { calendarModule as default, calendarModule } from "./module";
export * from "./api/calendar"; export * from "./api/calendar";
export { default as CalendarPicker } from "./features/calendar/CalendarPicker";
export * from "./features/calendar/calendarPickerLogic";
+146 -10
View File
@@ -1,21 +1,157 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import type {
CalendarPickerUiCapability,
DashboardWidgetsUiCapability,
PlatformWebModule,
QuickAccessToolsUiCapability,
SettingsSectionsUiCapability
} from "@govoplan/core-webui";
import { generatedTranslations as productSurfaceTranslations } from "@govoplan/core-webui/outcome-product-surface-translations";
import "./styles/calendar.css"; import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker";
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
import CalendarQuickAccess from "./features/calendar/CalendarQuickAccess";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage")); const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
const CalendarSettingsPanel = lazy(
() => import("./features/calendar/CalendarSettingsPanel")
);
const eventRead = ["calendar:event:read"]; const eventRead = ["calendar:event:read"];
const translations = {
en: { ...generatedTranslations.en, ...productSurfaceTranslations.en },
de: { ...generatedTranslations.de, ...productSurfaceTranslations.de }
};
export const calendarModule: PlatformWebModule = { const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
id: "calendar", const calendarSettingsSections: SettingsSectionsUiCapability = {
label: "Calendar", sections: [
version: "1.0.0", {
dependencies: ["access"], id: "calendar",
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"], surfaceId: "calendar.settings.preferences",
navItems: [{ to: "/calendar", label: "Calendar", iconName: "calendar", anyOf: eventRead, order: 55 }], label: "Calendar",
routes: [ group: "ui",
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) } order: 45,
anyOf: eventRead,
render: ({ settings, auth }) =>
createElement(CalendarSettingsPanel, { settings, auth })
}
]
};
const calendarDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "calendar.upcoming",
surfaceId: "calendar.widget.upcoming",
title: "Upcoming events",
description: "The next events across visible calendars.",
moduleId: "calendar",
category: "Planning",
order: 40,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: eventRead,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 5,
daysAhead: 14,
showLocation: true
},
configurationFields: [
{
id: "maxItems",
label: "Maximum events",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "daysAhead",
label: "Days ahead",
kind: "number",
min: 1,
max: 90,
step: 1,
required: true
},
{
id: "showLocation",
label: "Show locations",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(UpcomingEventsWidget, {
settings,
refreshKey,
configuration
})
}
]
};
const calendarQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "calendar.agenda",
render: (context) => createElement(CalendarQuickAccess, context)
}
] ]
}; };
export const calendarModule: PlatformWebModule = {
id: "calendar",
label: "i18n:govoplan-calendar.calendar.adab5090",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
translations,
viewSurfaces: [
{ id: "calendar.navigation", moduleId: "calendar", kind: "navigation", label: "i18n:govoplan-calendar.surface.navigation", order: 10 },
{ id: "calendar.page", moduleId: "calendar", kind: "route", label: "i18n:govoplan-calendar.surface.page", order: 20 },
{ id: "calendar.page.sidebar", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.sidebar", parentId: "calendar.page", order: 10 },
{ id: "calendar.page.agenda", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.agenda", parentId: "calendar.page.sidebar", order: 20 },
{ id: "calendar.page.workspace", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.workspace", parentId: "calendar.page", order: 30 },
{ id: "calendar.event-editor", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.event_editor", parentId: "calendar.page", order: 40 },
{ id: "calendar.collection-editor", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.collection_editor", parentId: "calendar.page.sidebar", order: 50 },
{ id: "calendar.sync-status", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.sync_status", parentId: "calendar.collection-editor", order: 60 },
{ id: "calendar.outbox", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.outbox", parentId: "calendar.sync-status", order: 70 },
{ id: "calendar.migration", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.migration", parentId: "calendar.sync-status", order: 80 },
{
id: "calendar.widget.upcoming",
moduleId: "calendar",
kind: "section",
label: "Upcoming events widget",
order: 40
},
{
id: "calendar.settings.preferences",
moduleId: "calendar",
kind: "section",
label: "Calendar preferences",
order: 45
},
{
id: "calendar.quick_access.agenda",
moduleId: "calendar",
kind: "quick_access",
label: "Calendar Quick Access",
order: 50
}
],
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55, surfaceId: "calendar.navigation" }],
routes: [
{ path: "/calendar", anyOf: eventRead, order: 55, surfaceId: "calendar.page", render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
uiCapabilities: {
"calendar.picker": calendarPicker,
"dashboard.widgets": calendarDashboardWidgets,
"settings.sections": calendarSettingsSections,
"quickAccess.tools": calendarQuickAccessTools
}
};
export default calendarModule; export default calendarModule;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
import fs from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const page = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarPage.tsx",
import.meta.url,
),
"utf8",
);
const views = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarViews.tsx",
import.meta.url,
),
"utf8",
);
const model = fs.readFileSync(
new URL(
"../src/features/calendar/calendarViewModel.ts",
import.meta.url,
),
"utf8",
);
const collectionDialogs = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarCollectionDialogs.tsx",
import.meta.url,
),
"utf8",
);
const eventDialog = fs.readFileSync(
new URL(
"../src/features/calendar/CalendarEventDialog.tsx",
import.meta.url,
),
"utf8",
);
assert(
page.includes('from "./CalendarViews"'),
"CalendarPage must compose the focused calendar view components",
);
assert(
!page.includes("function CalendarWeekRows("),
"CalendarPage must not own month/continuous rendering",
);
assert(
!page.includes("function CalendarTimeGrid("),
"CalendarPage must not own time-grid rendering",
);
assert(
views.includes("export function CalendarWeekRows(") &&
views.includes("export function CalendarTimeGrid("),
"CalendarViews must own both view families",
);
assert(
model.includes("export function layoutTimedEventsForDay(") &&
model.includes("export function continuousVirtualWindow("),
"calendar layout and virtualization must remain independently testable",
);
assert(
!page.includes("function CalendarCollectionDialog(") &&
!page.includes("function CalendarEventDialog("),
"CalendarPage must not own source or event form dialogs",
);
assert(
collectionDialogs.includes(
"export function CalendarCollectionDialog(",
) &&
eventDialog.includes("export function CalendarEventDialog("),
"calendar dialogs must remain focused components",
);
console.log("Calendar page decomposition checks passed.");
@@ -0,0 +1,20 @@
import fs from "node:fs";
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const picker = fs.readFileSync(new URL("../src/features/calendar/CalendarPicker.tsx", import.meta.url), "utf8");
const moduleSource = fs.readFileSync(new URL("../src/module.ts", import.meta.url), "utf8");
const coreTypes = fs.readFileSync(new URL("../../../govoplan-core/webui/src/types.ts", import.meta.url), "utf8");
assert(moduleSource.includes('"calendar.picker": calendarPicker'), "Calendar must register the named picker capability");
assert(coreTypes.includes("export type CalendarPickerUiCapability"), "Core must expose the narrow cross-module contract");
assert(picker.includes("listCalendars(settings)"), "Picker must use Calendar's authenticated list API");
assert(picker.includes("hasScope(auth, CALENDAR_PICKER_READ_SCOPE)"), "Picker must avoid loading without read permission");
assert(picker.includes("value={value}"), "Picker must remain controlled by its consumer");
assert(picker.includes("onChange={(event) => onChange(event.target.value)}"), "Picker must return the chosen calendar id");
assert(picker.includes("aria-busy={loading || undefined}"), "Picker must expose loading state accessibly");
assert(picker.includes("aria-describedby={status ? statusId : undefined}"), "Picker must associate availability feedback");
console.log("Calendar picker capability structure checks passed.");
+37
View File
@@ -0,0 +1,37 @@
import {
calendarPickerOptions,
calendarPickerTenantId,
type CalendarPickerOption
} from "../src/features/calendar/calendarPickerLogic";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
function calendar(id: string, name: string, isDefault = false): CalendarPickerOption {
return {
id,
name,
is_default: isDefault
};
}
const input = [
calendar("z", "Zulu"),
calendar("b", "Bravo", true),
calendar("a", "alpha")
];
const ordered = calendarPickerOptions(input);
assert(ordered.map((item) => item.id).join(",") === "b,a,z", "default calendar should lead, followed by names");
assert(input.map((item) => item.id).join(",") === "z,b,a", "picker sorting must not mutate API data");
assert(
calendarPickerTenantId({ tenant: { id: "fallback" }, active_tenant: { id: "active" } }) === "active",
"active tenant should partition picker requests"
);
assert(
calendarPickerTenantId({ tenant: { id: "fallback" } }) === "fallback",
"legacy tenant should remain a supported fallback"
);
console.log("Calendar picker behavior checks passed.");
+115
View File
@@ -0,0 +1,115 @@
import {
continuousEventWindow,
continuousVirtualWindow,
daysForMode,
groupEventsByDay,
layoutTimedEventsForDay,
moveEventToDay,
rangeForMode,
resizeEventToTime,
} from "../src/features/calendar/calendarViewModel.ts";
function assert(condition: unknown, message: string): void {
if (!condition) throw new Error(message);
}
function event(
id: string,
start: Date,
end: Date,
allDay = false,
) {
return {
id,
calendar_id: "calendar-1",
summary: id,
start_at: start.toISOString(),
end_at: end.toISOString(),
all_day: allDay,
} as never;
}
const focus = new Date(2026, 6, 15, 12);
const month = rangeForMode("month", focus, {
before: 8,
after: 12,
});
assert(
(month.end.getTime() - month.start.getTime()) / 86_400_000 === 42,
"month view should retain its six-week window",
);
const virtual = continuousVirtualWindow(
100,
{ scrollTop: 20 * 92, height: 8 * 92 },
3,
);
assert(virtual.start === 17, "virtualization should retain overscan above");
assert(virtual.end === 31, "virtualization should retain overscan below");
assert(
virtual.topSpacerHeight + virtual.bottomSpacerHeight > 0,
"virtualization should preserve offscreen geometry",
);
const continuousDays = daysForMode("continuous", focus, {
before: 50,
after: 50,
});
const eventWindow = continuousEventWindow(
continuousDays,
{ scrollTop: 40 * 92, height: 6 * 92 },
);
assert(
(eventWindow.end.getTime() - eventWindow.start.getTime()) / 86_400_000 === 10 * 7,
"continuous event loading should stay bounded to a ten-week window",
);
assert(
eventWindow.start > continuousDays[0],
"continuous event loading should follow the virtualized viewport",
);
const day = new Date(2026, 6, 7);
const parallel = [0, 1, 2, 3].map((index) =>
event(
`event-${index}`,
new Date(2026, 6, 7, 9),
new Date(2026, 6, 7, 10),
),
);
const layout = layoutTimedEventsForDay(parallel, day);
assert(
layout.items.length === 2 && layout.overflows.length === 1,
"parallel events should use bounded columns plus one overflow control",
);
assert(
layout.overflows[0].events.length === 2,
"overflow should retain every hidden event",
);
const allDay = event(
"all-day",
new Date(2026, 6, 7),
new Date(2026, 6, 9),
true,
);
const grouped = groupEventsByDay([allDay]);
assert(grouped.size === 2, "all-day end dates should remain exclusive");
const moved = moveEventToDay(allDay, new Date(2026, 6, 20));
assert(
moved.endAt?.getDate() === 22,
"moving an all-day event should preserve its duration",
);
const timed = event(
"timed",
new Date(2026, 6, 7, 10),
new Date(2026, 6, 7, 11),
);
const resized = resizeEventToTime(timed, "end", day, 9 * 60);
assert(
resized.endAt !== null &&
resized.endAt.getTime() > resized.startAt.getTime(),
"resizing must not invert an event",
);
console.log("Calendar view model interaction checks passed.");
+45
View File
@@ -0,0 +1,45 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": [
"../../govoplan-core/webui/src/index.ts"
],
"lucide-react": [
"../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"
],
"react": [
"../../govoplan-core/webui/node_modules/@types/react/index.d.ts"
],
"react/jsx-runtime": [
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
],
"react-router": [
"../../govoplan-core/webui/node_modules/react-router/dist/development/index.d.ts"
]
}
},
"include": [
"../../govoplan-core/webui/src/vite-env.d.ts",
"src/module.ts",
"src/api/calendar.ts",
"src/features/calendar/CalendarPage.tsx",
"src/features/calendar/CalendarSettingsPanel.tsx",
"src/features/calendar/UpcomingEventsWidget.tsx",
"src/features/calendar/CalendarQuickAccess.tsx",
"src/features/calendar/CalendarViews.tsx",
"src/features/calendar/CalendarCollectionDialogs.tsx",
"src/features/calendar/CalendarEventDialog.tsx",
"src/features/calendar/calendarViewModel.ts"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".calendar-picker-test-build",
"rootDir": "."
},
"include": [
"tests/calendar-picker.test.ts",
"src/features/calendar/calendarPickerLogic.ts"
]
}