47 Commits
Author SHA1 Message Date
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
74 changed files with 21345 additions and 3299 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
+1
View File
@@ -118,6 +118,7 @@ dist
.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/
+6
View File
@@ -1,5 +1,11 @@
# 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
This repository owns the `calendar` module: calendar collections, VEVENT storage, iCalendar import/export, availability primitives, backend module manifest, and `@govoplan/calendar-webui`.
+35 -8
View File
@@ -1,5 +1,9 @@
# govoplan-calendar
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Calendar is the standalone calendar module. It provides tenant calendar collections, iCalendar/VEVENT event storage, a calendar WebUI, and integration boundaries for scheduling, tasks, mail, appointments, workflow, notifications, and external groupware.
## Ownership
@@ -36,9 +40,28 @@ The first backend implementation stores VEVENT data in two layers:
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
records the remote collection URL, sync token, ETag/ctag state, username,
credential reference, sync interval, sync direction, and conflict policy.
Credentials can be supplied transiently for manual sync, referenced from
environment variables with `env:NAME`, stored through a platform secret provider
when one is available, or stored as encrypted calendar-owned credentials.
Credentials can be supplied transiently for manual sync. Persisted API-managed
sources accept only a password or token: Calendar stores an opaque, tenant- and
source-bound local reference, backed by the platform secret provider when one is
available or by an encrypted calendar-owned credential otherwise. Caller-selected
environment and external-provider references are rejected. Trusted deployment
code may resolve an `env:NAME` reference only through the separate deployment
configuration helper.
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`
when a sync token exists. It imports all VEVENT components in a resource and
@@ -47,12 +70,16 @@ write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and
ETag preconditions. If a remote resource changed, the local mutation is rejected
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
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,
CalDAV server endpoints, UI credential management, and full user-facing
recurrence editing remain follow-up work. The backend now includes a free/busy
API primitive for scheduling and appointment modules.
Calendar exposes collection and credential management, sync status/actions,
bounded outbound-change diagnostics and guarded retry/reconcile/discard recovery,
durable per-user view preferences, recurrence expansion, detached occurrence
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.
## Development
+176 -6
View File
@@ -6,7 +6,7 @@
- calendar collections
- 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
- resources such as rooms, shared equipment, and service desks
- groupware calendar adapter boundaries, including CalDAV and Open-Xchange
@@ -22,11 +22,172 @@ 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
- 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
- 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
- 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
- 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
@@ -74,6 +235,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.
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
`govoplan-dms` can link documents to events for:
@@ -113,9 +286,6 @@ The connector boundary should hand calendar a configured profile and credentials
## 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.
- 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.
+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.
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.7",
"version": "0.1.17",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,14 +19,14 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@govoplan/core-webui": "^0.1.17",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+4 -3
View File
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-calendar"
version = "0.1.7"
version = "0.1.17"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.7",
"govoplan-access>=0.1.7",
"govoplan-core>=0.1.17",
"govoplan-access>=0.1.17",
"defusedxml>=0.7,<1",
"icalendar>=7.2",
"python-dateutil>=2.9",
]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.4"
__version__ = "0.1.17"
+233 -75
View File
@@ -1,12 +1,20 @@
from __future__ import annotations
import base64
import posixpath
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from typing import Mapping, Protocol
from xml.etree import ElementTree
from typing import Any, Mapping, Protocol
from defusedxml import ElementTree as SafeElementTree
from govoplan_core.security.outbound_http import (
OutboundHttpError,
bounded_response_bytes,
build_outbound_http_opener,
validate_outbound_http_url,
)
class CalDAVError(RuntimeError):
@@ -75,6 +83,18 @@ class _DAVDiscoveryResponse:
supported_components: tuple[str, ...] = ()
@dataclass(slots=True)
class _DAVDiscoveryDraft:
display_name: str | None = None
color: str | None = None
ctag: str | None = None
sync_token: str | None = None
is_calendar: bool = False
principal_hrefs: list[str] = field(default_factory=list)
calendar_home_set_hrefs: list[str] = field(default_factory=list)
supported_components: list[str] = field(default_factory=list)
class CalDAVClient:
def __init__(
self,
@@ -228,8 +248,23 @@ class CalDAVClient:
return parse_multistatus(payload)
def fetch_object(self, href: str) -> str:
payload = self.request("GET", self.object_url(href), body=None, depth=None, expected={200})
return payload.decode("utf-8")
return self.fetch_object_state(href).calendar_data or ""
def fetch_object_state(self, href: str) -> CalDAVObject:
"""Fetch a resource together with its ETag for outbox reconciliation."""
_status, headers, payload = self.request_raw(
"GET",
self.object_url(href),
body=None,
depth=None,
expected={200},
)
return CalDAVObject(
href=href,
etag=response_etag(headers),
calendar_data=payload.decode("utf-8"),
)
def put_object(self, href: str, ics: str, *, etag: str | None = None, create: bool = False, overwrite: bool = False) -> CalDAVWriteResult:
headers = {"Content-Type": "text/calendar; charset=utf-8"}
@@ -266,7 +301,21 @@ class CalDAVClient:
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str:
return urllib.parse.urljoin(self.collection_url, href)
candidate = same_origin_dav_url(
self.collection_url,
href,
label="CalDAV object href",
)
collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate)
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
if not candidate_path.startswith(collection_prefix) or candidate_path == collection_path:
raise CalDAVError("CalDAV object href must remain inside the configured collection path")
if candidate_parts.query or candidate_parts.fragment:
raise CalDAVError("CalDAV object href must not contain a query or fragment")
return urllib.parse.urlunparse(candidate_parts)
def request(self, method: str, url: str, *, body: bytes | None, depth: str | None, expected: set[int]) -> bytes:
_status, _headers, payload = self.request_raw(method, url, body=body, depth=depth, expected=expected)
@@ -308,22 +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]:
url = validate_http_url(url)
try:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.status, dict(response.headers.items()), response.read()
url = validate_outbound_http_url(url, label="CalDAV URL")
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=body,
headers=dict(headers),
method=method,
)
opener = build_outbound_http_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = dict(response.headers.items())
return response.status, response_headers, bounded_response_bytes(
response,
headers=response_headers,
label="CalDAV response",
)
except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read()
response_headers = dict(exc.headers.items())
try:
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
except OutboundHttpError as policy_exc:
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
return exc.code, response_headers, payload
except urllib.error.URLError as exc:
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
except ValueError as exc:
except (OutboundHttpError, ValueError) as exc:
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
def parse_multistatus(payload: bytes) -> CalDAVReportResult:
try:
root = ElementTree.fromstring(payload)
except ElementTree.ParseError as exc:
root = SafeElementTree.fromstring(payload)
except SafeElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
objects: list[CalDAVObject] = []
sync_token = first_child_text(root, "sync-token")
@@ -355,76 +422,167 @@ def parse_multistatus(payload: bytes) -> CalDAVReportResult:
def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
try:
root = ElementTree.fromstring(payload)
except ElementTree.ParseError as exc:
root = SafeElementTree.fromstring(payload)
except SafeElementTree.ParseError as exc:
raise CalDAVError(f"Invalid CalDAV XML response: {exc}") from exc
responses: list[_DAVDiscoveryResponse] = []
for response in child_elements(root, "response"):
href = first_child_text(response, "href")
if not href:
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
display_name = None
color = None
ctag = None
sync_token = None
is_calendar = False
principal_hrefs: list[str] = []
calendar_home_set_hrefs: list[str] = []
supported_components: list[str] = []
for propstat in child_elements(response, "propstat"):
status = first_child_text(propstat, "status") or ""
if status and " 200 " not in status and not status.endswith(" 200") and " 207 " not in status:
continue
prop = first_child(propstat, "prop")
if prop is None:
continue
for item in prop:
name = local_name(item.tag)
if name == "displayname" and item.text:
display_name = item.text.strip() or display_name
elif name == "calendar-color" and item.text:
color = item.text.strip() or color
elif name == "getctag" and item.text:
ctag = item.text.strip() or ctag
elif name == "sync-token" and item.text:
sync_token = item.text.strip() or sync_token
elif name == "resourcetype":
is_calendar = is_calendar or any(local_name(child.tag) == "calendar" for child in item)
elif name in {"current-user-principal", "principal-URL"}:
principal_hrefs.extend(nested_href_texts(item))
elif name == "calendar-home-set":
calendar_home_set_hrefs.extend(nested_href_texts(item))
elif name == "supported-calendar-component-set":
for component in item:
if local_name(component.tag) == "comp":
component_name = (component.attrib.get("name") or "").strip().upper()
if component_name:
supported_components.append(component_name)
responses.append(
_DAVDiscoveryResponse(
href=href,
display_name=display_name,
color=color,
ctag=ctag,
sync_token=sync_token,
is_calendar=is_calendar,
principal_hrefs=tuple(dict.fromkeys(principal_hrefs)),
calendar_home_set_hrefs=tuple(dict.fromkeys(calendar_home_set_hrefs)),
supported_components=tuple(dict.fromkeys(supported_components)),
)
)
return responses
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:
value = value.strip()
if value and "://" not in value and not value.startswith("/"):
value = f"https://{value}"
return value if value.endswith("/") else f"{value}/"
url = validate_http_url(value)
return url if url.endswith("/") else f"{url}/"
def validate_http_url(value: str) -> str:
parsed = urllib.parse.urlparse(value.strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise CalDAVError("CalDAV URL must be an absolute HTTP(S) URL")
if parsed.username or parsed.password:
raise CalDAVError("CalDAV URL must not include embedded credentials")
if parsed.query or parsed.fragment:
raise CalDAVError("CalDAV URL must not include a query or fragment")
_url_origin(parsed)
return urllib.parse.urlunparse(parsed)
def absolute_dav_url(base_url: str, href: str) -> str:
return urllib.parse.urljoin(ensure_collection_url(base_url), href)
return same_origin_dav_url(base_url, href, label="CalDAV discovery href")
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
base = ensure_collection_url(base_url)
candidate = validate_http_url(urllib.parse.urljoin(base, href))
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
raise CalDAVError(f"{label} must use the configured collection origin")
return candidate
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise CalDAVError("CalDAV URL has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
except (CalDAVError, OutboundHttpError):
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None
method = req.get_method()
data = req.data
if code == 303 and method != "HEAD":
method, data = "GET", None
elif code in {301, 302} and method == "POST":
method, data = "GET", None
forwarded_headers = {
key: value
for key, value in req.header_items()
if key.casefold() not in {"host", "content-length"}
}
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
candidate,
data=data,
headers=forwarded_headers,
origin_req_host=req.origin_req_host,
unverifiable=True,
method=method,
)
def strip_weak_etag(value: str | None) -> str | None:
@@ -444,25 +602,25 @@ def xml_escape(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
def first_child(element: Any, name: str) -> Any | None:
for child in element:
if local_name(child.tag) == name:
return child
return None
def first_child_text(element: ElementTree.Element, name: str) -> str | None:
def first_child_text(element: Any, name: str) -> str | None:
found = first_child(element, name)
if found is None or found.text is None:
return None
return found.text.strip()
def child_elements(element: ElementTree.Element, name: str) -> list[ElementTree.Element]:
def child_elements(element: Any, name: str) -> list[Any]:
return [child for child in element if local_name(child.tag) == name]
def nested_href_texts(element: ElementTree.Element) -> list[str]:
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():
@@ -0,0 +1,827 @@
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,
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,
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,
)
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."
)
+169 -2
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint, text
@@ -52,6 +52,11 @@ class CalendarEvent(Base, TimestampMixin):
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_tenant_uid", "tenant_id", "uid"),
Index(
"ix_calendar_events_tenant_correlation",
"tenant_id",
"correlation_key",
),
Index("ix_calendar_events_tenant_status", "tenant_id", "status"),
)
@@ -59,6 +64,18 @@ class CalendarEvent(Base, TimestampMixin):
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
calendar_id: Mapped[str] = mapped_column(ForeignKey("calendar_collections.id", ondelete="CASCADE"), nullable=False, index=True)
uid: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
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)
sequence: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
summary: Mapped[str] = mapped_column(String(500), nullable=False)
@@ -94,6 +111,36 @@ class CalendarEvent(Base, TimestampMixin):
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):
__tablename__ = "calendar_sync_sources"
__table_args__ = (
@@ -151,4 +198,124 @@ class CalendarSyncCredential(Base, TimestampMixin):
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",
]
+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)
+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)
+66 -6
View File
@@ -153,6 +153,18 @@ def events_to_ics(events: list[Any]) -> str:
def event_to_component(event: Any) -> Event:
component = Event()
add_event_identity_and_time(component, event)
add_event_status_fields(component, event)
add_event_optional_text_fields(component, event)
add_event_recurrence_and_parties(component, event)
add_event_raw_records(component, event)
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
for alarm in alarms_for_export(event):
component.add_component(alarm)
return component
def add_event_identity_and_time(component: Event, event: Any) -> None:
component.add("uid", event.uid)
component.add("dtstamp", datetime.now(timezone.utc))
component.add("dtstart", event_temporal_value(event.start_at, all_day=event.all_day, timezone_id=event.timezone))
@@ -164,23 +176,35 @@ def event_to_component(event: Any) -> Event:
component.add("duration", timedelta(seconds=int(event.duration_seconds)))
if getattr(event, "recurrence_id", None):
add_recurrence_id(component, str(event.recurrence_id))
def add_event_status_fields(component: Event, event: Any) -> None:
component.add("summary", event.summary)
component.add("sequence", int(event.sequence or 0))
component.add("status", event.status)
component.add("transp", event.transparency)
component.add("class", event.classification)
def add_event_optional_text_fields(component: Event, event: Any) -> None:
if event.description:
component.add("description", event.description)
if event.location:
component.add("location", event.location)
if event.categories:
component.add("categories", list(event.categories))
def add_event_recurrence_and_parties(component: Event, event: Any) -> None:
if event.rrule:
component.add("rrule", normalized_rrule_for_export(event.rrule))
if event.organizer:
component.add("organizer", party_for_export(event.organizer))
for attendee in event.attendees or []:
component.add("attendee", party_for_export(attendee))
def add_event_raw_records(component: Event, event: Any) -> None:
for record in getattr(event, "rdate", None) or []:
add_raw_property(component, "RDATE", record)
for record in getattr(event, "exdate", None) or []:
@@ -190,11 +214,6 @@ def event_to_component(event: Any) -> Event:
for record in getattr(event, "related_to", None) or []:
add_raw_property(component, "RELATED-TO", record)
add_preserved_properties(component, getattr(event, "icalendar", None) or {})
for alarm in alarms_for_export(event):
component.add_component(alarm)
return component
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
"""Expand an event's recurrence primitives within a range.
@@ -283,6 +302,30 @@ def recurrence_id_value(value: Any | None) -> str | None:
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:
if value is None:
return None
@@ -634,13 +677,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]:
return {
"uid": event.uid,
"recurrence_id": format_ical_datetime(start_at),
"recurrence_id": occurrence_recurrence_id(start_at, event),
"start_at": start_at,
"end_at": start_at + duration if duration else None,
"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:
try:
return int(value or "0")
+696 -16
View File
@@ -1,12 +1,341 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from sqlalchemy import inspect
from govoplan_calendar.backend.db import models as calendar_models # noqa: F401 - populate Calendar ORM metadata
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.calendar import (
CALENDAR_AVAILABILITY_READ_SCOPE,
CALENDAR_EVENT_WRITE_SCOPE,
CAPABILITY_CALENDAR_EXTERNAL_PROFILES,
CAPABILITY_CALENDAR_INVITATIONS,
CAPABILITY_CALENDAR_OUTBOX,
CAPABILITY_CALENDAR_SCHEDULING,
)
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_calendar.backend.search_source import create_calendar_search_source
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"
CALENDAR_ARCHITECTURE = ModuleArchitectureDeclaration(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="test",
reference="tests/test_caldav.py",
summary="Exercises CalDAV discovery, pull, push, deletion, and reconciliation behavior.",
),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_outbox.py",
summary="Exercises durable calendar effects, retries, terminal states, and cleanup.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/CALENDAR_INTEGRATION_CONCEPT.md",
summary="Documents Calendar ownership and optional integration boundaries.",
),
),
known_limits=(
"Provider target-environment and recovery-drill evidence is not yet packaged as a reference package.",
"Microsoft Graph and EWS adapters do not yet have the same write/reconciliation coverage as CalDAV.",
),
supported_authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
"linked_reference",
),
owned_concepts=(
"calendar collections",
"VEVENT lifecycle",
"recurrence and availability",
"calendar synchronization state",
),
non_owned_concepts=(
"meeting polls",
"mail delivery",
"task lifecycle",
"external identity and credential secrets",
),
target_tested_providers=(CALDAV_PROVIDER_ID,),
documentation=ModuleArchitectureDocumentation(
migration=("src/govoplan_calendar/backend/migrations/versions",),
upgrade=("docs/CALENDAR_INTEGRATION_CONCEPT.md",),
recovery=("docs/CALENDAR_INTEGRATION_CONCEPT.md",),
security=("tests/test_caldav_security.py", "tests/test_http_security.py"),
operations=("docs/CALENDAR_INTEGRATION_CONCEPT.md",),
),
)
def _read_only_calendar_provider(
*,
provider_id: str,
label: str,
source_name: str,
) -> ExternalProviderDeclaration:
return ExternalProviderDeclaration(
id=provider_id,
module_id="calendar",
label=label,
maturity="read",
operations=("read", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="calendar_collection",
field_groups=("identity", "display", "sync_state"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
ProviderObjectDeclaration(
object_type="calendar_event",
field_groups=(
"identity",
"schedule",
"recurrence",
"participants",
"content",
),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens=f"{source_name} object identity, remote revision, and content fingerprint are retained.",
concurrency="The source is inbound-only; refresh compares remote identity and revision before replacing the derived projection.",
freshness="Last attempt, last success, and source status are retained.",
health="Authentication, transport, parsing, and source failures are recorded without exposing credentials.",
max_read_items=5000,
evidence=f"Imported events retain the {source_name} source, remote identity, revision, and normalized content fingerprint.",
audit_event_types=("calendar.sync.requested", "calendar.sync.completed"),
correction="A later successful refresh creates the corrected local projection while source authority remains external.",
reconciliation="Re-read the bounded source and compare remote identity, revision, and normalized event content.",
outage="The last local projection remains available with stale or unknown freshness.",
classifications=("internal", "confidential", "restricted"),
purposes=("calendar subscription", "availability", "meeting coordination"),
retention="Calendar projection, sync evidence, and credential retention are independent policies.",
secret_handling="Authentication material remains in Calendar credential records or deployment-owned secret references.",
),
documentation_topic_ids=("calendar.external-sources-and-sync",),
)
CALENDAR_EXTERNAL_PROVIDERS = (
ExternalProviderDeclaration(
id=CALDAV_PROVIDER_ID,
module_id="calendar",
label="CalDAV calendar synchronization",
maturity="synchronize",
operations=(
"discover",
"read",
"write",
"delete",
"synchronize",
"preview",
),
objects=(
ProviderObjectDeclaration(
object_type="calendar_collection",
field_groups=("identity", "display", "sync_state"),
authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
default_authority_mode="governed_sync",
),
ProviderObjectDeclaration(
object_type="calendar_event",
field_groups=(
"identity",
"schedule",
"recurrence",
"participants",
"content",
),
authority_modes=(
"native_authoritative",
"external_authoritative",
"external_mirror",
"governed_sync",
),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="CalDAV sync tokens and per-resource ETags are retained.",
concurrency="Conditional requests reject stale ETags; conflicts remain explicit.",
freshness="Last attempt, last success, sync token, and pending outbox work are visible.",
health="Discovery, authentication, transport, collection, and reconciliation failures are recorded.",
max_read_items=5000,
idempotency="Stable VEVENT UID, source id, and durable operation keys suppress duplicate effects.",
retry="Only classified transient failures receive bounded backoff retries.",
timeout_seconds=30,
conflicts="Stale revisions and concurrent remote changes enter conflict/reconciliation state.",
outcome_unknown="A timed-out remote write is outcome-unknown and is not blindly repeated.",
outcome_unknown_supported=True,
evidence="Operation id, request intent, ETag/sync token, response classification, and reconciliation result are retained.",
audit_event_types=(
"calendar.sync.requested",
"calendar.sync.completed",
"calendar.sync.conflict",
"calendar.sync.reconciled",
),
correction="A later conditional update or tombstone corrects external state after reconciliation.",
rollback="Remote effects are not treated as transactionally rollback-safe.",
compensation="A compensating event update/delete may be queued after the remote state is known.",
reconciliation="Read the resource by href/UID, compare ETag and content, then classify applied, retryable, conflict, or manual.",
outage="Local calendars remain usable with stale markers; pending writes stay durable and bounded.",
classifications=("internal", "confidential", "restricted"),
purposes=("calendar collaboration", "availability", "meeting coordination"),
retention="Calendar event, outbox terminal-state, audit, and credential retention are independent policies.",
secret_handling="Authentication material is stored through Calendar credential records or external secret references and is never emitted in provider metadata.",
),
capability_names=(CAPABILITY_CALENDAR_OUTBOX,),
interface_names=("calendar.outbox",),
documentation_topic_ids=("calendar.external-sources-and-sync",),
),
ExternalProviderDeclaration(
id=OPEN_XCHANGE_PROVIDER_ID,
module_id="calendar",
label="Open-Xchange calendar synchronization",
maturity="synchronize",
operations=("discover", "read", "write", "delete", "synchronize", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="calendar_collection",
field_groups=("identity", "display", "sync_state", "resource_mapping"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="governed_sync",
),
ProviderObjectDeclaration(
object_type="calendar_event",
field_groups=("identity", "schedule", "recurrence", "participants", "content"),
authority_modes=("external_authoritative", "external_mirror", "governed_sync"),
default_authority_mode="governed_sync",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Open-Xchange CalDAV sync tokens, collection tags, and per-resource ETags are retained.",
concurrency="Conditional CalDAV requests reject stale ETags and expose conflicts for reconciliation.",
freshness="Last attempt, last success, sync token, and pending outbox work are visible.",
health="Discovery, authentication, transport, collection, and reconciliation failures are recorded.",
max_read_items=5000,
idempotency="Stable VEVENT UID, source id, and durable operation keys suppress duplicate effects.",
retry="Only classified transient failures receive bounded backoff retries.",
timeout_seconds=30,
conflicts="Stale revisions and concurrent Open-Xchange changes enter conflict or reconciliation state.",
outcome_unknown="A timed-out remote write is outcome-unknown and is reconciled before retry.",
outcome_unknown_supported=True,
evidence="The Open-Xchange profile reference, mapping references, operation intent, ETag, and reconciliation result are retained.",
audit_event_types=("calendar.sync.requested", "calendar.sync.completed", "calendar.sync.conflict", "calendar.sync.reconciled"),
correction="A later conditional update or tombstone corrects external state after reconciliation.",
rollback="Remote effects are not treated as transactionally rollback-safe.",
compensation="A compensating event update or delete may be queued after remote state is known.",
reconciliation="Read by CalDAV href and VEVENT UID, compare ETag and content, then classify the result.",
outage="The local projection remains available with stale markers and durable pending writes.",
classifications=("internal", "confidential", "restricted"),
purposes=("calendar collaboration", "availability", "resource booking", "meeting coordination"),
retention="Calendar event, outbox, audit, profile-binding, and credential retention remain independent policies.",
secret_handling="Calendar stores only scoped credential references or encrypted Calendar-owned credentials.",
),
capability_names=(CAPABILITY_CALENDAR_EXTERNAL_PROFILES, CAPABILITY_CALENDAR_OUTBOX, CAPABILITY_CALENDAR_SCHEDULING),
interface_names=("calendar.external_profiles", "calendar.outbox", "calendar.scheduling"),
documentation_topic_ids=("calendar.external-sources-and-sync",),
),
_read_only_calendar_provider(
provider_id=ICS_PROVIDER_ID,
label="ICS and webcal subscription",
source_name="ICS/webcal",
),
_read_only_calendar_provider(
provider_id=GRAPH_PROVIDER_ID,
label="Microsoft Graph calendar projection",
source_name="Microsoft Graph",
),
_read_only_calendar_provider(
provider_id=EWS_PROVIDER_ID,
label="Exchange Web Services calendar projection",
source_name="Exchange Web Services",
),
)
_calendar_table_retirement_provider = drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarMigrationBatch,
calendar_models.CalendarMigrationResource,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
calendar_models.CalendarViewPreference,
label="Calendar",
)
def _calendar_retirement_provider(session: object | None, module_id: str):
plan = _calendar_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Calendar credential retirement.")
if inspect(execute_session.get_bind()).has_table(calendar_models.CalendarSyncCredential.__tablename__):
from govoplan_calendar.backend.service import delete_calendar_credentials_for_retirement
delete_calendar_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Calendar-owned credentials are deleted immediately before tables are dropped; retirement fails if an external secret provider is unavailable.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -28,11 +357,11 @@ PERMISSIONS = (
_permission("calendar:calendar:write", "Manage calendars", "Create and edit tenant calendar collections."),
_permission("calendar:calendar:admin", "Administer calendars", "Delete calendars and manage tenant-level calendar settings."),
_permission("calendar:event:read", "View calendar events", "List and inspect calendar events."),
_permission("calendar:event:write", "Manage calendar events", "Create and edit calendar events."),
_permission(CALENDAR_EVENT_WRITE_SCOPE, "Manage calendar events", "Create and edit calendar events."),
_permission("calendar:event:delete", "Delete calendar events", "Delete or cancel calendar events where policy allows it."),
_permission("calendar:event:import", "Import iCalendar events", "Import VEVENT data from iCalendar sources."),
_permission("calendar:event:export", "Export iCalendar events", "Export events as text/calendar VEVENT data."),
_permission("calendar:availability:read", "Read availability", "Read free/busy and availability data for integrations."),
_permission(CALENDAR_AVAILABILITY_READ_SCOPE, "Read availability", "Read free/busy and availability data for integrations."),
)
ROLE_TEMPLATES = (
@@ -44,11 +373,11 @@ ROLE_TEMPLATES = (
"calendar:calendar:read",
"calendar:calendar:write",
"calendar:event:read",
"calendar:event:write",
CALENDAR_EVENT_WRITE_SCOPE,
"calendar:event:delete",
"calendar:event:import",
"calendar:event:export",
"calendar:availability:read",
CALENDAR_AVAILABILITY_READ_SCOPE,
),
),
RoleTemplate(
@@ -59,20 +388,34 @@ ROLE_TEMPLATES = (
"calendar:calendar:read",
"calendar:event:read",
"calendar:event:export",
"calendar:availability:read",
CALENDAR_AVAILABILITY_READ_SCOPE,
),
),
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.db.models import (
CalendarCollection,
CalendarEvent,
CalendarOutboxOperation,
CalendarSyncCredential,
CalendarSyncSource,
CalendarViewPreference,
)
return {
"calendars": session.query(CalendarCollection).filter(CalendarCollection.tenant_id == tenant_id, CalendarCollection.deleted_at.is_(None)).count(),
"calendar_events": session.query(CalendarEvent).filter(CalendarEvent.tenant_id == tenant_id, CalendarEvent.deleted_at.is_(None)).count(),
"calendar_sync_sources": session.query(CalendarSyncSource).filter(CalendarSyncSource.tenant_id == tenant_id, CalendarSyncSource.deleted_at.is_(None)).count(),
"calendar_sync_credentials": session.query(CalendarSyncCredential).filter(CalendarSyncCredential.tenant_id == tenant_id, CalendarSyncCredential.deleted_at.is_(None)).count(),
"calendar_view_preferences": session.query(CalendarViewPreference).filter(CalendarViewPreference.tenant_id == tenant_id).count(),
"calendar_outbox_pending": session.query(CalendarOutboxOperation)
.filter(
CalendarOutboxOperation.tenant_id == tenant_id,
CalendarOutboxOperation.status.in_(("pending", "retry", "in_progress")),
)
.count(),
}
@@ -85,42 +428,379 @@ def _calendar_router(context: ModuleContext):
return router
def _calendar_scheduling_provider(context: ModuleContext) -> object:
del context
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
return SqlCalendarSchedulingProvider()
def _calendar_invitation_provider(context: ModuleContext) -> object:
del context
from govoplan_calendar.backend.capabilities import SqlCalendarInvitationProvider
return SqlCalendarInvitationProvider()
def _calendar_outbox_provider(context: ModuleContext) -> object:
from govoplan_calendar.backend.outbox import (
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
SqlCalendarOutboxProvider,
)
return SqlCalendarOutboxProvider(
terminal_retention_days=getattr(
context.settings,
"calendar_outbox_terminal_retention_days",
OUTBOX_DEFAULT_TERMINAL_RETENTION_DAYS,
)
)
def _calendar_external_profile_provider(context: ModuleContext) -> object:
del context
from govoplan_calendar.backend.capabilities import SqlCalendarExternalProfileProvider
return SqlCalendarExternalProfileProvider()
def _caldav_provider_states(context):
from govoplan_calendar.backend.provider_state import caldav_provider_states
return caldav_provider_states(context)
def _ics_provider_states(context):
from govoplan_calendar.backend.provider_state import ics_provider_states
return ics_provider_states(context)
def _graph_provider_states(context):
from govoplan_calendar.backend.provider_state import graph_provider_states
return graph_provider_states(context)
def _ews_provider_states(context):
from govoplan_calendar.backend.provider_state import ews_provider_states
return ews_provider_states(context)
def _open_xchange_provider_states(context):
from govoplan_calendar.backend.provider_state import open_xchange_provider_states
return open_xchange_provider_states(context)
manifest = ModuleManifest(
id="calendar",
name="Calendar",
version="0.1.7",
version="0.1.17",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"),
optional_dependencies=("mail", "tasks", "scheduling", "appointments", "workflow_engine", "notifications", "dms", "connectors", "search"),
provides_interfaces=(
ModuleInterfaceProvider(name="calendar.outbox", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.scheduling", version="0.1.8"),
ModuleInterfaceProvider(name="calendar.invitations", version="0.2.0"),
ModuleInterfaceProvider(name="calendar.external_profiles", version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="search.source",
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_calendar_router,
role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
search_sources=(
SearchSourceProviderRegistration(
id="calendar.events",
factory=create_calendar_search_source,
),
),
architecture=CALENDAR_ARCHITECTURE,
external_providers=CALENDAR_EXTERNAL_PROVIDERS,
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=CALDAV_PROVIDER_ID,
provider=_caldav_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=ICS_PROVIDER_ID,
provider=_ics_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=GRAPH_PROVIDER_ID,
provider=_graph_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=EWS_PROVIDER_ID,
provider=_ews_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="calendar",
provider_id=OPEN_XCHANGE_PROVIDER_ID,
provider=_open_xchange_provider_states,
),
),
documentation=(
DocumentationTopic(
id="calendar.search.events",
title="Search authorized calendar events",
summary="Expose event titles, schedules, locations, and descriptions to permission-aware platform Search.",
body=(
"When Search is installed, Calendar contributes non-deleted events from visible calendars. "
"Every result is tenant-bounded and rechecks the current event-read permission plus private "
"calendar ownership or group membership before it is returned. Committed event changes update "
"the derived index through the platform event outbox; rebuilding Search never changes Calendar data."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "calendar_manager", "administrator"),
related_modules=("search",),
order=19,
),
DocumentationTopic(
id="calendar.manage-calendars-and-events",
title="Use calendars and events",
summary="Create calendar collections and work with all-day or timed events in continuous, month, week, workweek, and day views.",
body="Calendar remembers the selected view and preferences. Events can be created, edited, moved, resized, repeated, imported, exported, or deleted when the current account has the corresponding permission. All-day events use dates rather than local clock times; timed events retain their timezone-aware start and end values.",
documentation_types=("user",),
audience=("user", "calendar_manager"),
related_modules=("scheduling", "notifications"),
metadata={
"kind": "reference",
"help_contexts": [
"calendar.page",
"calendar.page.sidebar",
"calendar.page.agenda",
"calendar.page.workspace",
"calendar.event-editor",
"calendar.settings.preferences",
"calendar.widget.upcoming",
"calendar.state.read-only",
],
"consequence_classes": {
"save_event": "create or update the authoritative event and queue synchronized writes when required",
"delete_event": "delete the selected occurrence or series and queue synchronized deletion when required",
},
},
),
DocumentationTopic(
id="calendar.external-sources-and-sync",
title="Connect and synchronize external calendars",
summary="Calendar supports local collections, two-way CalDAV and Open-Xchange profiles, and read-only ICS/webcal, Microsoft Graph, and Exchange Web Services sources.",
body="Each external source keeps its URL, synchronization direction, status, and credential reference with the Calendar collection. Open-Xchange uses the proven CalDAV transport while retaining connector-profile, identity/group-mapping, and resource-calendar references. Manual or scheduled synchronization records bounded outcomes. Scheduled source and outbox workers partition work by tenant entitlement; disabling Calendar preserves accepted operations and reports operator action instead of contacting a remote provider. CalDAV writes use conditional requests and durable outbox state; conflicts and unknown outcomes require synchronization or explicit reconciliation instead of blind repetition. Moving between two-way CalDAV calendars is an administrator-authorized migration batch: all destination resources must be copied before any source resource is conditionally deleted with its recorded ETag. Calendar and event changes remain locked while progress, conflicts, cancellation eligibility, and evidence are visible. Removing an external source removes the connection, while deleting a local calendar deletes its owned events after confirmation or transfer.",
documentation_types=("admin", "user"),
audience=("user", "calendar_manager", "operator"),
related_modules=("connectors", "audit", "ops"),
metadata={
"kind": "reference",
"help_contexts": [
"calendar.collection-editor",
"calendar.sync-status",
"calendar.migration",
"calendar.state.source-admin-required",
],
"consequence_classes": {
"change_collection": "change calendar identity, source configuration, credentials, and synchronization policy",
"delete_or_remove_collection": "delete a local calendar or remove an external source after explicit event handling",
"synchronize_source": "read and, where configured, write remote state using bounded synchronization evidence",
"force_full_sync": "re-read the complete remote source and reconcile it against local state",
"execute_remote_move": "copy all destination resources before conditionally deleting source resources",
},
},
),
DocumentationTopic(
id="calendar.campaign-invitations-and-replies",
title="Track Campaign invitations and attendee replies",
summary="Mirror accepted Campaign invitation deliveries as correlated VEVENTs and keep attendee participation state authoritative in Calendar.",
body="Campaign can render a METHOD:REQUEST attachment before delivery and create or update the correlated Calendar event only after delivery acceptance. Calendar stores correlation, attendee PARTSTAT, response time, bounded evidence, synchronization state, and any degraded behavior. When Mail watches an authorized IMAP folder, METHOD:REPLY parts are forwarded idempotently to Calendar. Campaign reports query the current state in batches instead of copying it into Campaign records. Recurring Campaign invitation series remain a separate workflow.",
documentation_types=("admin", "user"),
audience=("calendar_manager", "campaign_manager", "operator"),
conditions=(
DocumentationCondition(
required_modules=("calendar", "campaigns"),
required_capabilities=(CAPABILITY_CALENDAR_INVITATIONS,),
any_scopes=(
"calendar:calendar:read",
"calendar:calendar:write",
"calendar:calendar:admin",
),
),
),
related_modules=("campaigns", "mail", "audit"),
metadata={"kind": "workflow"},
),
DocumentationTopic(
id="calendar.outbound-change-recovery",
title="Recover synchronized calendar writes",
summary="Inspect unresolved CalDAV writes and recover only the latest safe resource generation.",
body=(
"Calendar administrators open Outbound changes from a synchronized calendar's settings. "
"The bounded history explains attempts, conflicts, dead work, stale generations, disabled sources, "
"and active worker leases. Retry schedules a failed desired state, reconcile compares it with the "
"remote resource, and discard abandons the local desired state after a separate warning so the next "
"full sync can accept remote. Automatic dispatch and due-source execution remain service-account-only "
"worker operations and are not exposed as interactive controls."
),
documentation_types=("admin", "user"),
audience=("calendar_manager", "operator", "tenant_admin"),
related_modules=("ops", "audit"),
metadata={
"kind": "runbook",
"help_contexts": [
"calendar.outbox",
"calendar.state.outbox-recovery",
],
"consequence_classes": {
"reconcile_outbox": "compare the latest desired generation with remote state before retry or discard",
},
},
),
),
capability_factories={
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
CAPABILITY_CALENDAR_SCHEDULING: _calendar_scheduling_provider,
CAPABILITY_CALENDAR_INVITATIONS: _calendar_invitation_provider,
CAPABILITY_CALENDAR_EXTERNAL_PROFILES: _calendar_external_profile_provider,
},
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
frontend=FrontendModule(
module_id="calendar",
package_name="@govoplan/calendar-webui",
routes=(
FrontendRoute(
path="/calendar",
component="CalendarPage",
required_any=("calendar:event:read",),
order=55,
),
),
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
view_surfaces=(
ViewSurface(
id="calendar.navigation",
module_id="calendar",
kind="navigation",
label="Calendar navigation",
order=10,
),
ViewSurface(
id="calendar.page",
module_id="calendar",
kind="route",
label="Calendar workspace",
order=20,
),
ViewSurface(
id="calendar.page.sidebar",
module_id="calendar",
kind="section",
label="Calendar list",
parent_id="calendar.page",
order=10,
),
ViewSurface(
id="calendar.page.agenda",
module_id="calendar",
kind="section",
label="Calendar agenda",
parent_id="calendar.page.sidebar",
order=20,
),
ViewSurface(
id="calendar.page.workspace",
module_id="calendar",
kind="section",
label="Calendar view",
parent_id="calendar.page",
order=30,
),
ViewSurface(
id="calendar.event-editor",
module_id="calendar",
kind="action",
label="Event editor",
parent_id="calendar.page",
order=40,
),
ViewSurface(
id="calendar.collection-editor",
module_id="calendar",
kind="action",
label="Calendar source editor",
parent_id="calendar.page.sidebar",
order=50,
),
ViewSurface(
id="calendar.sync-status",
module_id="calendar",
kind="section",
label="Calendar synchronization status",
parent_id="calendar.collection-editor",
order=60,
),
ViewSurface(
id="calendar.outbox",
module_id="calendar",
kind="action",
label="Outbound calendar changes",
parent_id="calendar.sync-status",
order=70,
),
ViewSurface(
id="calendar.migration",
module_id="calendar",
kind="action",
label="Remote calendar move",
parent_id="calendar.sync-status",
order=80,
),
ViewSurface(
id="calendar.widget.upcoming",
module_id="calendar",
kind="section",
label="Upcoming events widget",
order=40,
),
ViewSurface(
id="calendar.settings.preferences",
module_id="calendar",
kind="section",
label="Calendar preferences",
order=45,
),
),
),
migration_spec=MigrationSpec(
module_id="calendar",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
label="Calendar",
),
retirement_provider=_calendar_retirement_provider,
retirement_notes="Destructive retirement drops calendar-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
calendar_models.CalendarCollection,
calendar_models.CalendarEvent,
calendar_models.CalendarMigrationBatch,
calendar_models.CalendarMigrationResource,
calendar_models.CalendarOutboxOperation,
calendar_models.CalendarSyncCredential,
calendar_models.CalendarSyncSource,
calendar_models.CalendarViewPreference,
label="Calendar",
),
),
@@ -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",
]
+474 -14
View File
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.core.change_sequence import decode_sequence_watermark, encode_sequence_watermark, max_sequence_id, sequence_entries_since, sequence_watermark_is_expired
from govoplan_calendar.backend.db.models import CalendarEvent
from govoplan_calendar.backend.ical import ICalendarError, event_to_ics, http_last_modified
@@ -27,14 +28,24 @@ from govoplan_calendar.backend.schemas import (
CalendarCollectionListResponse,
CalendarCollectionResponse,
CalendarCollectionUpdateRequest,
CalendarCredentialEnvelopeListResponse,
CalendarCredentialEnvelopeResponse,
CalendarEventCreateRequest,
CalendarEventDeltaResponse,
CalendarEventListResponse,
CalendarEventOccurrenceDeleteRequest,
CalendarEventOccurrenceUpdateRequest,
CalendarEventResponse,
CalendarEventUpdateRequest,
CalendarFreeBusyRequest,
CalendarFreeBusyResponse,
CalendarIcsImportRequest,
CalendarMigrationBatchListResponse,
CalendarMigrationBatchResponse,
CalendarMigrationCancelRequest,
CalendarOutboxDispatchResponse,
CalendarOutboxOperationListResponse,
CalendarOutboxOperationResponse,
CalendarSyncDueSyncItemResponse,
CalendarSyncDueSyncResponse,
CalendarSyncSourceCreateRequest,
@@ -43,12 +54,31 @@ from govoplan_calendar.backend.schemas import (
CalendarSyncSourceSyncRequest,
CalendarSyncSourceSyncResponse,
CalendarSyncSourceUpdateRequest,
CalendarViewPreferencesResponse,
CalendarViewPreferencesUpdateRequest,
)
from govoplan_calendar.backend.migrations_saga import (
CalendarMigrationError,
calendar_migration_response,
cancel_calendar_migration_batch,
get_calendar_migration_batch,
list_calendar_migration_batches,
)
from govoplan_calendar.backend.outbox import (
calendar_outbox_operation_action_states,
calendar_outbox_operation_response,
discard_calendar_outbox_operation,
dispatch_calendar_outbox,
list_calendar_outbox_operations,
reconcile_calendar_outbox_operation,
retry_calendar_outbox_operation,
)
from govoplan_calendar.backend.service import (
CALENDAR_EVENTS_COLLECTION,
CALENDAR_EVENT_RESOURCE,
CALENDAR_MODULE_ID,
CalendarError,
available_calendar_credentials,
caldav_source_response,
caldav_sync_response,
calendar_response,
@@ -60,15 +90,17 @@ from govoplan_calendar.backend.service import (
delete_caldav_source,
delete_sync_source,
delete_event,
delete_event_occurrence,
discover_caldav_calendars,
event_response,
get_caldav_source,
get_calendar_view_preferences,
get_event,
import_ics_event,
list_freebusy,
list_caldav_sources,
list_calendars,
list_events,
list_event_occurrences,
list_sync_sources,
normalize_datetime,
sync_due_sources,
@@ -79,12 +111,23 @@ from govoplan_calendar.backend.service import (
update_caldav_source,
update_sync_source,
update_event,
update_event_occurrence,
update_calendar_view_preferences,
)
from govoplan_core.db.session import get_session
router = APIRouter(prefix="/calendar", tags=["calendar"])
def _caldav_discovery_http_error(
exc: CalendarError | CalDAVError,
) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
@@ -95,6 +138,14 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
def _require_worker_principal(principal: ApiPrincipal) -> None:
if principal.auth_method != "service_account":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This endpoint is restricted to service-account workers.",
)
def _calendar_response(calendar) -> CalendarCollectionResponse:
return CalendarCollectionResponse.model_validate(calendar_response(calendar))
@@ -118,6 +169,25 @@ def _parse_payload_datetime(value) -> datetime | None:
return None
@router.get("/credentials", response_model=CalendarCredentialEnvelopeListResponse)
def api_list_calendar_credentials(
source_id: str | None = None,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
return CalendarCredentialEnvelopeListResponse(
credentials=[
CalendarCredentialEnvelopeResponse.model_validate(item)
for item in available_calendar_credentials(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
)
]
)
def _event_interval_overlaps(payload: dict, *, prefix: str, start_at: datetime | None, end_at: datetime | None) -> bool:
event_start = _parse_payload_datetime(payload.get(f"{prefix}start_at"))
event_end = _parse_payload_datetime(payload.get(f"{prefix}end_at")) or event_start
@@ -220,13 +290,99 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
def _outbox_operation_response(
session: Session,
operation,
) -> CalendarOutboxOperationResponse:
actions = calendar_outbox_operation_action_states(session, [operation])
return CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(
operation,
actions=actions.get(operation.id),
)
)
def _migration_response(session: Session, batch) -> CalendarMigrationBatchResponse:
return CalendarMigrationBatchResponse.model_validate(calendar_migration_response(session, batch))
@router.get("/migrations", response_model=CalendarMigrationBatchListResponse)
def api_list_calendar_migrations(
calendar_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
migrations = list_calendar_migration_batches(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
limit=limit,
)
response = CalendarMigrationBatchListResponse(
migrations=[_migration_response(session, item) for item in migrations]
)
session.commit()
return response
@router.get("/migrations/{batch_id}", response_model=CalendarMigrationBatchResponse)
def api_get_calendar_migration(
batch_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
batch = get_calendar_migration_batch(session, tenant_id=principal.tenant_id, batch_id=batch_id)
response = _migration_response(session, batch)
session.commit()
return response
except CalendarMigrationError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/migrations/{batch_id}/cancel", response_model=CalendarMigrationBatchResponse)
def api_cancel_calendar_migration(
batch_id: str,
payload: CalendarMigrationCancelRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
batch = cancel_calendar_migration_batch(
session,
tenant_id=principal.tenant_id,
batch_id=batch_id,
evidence_note=payload.evidence_note,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
response = _migration_response(session, batch)
session.commit()
return response
except CalendarMigrationError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/calendars", response_model=CalendarCollectionListResponse)
def api_list_calendars(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:read")
calendars = list_calendars(session, tenant_id=principal.tenant_id, user_id=principal.user.id)
calendars = list_calendars(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
group_ids=principal.group_ids,
can_admin=principal.has("calendar:calendar:admin"),
)
session.commit()
return CalendarCollectionListResponse(calendars=[_calendar_response(calendar) for calendar in calendars])
@@ -275,7 +431,14 @@ def api_delete_calendar(
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_calendar(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, payload=payload)
delete_calendar(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
@@ -321,7 +484,14 @@ def api_update_sync_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = update_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
source = update_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
session.refresh(source)
return _sync_source_response(source)
@@ -338,12 +508,23 @@ def api_delete_sync_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_sync_source(session, tenant_id=principal.tenant_id, source_id=source_id)
delete_sync_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/sync-sources/{source_id}/sync", response_model=CalendarSyncSourceSyncResponse)
@@ -380,6 +561,7 @@ def api_sync_due_sources(
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
_require_worker_principal(principal)
results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit()
return CalendarSyncDueSyncResponse(
@@ -422,7 +604,7 @@ def api_discover_caldav_calendars(
calendars = discover_caldav_calendars(session, tenant_id=principal.tenant_id, payload=payload)
return CalendarCalDavDiscoveryResponse(calendars=calendars)
except (CalendarError, CalDAVError) as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
raise _caldav_discovery_http_error(exc) from exc
@router.post("/caldav/sources", response_model=CalendarCalDavSourceResponse, status_code=status.HTTP_201_CREATED)
@@ -451,7 +633,14 @@ def api_update_caldav_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
source = update_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id, payload=payload)
source = update_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
payload=payload,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
session.refresh(source)
return _caldav_source_response(source)
@@ -468,12 +657,23 @@ def api_delete_caldav_source(
):
_require_scope(principal, "calendar:calendar:admin")
try:
delete_caldav_source(session, tenant_id=principal.tenant_id, source_id=source_id)
delete_caldav_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
user_id=principal.user.id,
api_key_id=principal.api_key_id,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except CalendarError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
error_status = (
status.HTTP_404_NOT_FOUND
if "not found" in str(exc).lower()
else status.HTTP_409_CONFLICT
)
raise HTTPException(status_code=error_status, detail=str(exc)) from exc
@router.post("/caldav/sources/{source_id}/sync", response_model=CalendarCalDavSyncResponse)
@@ -510,6 +710,7 @@ def api_sync_due_caldav_sources(
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:import")
_require_worker_principal(principal)
results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit()
return CalendarCalDavDueSyncResponse(
@@ -530,15 +731,165 @@ def api_sync_due_caldav_sources(
)
@router.get("/caldav/outbox", response_model=CalendarOutboxOperationListResponse)
def api_list_caldav_outbox(
operation_status: str | None = Query(default=None, alias="status"),
source_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
operations = list_calendar_outbox_operations(
session,
tenant_id=principal.tenant_id,
status=operation_status,
source_id=source_id,
limit=limit,
)
action_states = calendar_outbox_operation_action_states(session, operations)
return CalendarOutboxOperationListResponse(
operations=[
CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(
operation,
actions=action_states.get(operation.id),
)
)
for operation in operations
]
)
@router.post("/caldav/outbox/dispatch", response_model=CalendarOutboxDispatchResponse)
def api_dispatch_caldav_outbox(
limit: int = Query(default=50, ge=1, le=200),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
_require_worker_principal(principal)
return CalendarOutboxDispatchResponse.model_validate(
dispatch_calendar_outbox(
session,
tenant_id=principal.tenant_id,
limit=limit,
)
)
@router.post(
"/caldav/outbox/{operation_id}/retry",
response_model=CalendarOutboxOperationResponse,
)
def api_retry_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = retry_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(session, operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/reconcile",
response_model=CalendarOutboxOperationResponse,
)
def api_reconcile_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:calendar:admin")
try:
operation = reconcile_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(session, operation)
except (ValueError, CalDAVError, CalendarError) as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post(
"/caldav/outbox/{operation_id}/discard",
response_model=CalendarOutboxOperationResponse,
)
def api_discard_caldav_outbox_operation(
operation_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
"""Abandon local desired state and allow the next sync to accept remote."""
_require_scope(principal, "calendar:calendar:admin")
try:
operation = discard_calendar_outbox_operation(
session,
tenant_id=principal.tenant_id,
operation_id=operation_id,
)
session.commit()
session.refresh(operation)
return _outbox_operation_response(session, operation)
except ValueError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@router.get("/events", response_model=CalendarEventListResponse)
def api_list_events(
calendar_id: str | None = None,
start_at: datetime | None = Query(default=None),
end_at: datetime | None = Query(default=None),
expand_recurring: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
if expand_recurring:
if start_at is None or end_at is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
"start_at and end_at are required when expanding "
"recurring events"
),
)
try:
occurrences = list_event_occurrences(
session,
tenant_id=principal.tenant_id,
calendar_id=calendar_id,
start_at=start_at,
end_at=end_at,
)
return CalendarEventListResponse(
events=[
CalendarEventResponse.model_validate(item)
for item in occurrences
]
)
except CalendarError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
events = list_events(session, tenant_id=principal.tenant_id, calendar_id=calendar_id, start_at=start_at, end_at=end_at)
return CalendarEventListResponse(events=[_event_response(event) for event in events])
@@ -600,7 +951,7 @@ def api_create_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = create_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, payload=payload)
session.commit()
@@ -631,7 +982,7 @@ def api_update_event(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:write")
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = update_event(session, tenant_id=principal.tenant_id, user_id=principal.user.id, event_id=event_id, payload=payload)
session.commit()
@@ -642,13 +993,77 @@ def api_update_event(
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.patch(
"/events/{series_event_id}/occurrence",
response_model=CalendarEventResponse,
)
def api_update_event_occurrence(
series_event_id: str,
payload: CalendarEventOccurrenceUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, CALENDAR_EVENT_WRITE_SCOPE)
try:
event = update_event_occurrence(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
series_event_id=series_event_id,
payload=payload,
)
session.commit()
session.refresh(event)
return _event_response(event)
except CalendarError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.delete(
"/events/{series_event_id}/occurrence",
response_model=CalendarEventResponse,
)
def api_delete_event_occurrence(
series_event_id: str,
payload: CalendarEventOccurrenceDeleteRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_any_scope(
principal,
"calendar:event:delete",
CALENDAR_EVENT_WRITE_SCOPE,
)
try:
event = delete_event_occurrence(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
series_event_id=series_event_id,
recurrence_id=payload.recurrence_id,
)
session.commit()
session.refresh(event)
return _event_response(event)
except CalendarError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.delete("/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_event(
event_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_any_scope(principal, "calendar:event:delete", "calendar:event:write")
_require_any_scope(principal, "calendar:event:delete", CALENDAR_EVENT_WRITE_SCOPE)
try:
delete_event(session, tenant_id=principal.tenant_id, event_id=event_id, user_id=principal.user.id)
session.commit()
@@ -658,6 +1073,51 @@ def api_delete_event(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get(
"/preferences/view",
response_model=CalendarViewPreferencesResponse,
)
def api_get_calendar_view_preferences(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
return CalendarViewPreferencesResponse.model_validate(
get_calendar_view_preferences(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
)
)
@router.patch(
"/preferences/view",
response_model=CalendarViewPreferencesResponse,
)
def api_update_calendar_view_preferences(
payload: CalendarViewPreferencesUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:event:read")
try:
response = update_calendar_view_preferences(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
payload=payload,
)
session.commit()
return CalendarViewPreferencesResponse.model_validate(response)
except CalendarError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post("/events/import-ics", response_model=CalendarEventResponse, status_code=status.HTTP_201_CREATED)
def api_import_ics_event(
payload: CalendarIcsImportRequest,
@@ -713,7 +1173,7 @@ def api_freebusy(
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "calendar:availability:read")
_require_scope(principal, CALENDAR_AVAILABILITY_READ_SCOPE)
try:
busy = list_freebusy(
session,
+6 -32
View File
@@ -1,36 +1,10 @@
from __future__ import annotations
from typing import Any
from govoplan_core.core.runtime import ModuleRuntimeState
_runtime_registry: object | None = None
_runtime_settings: object | None = None
_runtime = ModuleRuntimeState("Calendar")
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_registry, _runtime_settings
if registry is not None:
_runtime_registry = registry
if settings is not None:
_runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object:
if _runtime_settings is not None:
return _runtime_settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError("GovOPlaN Calendar runtime settings are not configured") from exc
return legacy_settings
class SettingsProxy:
def __getattr__(self, name: str) -> Any:
return getattr(get_settings(), name)
settings = SettingsProxy()
configure_runtime = _runtime.configure_runtime
get_registry = _runtime.get_registry
get_settings = _runtime.get_settings
settings = _runtime.settings
+168
View File
@@ -11,6 +11,11 @@ from govoplan_core.api.v1.schemas import DeltaDeletedItem
CalendarOwnerType = Literal["tenant", "user", "group", "resource"]
CalendarVisibility = Literal["private", "tenant", "shared", "public"]
CalendarDeleteEventAction = Literal["delete", "move"]
CalendarBulkMoveExternalAction = Literal[
"detach_keep_remote",
"copy_to_remote",
"remote_move",
]
CalendarSyncAuthType = Literal["none", "basic", "bearer"]
CalendarSyncDirection = Literal["inbound", "two_way"]
CalendarSyncSourceKind = Literal["caldav", "ics", "webcal", "graph", "ews"]
@@ -50,6 +55,9 @@ class CalendarCollectionDeleteRequest(BaseModel):
event_action: CalendarDeleteEventAction = "delete"
target_calendar_id: str | None = None
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):
@@ -73,6 +81,55 @@ class CalendarCollectionListResponse(BaseModel):
calendars: list[CalendarCollectionResponse] = Field(default_factory=list)
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")
evidence_note: str = Field(min_length=10, max_length=2000)
class CalendarSyncSourceCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -130,6 +187,7 @@ class CalendarSyncSourceResponse(BaseModel):
auth_type: str
username: str | None = None
credential_ref: str | None = None
credential_envelope_id: str | None = None
has_credential: bool = False
sync_enabled: bool
sync_interval_seconds: int
@@ -159,6 +217,26 @@ class CalendarCalDavSourceListResponse(BaseModel):
sources: list[CalendarCalDavSourceResponse] = Field(default_factory=list)
class CalendarCredentialEnvelopeResponse(BaseModel):
id: str
scope_type: str
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_active: bool = True
revision: str
class CalendarCredentialEnvelopeListResponse(BaseModel):
credentials: list[CalendarCredentialEnvelopeResponse] = Field(default_factory=list)
class CalendarCalDavDiscoveryRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -238,6 +316,58 @@ class CalendarCalDavDueSyncResponse(BaseModel):
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):
model_config = ConfigDict(extra="forbid")
@@ -304,6 +434,16 @@ class CalendarEventUpdateRequest(BaseModel):
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):
id: str
tenant_id: str
@@ -338,6 +478,10 @@ class CalendarEventResponse(BaseModel):
created_at: datetime
updated_at: datetime
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):
@@ -352,6 +496,30 @@ class CalendarEventDeltaResponse(BaseModel):
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):
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
+59 -15
View File
@@ -6,22 +6,66 @@ from celery import shared_task
@shared_task(name="govoplan_calendar.sync_due_caldav_sources")
def sync_due_caldav_sources_task(tenant_id: str | None = None, limit: int = 50) -> list[dict[str, object]]:
from govoplan_calendar.backend.service import sync_due_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.settings import settings
with get_database().SessionLocal() as session:
results = sync_due_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()
return [
{
"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
]
return payload
+786 -46
View File
@@ -2,32 +2,63 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarSyncCredential, CalendarSyncSource
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
from govoplan_calendar.backend.caldav import (
CalDAVClient,
CalDAVDiscoveryCalendar,
CalDAVError,
CalDAVNotFound,
CalDAVObject,
CalDAVPreconditionFailed,
CalDAVReportResult,
CalDAVWriteResult,
parse_discovery_multistatus,
parse_multistatus,
)
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
from govoplan_calendar.backend.manifest import manifest
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
from govoplan_calendar.backend.schemas import (
CalendarCalDavDiscoveryRequest,
CalendarCalDavSourceCreateRequest,
CalendarCalDavSourceUpdateRequest,
CalendarCollectionCreateRequest,
CalendarEventCreateRequest,
CalendarEventUpdateRequest,
)
from govoplan_calendar.backend.service import (
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
CalendarError,
_CalDAVDiscoveryAuth,
_caldav_discovery_client,
_caldav_discovery_response,
_plan_sync_source_creation,
caldav_client_for_source,
caldav_source_response,
create_calendar,
create_caldav_source,
create_event,
delete_caldav_source,
delete_calendar,
delete_event,
discover_caldav_calendars,
list_caldav_sources,
list_freebusy,
resolve_caldav_credential_ref,
resolve_trusted_deployment_caldav_credential_ref,
sync_caldav_source,
sync_due_caldav_sources,
update_caldav_source,
update_event,
)
from govoplan_calendar.backend.router import _caldav_discovery_http_error
from govoplan_core.db.base import Base
from govoplan_core.security.credential_envelopes import create_credential_envelope
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
@@ -84,7 +115,93 @@ class FakeCalDAVClient:
return CalDAVWriteResult(href=href, status=204)
class FakeNotificationProvider:
def __init__(self) -> None:
self.requests: list[object] = []
def enqueue_notification(self, _session, request, **_kwargs) -> None:
self.requests.append(request)
class CalDAVParsingTests(unittest.TestCase):
def test_discovery_parser_is_independent_from_transport(self) -> None:
result = parse_discovery_multistatus(
b"""<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:response>
<D:href>/calendars/ada/work/</D:href>
<D:propstat>
<D:prop>
<D:displayname>Work</D:displayname>
<D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>"""
)
self.assertEqual(1, len(result))
self.assertEqual("Work", result[0].display_name)
self.assertTrue(result[0].is_calendar)
self.assertEqual(("VEVENT",), result[0].supported_components)
def test_discovery_auth_client_and_response_do_not_expose_secret(self) -> None:
auth = _CalDAVDiscoveryAuth(
auth_type="basic",
username="ada",
credential_ref=None,
secret="calendar-secret",
)
client = _caldav_discovery_client(
"https://dav.example.test/cal",
auth,
)
response = _caldav_discovery_response(
(
CalDAVDiscoveryCalendar(
collection_url="https://dav.example.test/cal/work/",
href="/cal/work/",
display_name="Work",
),
)
)
self.assertNotIn("calendar-secret", repr(auth))
self.assertEqual("calendar-secret", client.password)
self.assertEqual("Work", response[0]["display_name"])
self.assertNotIn("password", response[0])
self.assertNotIn("credential_ref", response[0])
def test_discovery_error_translation_is_stable(self) -> None:
error = _caldav_discovery_http_error(
CalDAVError("CalDAV endpoint rejected PROPFIND")
)
self.assertEqual(422, error.status_code)
self.assertEqual(
"CalDAV endpoint rejected PROPFIND",
error.detail,
)
def test_sync_source_creation_plan_is_secret_free(self) -> None:
plan = _plan_sync_source_creation(
CalendarCalDavSourceCreateRequest(
calendar_id="calendar-1",
collection_url="dav.example.test/cal",
auth_type="basic",
username="ada",
password="calendar-secret",
)
)
self.assertEqual("https://dav.example.test/cal/", plan.collection_url)
self.assertTrue(plan.has_inline_secret)
self.assertNotIn("calendar-secret", repr(plan))
def test_parse_multistatus_extracts_objects_sync_token_and_deletions(self) -> None:
result = parse_multistatus(
b"""<?xml version="1.0" encoding="utf-8"?>
@@ -216,10 +333,20 @@ END:VCALENDAR</C:calendar-data>
self.assertEqual(calendars[0].collection_url, "https://cloud.example.test/remote.php/dav/calendars/ada/personal/")
def test_discovery_reports_relative_url_as_caldav_error(self) -> None:
client = CalDAVClient(collection_url="/remote.php/dav")
with self.assertRaisesRegex(CalDAVError, "absolute HTTP"):
CalDAVClient(collection_url="/remote.php/dav")
with self.assertRaisesRegex(CalDAVError, "unknown url type"):
client.discover_calendars()
def test_object_url_rejects_off_origin_and_cross_collection_hrefs(self) -> None:
client = CalDAVClient(collection_url="https://dav.example.test/cal")
with self.assertRaisesRegex(CalDAVError, "collection origin"):
client.object_url("https://evil.example.test/steal.ics")
with self.assertRaisesRegex(CalDAVError, "collection path"):
client.object_url("https://dav.example.test/other/steal.ics")
self.assertEqual(
client.object_url("/cal/event.ics"),
"https://dav.example.test/cal/event.ics",
)
class CalDAVSyncTests(unittest.TestCase):
@@ -228,12 +355,21 @@ class CalDAVSyncTests(unittest.TestCase):
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session(self):
session = self.Session()
self.sessions.append(session)
return session
def test_source_creation_encrypts_credential_and_resolves_client_secret(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
@@ -259,8 +395,261 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
def test_source_can_resolve_reusable_core_credential(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
credential = create_credential_envelope(
session,
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Shared DAV login",
credential_kind="username_password",
public_data={"username": "ada"},
secret_data={"password": "secret"},
allowed_modules=["calendar"],
inherit_to_lower_scopes=True,
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
credential_ref=f"credential-envelope:{credential.id}",
),
)
session.commit()
client = caldav_client_for_source(session, source)
response = caldav_source_response(source)
self.assertEqual(client.username, "ada")
self.assertEqual(client.password, "secret")
self.assertEqual(response["credential_envelope_id"], credential.id)
self.assertEqual(session.query(CalendarSyncCredential).count(), 0)
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="env:MASTER_KEY_B64",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
discover_caldav_calendars(
session,
tenant_id="tenant-1",
payload=CalendarCalDavDiscoveryRequest(
url="https://attacker.example.test/cal",
auth_type="bearer",
credential_ref="vault:another-tenant",
),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(
credential_ref="env:MASTER_KEY_B64",
),
)
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
auth_type="basic",
username="ada",
password="secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
self.assertNotEqual(source.credential_ref, provider_ref)
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
self.assertIsNone(caldav_source_response(source)["credential_ref"])
self.assertTrue(caldav_source_response(source)["has_credential"])
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="another-tenant",
source_id=source.id,
credential_ref=source.credential_ref,
)
)
self.assertIsNone(
resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id="another-source",
credential_ref=source.credential_ref,
)
)
other_calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Other remote"),
)
other_source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=other_calendar.id,
collection_url="https://dav.example.test/other-cal",
),
)
other_source.auth_type = "basic"
other_source.username = "mallory"
other_source.credential_ref = source.credential_ref
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, other_source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
self.assertEqual(provider.deleted, [])
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.deleted, [provider_ref])
self.assertNotIn(provider_ref, provider.values)
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertNotIn("provider_ref", credential.metadata_ or {})
audit.assert_called_once()
audit_call = audit.call_args.kwargs
self.assertEqual(audit_call["action"], "calendar.sync_credential_deleted")
self.assertEqual(audit_call["object_id"], credential.id)
self.assertEqual(audit_call["details"]["storage_backend"], "external_secret_provider")
self.assertEqual(audit_call["details"]["deletion_reason"], "calendar_deleted")
self.assertNotIn("credential_ref", audit_call["details"])
self.assertNotIn("provider_ref", audit_call["details"])
self.assertNotIn(provider_ref, repr(audit_call["details"]))
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
class Provider:
def __init__(self) -> None:
self.read: list[str] = []
self.deleted: list[str] = []
def read_secret(self, secret_ref: str) -> str | None:
self.read.append(secret_ref)
return "other-tenant-secret"
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/cal",
),
)
source.auth_type = "bearer"
source.credential_ref = "vault:another-tenant"
session.commit()
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
caldav_client_for_source(session, source)
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
self.assertEqual(provider.read, [])
self.assertEqual(provider.deleted, [])
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
self.assertEqual(
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
"trusted-token",
)
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
resolve_trusted_deployment_caldav_credential_ref("vault:token")
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
@@ -282,11 +671,319 @@ class CalDAVSyncTests(unittest.TestCase):
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(credential.secret_encrypted)
self.assertEqual(credential.metadata_, {"source_id": source.id})
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_external_credential_deletion_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/provider-down",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"secret provider is unavailable",
):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIn(provider_ref, provider.values)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider), patch.object(
provider,
"delete_secret",
side_effect=RuntimeError(f"provider failed for {provider_ref}"),
):
with self.assertRaises(CalendarError) as raised:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, str(raised.exception))
session.rollback()
self.assertIsNone(source.deleted_at)
self.assertIsNone(credential.deleted_at)
def test_disabling_auth_immediately_scrubs_and_audits_database_credential(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/disable-auth",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
self.assertIsNotNone(credential.secret_encrypted)
with patch("govoplan_calendar.backend.service.audit_event") as audit:
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(auth_type="none"),
)
session.commit()
self.assertIsNone(source.credential_ref)
self.assertIsNone(credential.secret_encrypted)
self.assertIsNotNone(credential.deleted_at)
audit.assert_called_once()
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"authentication_disabled",
)
self.assertNotIn("do-not-log-this", repr(audit.call_args))
def test_external_credential_replacement_fails_closed_when_provider_is_unavailable(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/replacement-provider-down",
auth_type="bearer",
bearer_token="old-secret",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.secret_provider", return_value=None), self.assertRaisesRegex(
CalendarError,
"cannot be replaced.*provider is unavailable",
):
update_caldav_source(
session,
tenant_id="tenant-1",
source_id=source.id,
payload=CalendarCalDavSourceUpdateRequest(bearer_token="new-secret"),
)
session.rollback()
self.assertEqual(source.credential_ref, f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{credential.id}")
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertEqual(provider.values[provider_ref], "old-secret")
def test_external_credential_delete_is_retryable_after_database_rollback(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deletes: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deletes.append(secret_ref)
if secret_ref not in self.values:
raise KeyError("already deleted")
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/retry-delete",
auth_type="basic",
username="ada",
password="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
with patch("govoplan_calendar.backend.service.audit_event"):
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
self.assertNotIn(provider_ref, provider.values)
self.assertIsNotNone(credential.deleted_at)
self.assertIsNone(source.credential_ref)
session.rollback()
self.assertIsNone(source.deleted_at)
session.refresh(credential)
self.assertIsNone(credential.deleted_at)
self.assertEqual((credential.metadata_ or {})["provider_ref"], provider_ref)
self.assertIsNone(resolve_caldav_credential_ref(
session,
tenant_id="tenant-1",
source_id=source.id,
credential_ref=source.credential_ref,
))
with patch("govoplan_calendar.backend.service.audit_event") as audit:
delete_caldav_source(session, tenant_id="tenant-1", source_id=source.id)
session.commit()
self.assertEqual(provider.deletes, [provider_ref, provider_ref])
self.assertIsNotNone(source.deleted_at)
self.assertIsNone(source.credential_ref)
self.assertIsNotNone(credential.deleted_at)
self.assertEqual(audit.call_count, 1)
def test_destructive_module_retirement_deletes_external_credentials_before_tables(self) -> None:
class Provider:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.deleted: list[str] = []
def store_secret(self, *, scope: str, name: str, value: str) -> str:
reference = f"vault:{scope}:{name}"
self.values[reference] = value
return reference
def read_secret(self, secret_ref: str) -> str | None:
return self.values.get(secret_ref)
def delete_secret(self, secret_ref: str) -> None:
self.deleted.append(secret_ref)
self.values.pop(secret_ref, None)
provider = Provider()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCollectionCreateRequest(name="Remote"),
)
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(
calendar_id=calendar.id,
collection_url="https://dav.example.test/module-retirement",
auth_type="bearer",
bearer_token="do-not-log-this",
),
)
session.commit()
credential = session.query(CalendarSyncCredential).one()
provider_ref = str((credential.metadata_ or {})["provider_ref"])
retirement_provider = manifest.migration_spec.retirement_provider
assert retirement_provider is not None
plan = retirement_provider(session, "calendar")
assert plan.destroy_data_executor is not None
with patch("govoplan_calendar.backend.service.audit_event") as audit:
plan.destroy_data_executor(session, "calendar")
# Retirement intentionally participates in the caller's database
# transaction. Commit before inspecting through a new Engine
# connection; otherwise SQLite rolls back the uncommitted DDL when
# the temporary inspector connection is returned to the pool.
session.commit()
self.assertEqual(provider.deleted, [provider_ref])
self.assertEqual(provider.values, {})
self.assertEqual(audit.call_count, 1)
self.assertEqual(
audit.call_args.kwargs["details"]["deletion_reason"],
"module_data_retired",
)
self.assertFalse(inspect(self.engine).has_table("calendar_sync_credentials"))
self.assertFalse(inspect(self.engine).has_table("calendar_sync_sources"))
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old"))
old_source = create_caldav_source(
@@ -312,7 +1009,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id])
def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First"))
second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second"))
@@ -333,7 +1030,7 @@ class CalDAVSyncTests(unittest.TestCase):
)
def test_due_sync_runs_due_sources_and_reschedules(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
@@ -359,40 +1056,71 @@ class CalDAVSyncTests(unittest.TestCase):
next_sync_at = source.next_sync_at if source.next_sync_at.tzinfo else source.next_sync_at.replace(tzinfo=timezone.utc)
self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc))
def test_create_and_update_event_puts_caldav_resource_with_etag(self) -> None:
session = self.Session()
def test_due_sync_emits_recovery_notification_after_previous_error(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"),
)
source.last_status = "error"
source.next_sync_at = datetime(2026, 7, 7, 8, 0, tzinfo=timezone.utc)
session.commit()
provider = FakeNotificationProvider()
fake = FakeCalDAVClient(list_result=CalDAVReportResult(sync_token="token-1"))
with patch("govoplan_calendar.backend.service.notification_dispatch_provider", return_value=provider):
results = sync_due_caldav_sources(
session,
tenant_id="tenant-1",
now=datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc),
client_factory=lambda _source: fake,
)
self.assertEqual(results[0].status, "ok")
self.assertEqual(len(provider.requests), 1)
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None:
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-1"', '"etag-2"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
event = create_event(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=calendar.id,
uid="event-1@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),
),
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
fake = FakeCalDAVClient(put_etags=['"etag-1"'])
event = create_event(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarEventCreateRequest(
calendar_id=calendar.id,
uid="event-1@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),
),
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
self.assertEqual(fake.puts, [])
session.commit()
self.assertEqual(len(fake.puts), 2)
operations = session.query(CalendarOutboxOperation).all()
self.assertCountEqual([operation.status for operation in operations], ["superseded", "pending"])
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1)
self.assertTrue(fake.puts[0]["create"])
self.assertEqual(fake.puts[1]["etag"], '"etag-1"')
self.assertFalse(fake.puts[1]["create"])
self.assertIn("SUMMARY:Updated", fake.puts[0]["ics"])
self.assertEqual(event.source_kind, "caldav")
self.assertEqual(event.etag, '"etag-2"')
self.assertEqual(event.etag, '"etag-1"')
self.assertIn("SUMMARY:Updated", event.raw_ics or "")
def test_update_event_reports_remote_etag_conflict(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -400,13 +1128,25 @@ class CalDAVSyncTests(unittest.TestCase):
session.add(event)
session.commit()
fake = FakeCalDAVClient(fail_precondition=True)
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
with self.assertRaisesRegex(CalendarError, "changed remotely"):
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
fake = FakeCalDAVClient(
fail_precondition=True,
objects={event.source_href: recurring_resource()},
)
update_event(session, tenant_id="tenant-1", user_id=None, event_id=event.id, payload=CalendarEventUpdateRequest(summary="Updated"))
session.commit()
result = dispatch_calendar_outbox(
session,
tenant_id="tenant-1",
client_factory=lambda _session, _source: fake,
)
self.assertEqual(result["failed"], 1)
operation = session.query(CalendarOutboxOperation).one()
self.assertEqual(operation.status, "conflict")
self.assertIn("changed remotely", operation.last_error or "")
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -417,9 +1157,9 @@ class CalDAVSyncTests(unittest.TestCase):
session.commit()
fake = FakeCalDAVClient(put_etags=['"etag-2"'])
with patch("govoplan_calendar.backend.service.caldav_client_for_source", return_value=fake):
delete_event(session, tenant_id="tenant-1", event_id=override.id)
delete_event(session, tenant_id="tenant-1", event_id=override.id)
session.commit()
dispatch_calendar_outbox(session, tenant_id="tenant-1", client_factory=lambda _session, _source: fake)
self.assertEqual(len(fake.puts), 1)
self.assertEqual(fake.deletes, [])
@@ -429,7 +1169,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(override.deleted_at)
def test_freebusy_expands_recurring_events_and_skips_transparent_items(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Local"))
create_event(
@@ -471,7 +1211,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc))
def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None:
session = self.Session()
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(
@@ -512,7 +1252,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(calendar.metadata_["source_kind"], "caldav")
def test_sync_token_deletion_soft_deletes_remote_resource_events(self) -> None:
session = self.Session()
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
@@ -561,7 +1301,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(event.deleted_at)
def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None:
session = self.Session()
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
+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()
+285
View File
@@ -0,0 +1,285 @@
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 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 test_event_request_validation_is_exposed_as_capability_error(self) -> None:
provider = SqlCalendarSchedulingProvider()
with self.assertRaises(CalendarCapabilityError):
provider.create_event(
object(),
tenant_id="tenant-1",
user_id="user-1",
request=CalendarEventRequest(
summary="x" * 501,
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
),
)
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()
+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()
@@ -0,0 +1,70 @@
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_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.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"])
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", "DocumentationHelpLink", "useUnsavedDraftGuard"):
self.assertIn(component, event_dialog)
for component in ("ActionBlockerHint", "DocumentationHelpLink", "useUnsavedDraftGuard"):
self.assertIn(component, collection_dialog)
for component in ("DocumentationHelpLink", "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()
+256
View File
@@ -0,0 +1,256 @@
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 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,
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_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()
+62 -1
View File
@@ -6,7 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from govoplan_calendar.backend.schemas import CalendarCollectionDeleteRequest, CalendarEventResponse
from govoplan_calendar.backend.service import delete_calendar, event_response
from govoplan_calendar.backend.service import calendar_is_visible_to_principal, delete_calendar, event_response
class FakeSession:
@@ -65,6 +65,67 @@ class CalendarServiceResponseTests(unittest.TestCase):
self.assertEqual(response.start_at.tzinfo, timezone.utc)
class CalendarVisibilityTests(unittest.TestCase):
@staticmethod
def calendar(**overrides):
values = {
"visibility": "private",
"owner_type": "user",
"owner_id": "owner-1",
"created_by_user_id": "creator-1",
}
values.update(overrides)
return SimpleNamespace(**values)
def test_tenant_shared_and_public_calendars_are_visible_to_readers(self) -> None:
for visibility in ("tenant", "shared", "public"):
with self.subTest(visibility=visibility):
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(visibility=visibility),
user_id="reader-1",
)
)
def test_private_calendar_is_visible_to_user_owner_creator_or_group_member(self) -> None:
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(),
user_id="owner-1",
)
)
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(),
user_id="creator-1",
)
)
self.assertTrue(
calendar_is_visible_to_principal(
self.calendar(owner_type="group", owner_id="group-1"),
user_id="member-1",
group_ids=("group-1",),
)
)
def test_private_calendar_is_hidden_from_other_readers_but_visible_to_admin(self) -> None:
calendar = self.calendar()
self.assertFalse(
calendar_is_visible_to_principal(
calendar,
user_id="other-1",
group_ids=("group-2",),
)
)
self.assertTrue(
calendar_is_visible_to_principal(
calendar,
user_id="other-1",
can_admin=True,
)
)
class CalendarServiceDeleteTests(unittest.TestCase):
def test_delete_default_calendar_soft_deletes_calendar_and_events(self) -> None:
session = FakeSession()
+176 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate users/accounts tables
from govoplan_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, sync_source
from govoplan_calendar.backend.service import CalendarError, create_calendar, create_event, create_sync_source, soft_delete_unseen_source_events, sync_source
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_tenancy.backend.db.models import Tenant
@@ -23,12 +23,17 @@ class CalendarSyncSourceTests(unittest.TestCase):
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()
@@ -78,6 +83,55 @@ END:VCALENDAR
),
)
def test_ics_not_modified_sync_clears_error_and_reschedules(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ics",
calendar_id=calendar.id,
collection_url="https://calendar.example.test/feed.ics",
),
)
source.ctag = '"feed-1"'
source.last_status = "error"
source.last_error = "previous failure"
with patch("govoplan_calendar.backend.service.http_request", return_value=(304, {}, "")):
refreshed, stats = sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
self.assertEqual(refreshed.last_status, "ok")
self.assertIsNone(refreshed.last_error)
self.assertIsNotNone(refreshed.last_synced_at)
self.assertIsNotNone(refreshed.next_sync_at)
self.assertEqual(stats.created, 0)
self.assertEqual(stats.updated, 0)
self.assertEqual(stats.deleted, 0)
def test_ics_sync_failure_records_error_and_reschedules(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="ics",
calendar_id=calendar.id,
collection_url="https://calendar.example.test/feed.ics",
),
)
with patch("govoplan_calendar.backend.service.http_request", side_effect=RuntimeError("network down")):
with self.assertRaisesRegex(RuntimeError, "network down"):
sync_source(session, tenant_id="tenant-1", user_id=None, source_id=source.id)
self.assertEqual(source.last_status, "error")
self.assertIn("network down", source.last_error or "")
self.assertIsNotNone(source.last_attempt_at)
self.assertIsNotNone(source.next_sync_at)
def test_graph_sync_imports_delta_events(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
@@ -117,6 +171,74 @@ END:VCALENDAR
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(
@@ -168,6 +290,59 @@ END:VCALENDAR
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()
+11 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.7",
"version": "0.1.17",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,15 +13,19 @@
},
"./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": {
"@govoplan/core-webui": "^0.1.7",
"@govoplan/core-webui": "^0.1.17",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@vitejs/plugin-react": "^4.3.4",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+234 -2
View File
@@ -51,6 +51,10 @@ export type CalendarEvent = {
created_at: string;
updated_at: string;
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[] };
@@ -78,7 +82,7 @@ export type CalendarSyncSource = {
display_name?: string | null;
auth_type: CalendarCalDavAuthType;
username?: string | null;
credential_ref?: string | null;
credential_envelope_id?: string | null;
has_credential: boolean;
sync_enabled: boolean;
sync_interval_seconds: number;
@@ -143,6 +147,26 @@ 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;
@@ -167,6 +191,78 @@ export type CalendarSyncSourceSyncResponse = {
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 = {
name: string;
slug?: string | null;
@@ -182,10 +278,14 @@ export type CalendarCollectionCreatePayload = {
export type CalendarCollectionUpdatePayload = Partial<CalendarCollectionCreatePayload>;
export type CalendarDeleteEventAction = "delete" | "move";
export type CalendarBulkMoveExternalAction = "detach_keep_remote" | "copy_to_remote" | "remote_move";
export type CalendarCollectionDeletePayload = {
event_action?: CalendarDeleteEventAction;
target_calendar_id?: string | null;
make_target_default?: boolean;
external_action?: CalendarBulkMoveExternalAction | null;
destructive_confirmation?: string | null;
evidence_note?: string | null;
};
export type CalendarEventCreatePayload = {
@@ -221,6 +321,29 @@ export type 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> {
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
@@ -253,6 +376,13 @@ export function listCalDavSources(settings: ApiSettings, params: { calendar_id?:
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) });
}
@@ -289,14 +419,66 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
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(
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 } = {}
): Promise<CalendarEventListResponse> {
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.expand_recurring) search.set("expand_recurring", "true");
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
}
@@ -315,6 +497,10 @@ export function listCalendarEventsDelta(
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> {
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
}
@@ -326,3 +512,49 @@ export function updateCalendarEvent(settings: ApiSettings, eventId: string, payl
export function deleteCalendarEvent(settings: ApiSettings, eventId: string): Promise<void> {
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,636 @@
import {
useMemo,
useState,
type FormEvent,
} from "react";
import { Trash2 } from "lucide-react";
import {
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"}
className="calendar-event-dialog calendar-vevent-dialog"
footerClassName="calendar-event-dialog-footer"
closeDisabled={saving}
onClose={onCancel}
footer={
<>
<div>
{event && canDelete &&
<Button type="button" variant="danger" 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>
</>
}>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
<div className="calendar-dialog-documentation">
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
</div>
{!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} />
<div className="calendar-dialog-date-row">
<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>
</div>
{!allDay &&
<div className="calendar-dialog-date-row">
<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>
}
</div>
}
{(allDay || endMode === "end") &&
<div className="calendar-dialog-date-row">
<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>
</div>
}
<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>
<div className="calendar-dialog-grid-three">
<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>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.state.a7250206</h3>
<div className="calendar-dialog-grid-three">
<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>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.participants.cd56e083</h3>
<div className="calendar-dialog-grid-two">
<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>
</div>
</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>
<div className="calendar-dialog-grid-two">
<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>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.related.917df91e</h3>
<div className="calendar-dialog-grid-three">
<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>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.source.6da13add</h3>
<div className="calendar-dialog-grid-three">
<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>
</div>
</section>
<section className="calendar-vevent-section">
<h3>i18n:govoplan-calendar.raw.da433cd4</h3>
<div className="calendar-dialog-grid-two">
<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>
</div>
</section>
</details>
</form>
</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,195 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCw, XCircle } from "lucide-react";
import {
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"
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">
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
{error && (
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
)}
{batch && (
<>
<div 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)} />
</div>
<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 {
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"
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>
<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div 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>
</div>
{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,267 @@
import { useEffect, useMemo, useState } from "react";
import { Save } from "lucide-react";
import {
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 (
<div className="dashboard-grid settings-dashboard-grid calendar-settings-panel">
<div className="calendar-settings-documentation">
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
</div>
<Card title="Calendar display">
<div className="form-grid">
<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 }))
}
/>
<div className="calendar-dialog-grid-two">
<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>
</div>
</div>
</Card>
<Card title="Continuous view">
<div className="form-grid">
<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>
)}
</div>
</Card>
</div>
);
}
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;
+127 -5
View File
@@ -2,6 +2,33 @@ 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",
@@ -24,6 +51,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"i18n:govoplan-calendar.calendar.adab5090": "Calendar",
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Calendars are unavailable.",
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
"i18n:govoplan-calendar.cancel.77dfd213": "Cancel",
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
@@ -37,8 +65,8 @@ export const generatedTranslations: PlatformTranslations = {
"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_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Day",
@@ -46,6 +74,7 @@ export const generatedTranslations: PlatformTranslations = {
"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...",
@@ -61,30 +90,35 @@ export const generatedTranslations: PlatformTranslations = {
"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_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as",
"i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Enter a password or token for this source.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.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",
@@ -96,6 +130,7 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -103,14 +138,20 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -124,6 +165,7 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -140,6 +182,7 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -158,6 +201,7 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -176,9 +220,53 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -201,6 +289,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.calendar_source_type.92cdb42f": "Calendar source type",
"i18n:govoplan-calendar.calendar_views.9e6b9c2b": "Calendar views",
"i18n:govoplan-calendar.calendar.adab5090": "Kalender",
"i18n:govoplan-calendar.calendars_are_unavailable.f074c862": "Kalender sind nicht verfügbar.",
"i18n:govoplan-calendar.calendars.94445018": "Calendars",
"i18n:govoplan-calendar.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-calendar.cancelled.5587b0af": "CANCELLED",
@@ -214,8 +303,8 @@ export const generatedTranslations: PlatformTranslations = {
"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_reference.a7e92de5": "Credential reference",
"i18n:govoplan-calendar.credential.8bede3ea": "Credential",
"i18n:govoplan-calendar.dav_url.4205e180": "DAV URL",
"i18n:govoplan-calendar.day.987b9ced": "Tag",
@@ -223,6 +312,7 @@ export const generatedTranslations: PlatformTranslations = {
"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...",
@@ -238,30 +328,35 @@ export const generatedTranslations: PlatformTranslations = {
"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_secret_now_or_use_an_environment_referen.6db0e6ce": "Enter a secret now or use an environment reference such as",
"i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54": "Geben Sie ein Passwort oder Token für diese Quelle ein.",
"i18n:govoplan-calendar.etag.11d00f6e": "ETag",
"i18n:govoplan-calendar.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",
@@ -273,21 +368,28 @@ export const generatedTranslations: PlatformTranslations = {
"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_events_to_another_calendar.830c7b09": "Move events to another calendar",
"i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004": "Termine verschieben und in den externen Kalender kopieren",
"i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09": "Termine in einen anderen Kalender verschieben",
"i18n:govoplan-calendar.name.709a2322": "Name",
"i18n:govoplan-calendar.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",
@@ -301,6 +403,7 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -317,6 +420,7 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -335,6 +439,7 @@ export const generatedTranslations: PlatformTranslations = {
"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",
@@ -353,6 +458,23 @@ export const generatedTranslations: PlatformTranslations = {
"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 * from "./api/calendar";
export { default as CalendarPicker } from "./features/calendar/CalendarPicker";
export * from "./features/calendar/calendarPickerLogic";
+115 -4
View File
@@ -1,9 +1,19 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import type {
CalendarPickerUiCapability,
DashboardWidgetsUiCapability,
PlatformWebModule,
SettingsSectionsUiCapability
} from "@govoplan/core-webui";
import "./styles/calendar.css";
import { generatedTranslations } from "./i18n/generatedTranslations";
import CalendarPicker from "./features/calendar/CalendarPicker";
import UpcomingEventsWidget from "./features/calendar/UpcomingEventsWidget";
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
const CalendarSettingsPanel = lazy(
() => import("./features/calendar/CalendarSettingsPanel")
);
const eventRead = ["calendar:event:read"];
const translations = {
@@ -11,6 +21,76 @@ const translations = {
de: generatedTranslations.de
};
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
const calendarSettingsSections: SettingsSectionsUiCapability = {
sections: [
{
id: "calendar",
surfaceId: "calendar.settings.preferences",
label: "Calendar",
group: "ui",
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
})
}
]
};
export const calendarModule: PlatformWebModule = {
id: "calendar",
label: "i18n:govoplan-calendar.calendar.adab5090",
@@ -18,10 +98,41 @@ export const calendarModule: PlatformWebModule = {
dependencies: ["access"],
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
translations,
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }],
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
}
],
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, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }]
{ 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
}
};
export default calendarModule;
export default calendarModule;
+355 -118
View File
@@ -28,7 +28,7 @@
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
overflow: hidden;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 0;
background: var(--panel);
}
@@ -51,7 +51,7 @@
align-items: center;
gap: 14px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
background: var(--panel-header);
}
@@ -89,21 +89,6 @@
gap: 6px;
}
.calendar-icon-button.btn {
width: 36px;
min-width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calendar-icon-button.btn:disabled {
opacity: .42;
cursor: default;
}
.calendar-mode-switch {
flex: 0 1 520px;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -121,14 +106,14 @@
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--line);
border-right: var(--border-line);
background: linear-gradient(180deg, var(--panel-soft), var(--panel));
}
.calendar-sidebar-heading {
flex: 0 0 auto;
padding: 13px 14px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
color: var(--muted);
font-size: 12px;
font-weight: 800;
@@ -137,7 +122,7 @@
}
.calendar-sidebar-heading.is-secondary {
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.calendar-list,
@@ -201,9 +186,9 @@
display: inline-flex;
align-items: center;
padding: 2px;
border: 1px solid #c9c3b9;
border: 1px solid var(--control-border);
border-radius: 999px;
background: #d8d3cb;
background: var(--calendar-switch-bg);
cursor: pointer;
transition: background .16s ease, border-color .16s ease;
}
@@ -217,8 +202,8 @@
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, .18);
background: var(--surface);
box-shadow: var(--shadow-thumb);
transform: translateX(0);
transition: transform .16s ease;
}
@@ -250,40 +235,7 @@
}
.calendar-list-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
min-width: 28px;
}
.calendar-row-icon-button.btn {
width: 28px;
min-width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-color: transparent;
background: transparent;
opacity: .72;
}
.calendar-row-icon-button.btn:hover,
.calendar-row-icon-button.btn:focus-visible {
border-color: var(--line-dark);
background: var(--surface);
opacity: 1;
}
.calendar-row-icon-button.btn.is-syncing,
.calendar-row-icon-button.btn.is-syncing:disabled {
border-color: var(--green);
background: var(--surface);
color: var(--green);
opacity: 1;
cursor: progress;
width: auto;
}
.calendar-sync-spin {
@@ -306,7 +258,7 @@
min-width: 0;
margin-top: 6px;
padding-top: 8px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.calendar-create-action .btn {
@@ -331,7 +283,7 @@
.calendar-agenda-group + .calendar-agenda-group {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.calendar-agenda-group h3 {
@@ -346,15 +298,15 @@
.calendar-agenda-item {
--calendar-event-color: var(--green);
--calendar-event-bg: #edf8f5;
--calendar-event-border: #b9d7d0;
--calendar-event-bg: var(--calendar-event-bg-default);
--calendar-event-border: var(--calendar-event-border-default);
display: block;
min-height: 28px;
padding: 5px 7px;
border: 1px solid var(--calendar-event-border);
border-left: 4px solid var(--calendar-event-color, var(--green));
background: var(--calendar-event-bg);
color: #21453e;
color: var(--calendar-event-text);
}
.calendar-agenda-item strong {
@@ -369,7 +321,7 @@
.calendar-agenda-item .calendar-event-separator,
.calendar-agenda p {
margin: 0;
color: #4d6764;
color: var(--calendar-event-muted-text);
font-size: 12px;
}
@@ -401,9 +353,9 @@
inset: 0 auto auto 0;
z-index: 4;
padding: 10px;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
border-right: var(--border-line);
border-bottom: var(--border-line);
background: var(--panel-glass-bright);
}
.calendar-week-rows {
@@ -439,7 +391,7 @@
top: 0;
z-index: 2;
flex: 0 0 auto;
border-bottom: 1px solid var(--line-dark);
border-bottom: var(--border-line-dark);
background: var(--panel);
}
@@ -459,7 +411,7 @@
.calendar-week-row {
min-height: 132px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.calendar-week-rows.is-continuous .calendar-week-row {
@@ -476,22 +428,22 @@
flex-direction: column;
gap: 6px;
padding: 8px;
border-right: 1px solid var(--line);
border-right: var(--border-line);
background: var(--surface);
overflow: hidden;
}
.calendar-day-cell.is-even-month {
background: #ffffff;
background: var(--surface);
}
.calendar-day-cell.is-odd-month {
background: #f8f6f2;
background: var(--calendar-grid-bg);
}
.calendar-day-cell.is-muted {
color: #8a8278;
background: #f1efeb;
color: var(--muted);
background: var(--control-gradient-end-muted);
}
.calendar-day-cell.is-today {
@@ -499,7 +451,7 @@
}
.calendar-day-cell.is-drop-target {
background: #e3f3ef;
background: var(--calendar-today-bg);
box-shadow: inset 0 0 0 2px var(--green);
}
@@ -535,8 +487,8 @@
.calendar-event-chip {
--calendar-event-color: var(--green);
--calendar-event-bg: #edf8f5;
--calendar-event-border: #b9d7d0;
--calendar-event-bg: var(--calendar-event-bg-default);
--calendar-event-border: var(--calendar-event-border-default);
min-width: 0;
min-height: 26px;
display: block;
@@ -545,7 +497,7 @@
border-left: 4px solid var(--calendar-event-color);
border-radius: 4px;
background: var(--calendar-event-bg);
color: #21453e;
color: var(--calendar-event-text);
cursor: pointer;
font: inherit;
font-size: 12px;
@@ -597,7 +549,7 @@
.calendar-event-time,
.calendar-event-separator {
flex: 0 0 auto;
color: #4d6764;
color: var(--calendar-event-muted-text);
}
.calendar-more-events {
@@ -635,8 +587,8 @@
.calendar-time-corner,
.calendar-all-day-label,
.calendar-all-day-cell {
border-bottom: 1px solid var(--line-dark);
border-right: 1px solid var(--line);
border-bottom: var(--border-line-dark);
border-right: var(--border-line);
background: var(--panel);
}
@@ -649,12 +601,12 @@
}
.calendar-time-header > header.is-today {
background: #e0f0ea;
background: var(--calendar-selected-bg);
}
.calendar-time-header > header.is-weekend,
.calendar-all-day-cell.is-weekend {
background: #f1efeb;
background: var(--control-gradient-end-muted);
}
.calendar-time-header > header span {
@@ -665,7 +617,7 @@
.calendar-all-day-strip {
flex: 0 0 auto;
min-height: 42px;
border-bottom: 1px solid var(--line-dark);
border-bottom: var(--border-line-dark);
}
.calendar-all-day-label {
@@ -683,7 +635,7 @@
}
.calendar-all-day-cell.is-drop-target {
background: #e3f3ef;
background: var(--calendar-today-bg);
box-shadow: inset 0 0 0 2px var(--green);
}
@@ -712,8 +664,8 @@
.calendar-hour-label {
min-height: 64px;
border-bottom: 1px solid var(--line);
border-right: 1px solid var(--line);
border-bottom: var(--border-line);
border-right: var(--border-line);
padding: 8px;
color: var(--muted);
font-size: 12px;
@@ -724,17 +676,17 @@
position: relative;
height: 1536px;
min-height: 1536px;
border-right: 1px solid var(--line);
border-right: var(--border-line);
background:
linear-gradient(to bottom, var(--calendar-day-shade), var(--calendar-day-shade)),
linear-gradient(
to bottom,
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 0,
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-start-y),
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 0,
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-start-y),
transparent var(--calendar-work-start-y),
transparent var(--calendar-work-end-y),
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) var(--calendar-work-end-y),
rgba(241, 239, 235, var(--calendar-off-hours-opacity)) 100%
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) var(--calendar-work-end-y),
rgba(var(--calendar-off-hours-rgb), var(--calendar-off-hours-opacity)) 100%
),
repeating-linear-gradient(
to bottom,
@@ -746,11 +698,11 @@
}
.calendar-day-time-column.is-weekend {
--calendar-day-shade: rgba(241, 239, 235, .74);
--calendar-day-shade: var(--calendar-day-shade);
}
.calendar-day-time-column.is-drop-target {
box-shadow: inset 0 0 0 2px rgba(90, 169, 155, .55);
box-shadow: var(--calendar-focus-inset);
}
.calendar-time-drop-marker {
@@ -778,10 +730,10 @@
top: -13px;
left: 10px;
padding: 2px 6px;
border: 1px solid #4f9489;
border: 1px solid var(--calendar-success-border);
border-radius: 4px;
background: var(--green);
color: #fff;
color: var(--on-accent);
font-size: 11px;
font-weight: 800;
line-height: 1.2;
@@ -799,15 +751,15 @@
.calendar-timed-event {
--calendar-event-color: var(--green);
--calendar-event-bg: #edf8f5;
--calendar-event-border: #b9d7d0;
--calendar-event-bg: var(--calendar-event-bg-default);
--calendar-event-border: var(--calendar-event-border-default);
display: block;
padding: 0;
overflow: hidden;
border: 1px solid var(--calendar-event-border);
border-left: 4px solid var(--calendar-event-color);
background: var(--calendar-event-bg);
color: #21453e;
color: var(--calendar-event-text);
cursor: pointer;
font-size: 12px;
}
@@ -817,8 +769,8 @@
.calendar-timed-event.is-linked-hover,
.calendar-timed-overflow:hover,
.calendar-timed-overflow:focus-visible {
border-color: var(--calendar-event-color, #5aa99b);
background: var(--calendar-event-bg, #e3f3ef);
border-color: var(--calendar-event-color, var(--success-border));
background: var(--calendar-event-bg, var(--calendar-today-bg));
outline: none;
}
@@ -837,7 +789,7 @@
}
.calendar-timed-event-content:focus-visible {
outline: 2px solid rgba(90, 169, 155, .35);
outline: var(--calendar-focus-outline);
outline-offset: -2px;
}
@@ -864,7 +816,7 @@
width: 34px;
height: 2px;
border-radius: 999px;
background: rgba(33, 69, 62, .46);
background: var(--calendar-overlay);
content: "";
opacity: 0;
transform: translateX(-50%);
@@ -890,7 +842,7 @@
height: 28px;
display: grid;
place-items: center;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
background: var(--panel-soft);
color: var(--text-strong);
cursor: pointer;
@@ -911,6 +863,16 @@
gap: 14px;
}
.calendar-dialog-documentation,
.calendar-settings-documentation {
display: flex;
justify-content: flex-end;
}
.calendar-settings-documentation {
grid-column: 1 / -1;
}
.calendar-dialog-form label {
display: grid;
gap: 5px;
@@ -963,7 +925,7 @@
display: grid;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
}
@@ -1051,7 +1013,7 @@
}
.calendar-vevent-details {
border-top: 1px solid var(--line);
border-top: var(--border-line);
padding-top: 4px;
}
@@ -1059,7 +1021,7 @@
display: grid;
gap: 10px;
padding: 12px 0;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
.calendar-vevent-section:first-of-type {
@@ -1085,7 +1047,7 @@
.calendar-sync-status {
padding: 3px 8px;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
@@ -1094,8 +1056,8 @@
}
.calendar-sync-status.is-error {
border-color: #f0b8b2;
background: #fff2f0;
border-color: var(--calendar-danger-border);
background: var(--calendar-danger-bg);
color: var(--red);
}
@@ -1115,7 +1077,7 @@
display: grid;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
}
@@ -1147,12 +1109,287 @@
white-space: nowrap;
}
.calendar-outbox-dialog {
width: min(860px, 100%);
}
.calendar-outbox-dialog .dialog-body {
min-height: 360px;
max-height: min(70vh, 720px);
overflow: hidden;
}
.calendar-outbox-body {
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 12px;
}
.calendar-outbox-calendar-name {
margin: 0;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.calendar-outbox-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.calendar-outbox-summary {
display: flex;
align-items: center;
gap: 14px;
margin: 0;
}
.calendar-outbox-summary div {
display: flex;
align-items: baseline;
gap: 5px;
}
.calendar-outbox-summary dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.calendar-outbox-summary dd {
margin: 0;
color: var(--text-strong);
font-weight: 800;
}
.calendar-outbox-list {
min-height: 0;
display: grid;
align-content: start;
gap: 8px;
margin: 0;
padding: 0 4px 0 0;
overflow: auto;
list-style: none;
}
.calendar-outbox-item {
display: grid;
gap: 8px;
padding: 11px 12px;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
}
.calendar-outbox-item-heading,
.calendar-outbox-item-heading > div,
.calendar-outbox-actions {
display: flex;
align-items: center;
gap: 8px;
}
.calendar-remote-move-confirmation {
display: grid;
gap: 10px;
padding: 12px;
border-left: 3px solid var(--color-danger, #b42318);
background: var(--color-danger-subtle, rgba(180, 35, 24, 0.08));
}
.calendar-remote-move-confirmation label,
.calendar-migration-cancel label {
display: grid;
gap: 6px;
}
.calendar-remote-move-confirmation input,
.calendar-remote-move-confirmation textarea,
.calendar-migration-cancel textarea {
width: 100%;
}
.calendar-migration-dialog {
width: min(820px, calc(100vw - 32px));
}
.calendar-migration-body {
display: grid;
gap: 14px;
min-height: 240px;
}
.calendar-migration-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.calendar-migration-heading > div {
display: grid;
gap: 3px;
}
.calendar-migration-heading span {
color: var(--muted);
font-size: 0.86rem;
}
.calendar-migration-body progress {
width: 100%;
height: 8px;
}
.calendar-migration-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.calendar-migration-summary div {
display: grid;
gap: 3px;
padding: 8px;
border: var(--border-line);
}
.calendar-migration-summary dt {
color: var(--muted);
font-size: 0.78rem;
}
.calendar-migration-summary dd {
margin: 0;
font-weight: 600;
}
.calendar-migration-resources {
display: grid;
gap: 6px;
max-height: 280px;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.calendar-migration-resources li {
display: grid;
gap: 5px;
padding: 9px;
background: var(--panel-soft);
}
.calendar-migration-resources li > div {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.calendar-migration-resources code {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-migration-resources span,
.calendar-migration-resources p {
margin: 0;
color: var(--muted);
font-size: 0.84rem;
}
.calendar-migration-error {
margin: 0;
color: var(--color-danger, #b42318);
}
.calendar-migration-cancel {
display: grid;
gap: 8px;
padding-top: 12px;
border-top: var(--border-line);
}
.calendar-migration-cancel .button {
justify-self: start;
}
@media (max-width: 700px) {
.calendar-migration-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.calendar-outbox-item-heading {
justify-content: space-between;
}
.calendar-outbox-item-heading time {
color: var(--muted);
font-size: 12px;
}
.calendar-outbox-item code {
overflow: hidden;
color: var(--text);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-outbox-item-facts {
display: flex;
gap: 16px;
margin: 0;
}
.calendar-outbox-item-facts div {
display: flex;
gap: 5px;
}
.calendar-outbox-item-facts dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.calendar-outbox-item-facts dd {
margin: 0;
font-size: 12px;
}
.calendar-outbox-error {
margin: 0;
padding: 8px 9px;
border-left: 3px solid var(--red);
background: var(--calendar-danger-bg);
color: var(--red);
font-size: 12px;
}
.calendar-outbox-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.calendar-form-error {
margin: 0;
padding: 9px 10px;
border: 1px solid #f0b8b2;
border: 1px solid var(--calendar-danger-border);
border-radius: 4px;
background: #fff2f0;
background: var(--calendar-danger-bg);
color: var(--red);
font-size: 13px;
}
@@ -1166,13 +1403,13 @@
}
.calendar-delete-warning {
border: 1px solid #f0b8b2;
background: #fff2f0;
border: 1px solid var(--calendar-danger-border);
background: var(--calendar-danger-bg);
color: var(--red);
}
.calendar-form-note {
border: 1px solid var(--line);
border: var(--border-line);
background: var(--panel-soft);
color: var(--muted);
}
@@ -1182,7 +1419,7 @@
gap: 10px;
margin: 0;
padding: 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
}
@@ -1275,7 +1512,7 @@
.calendar-sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.calendar-mode-switch {
@@ -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.");
+44
View File
@@ -0,0 +1,44 @@
{
"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/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"
]
}