Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128c78597e | ||
|
|
f6faaf196d | ||
|
|
2b1fc9af19 | ||
|
|
6e70bbec06 | ||
|
|
00add294a8 | ||
|
|
b4e5351b8c | ||
|
|
04438d96ca | ||
|
|
d7fd9447a2 | ||
|
|
a125b666da | ||
|
|
56d7b2108d | ||
|
|
77a40ec2f9 | ||
|
|
4e05ab2c3e | ||
|
|
66a6df4f05 | ||
|
|
ccdf12162a | ||
|
|
001ebd3b6d | ||
|
|
f00cff1d8c | ||
|
|
1e063f51cc | ||
|
|
b1cf001452 | ||
|
|
56b387a784 | ||
|
|
4011e61a63 | ||
|
|
2cb3aca27c | ||
|
|
77072d057a | ||
|
|
202eb78ae4 | ||
|
|
bf59cd830c | ||
|
|
041e2159e7 | ||
|
|
d64de50802 | ||
|
|
7843fe88e5 | ||
|
|
39b199d7e6 | ||
|
|
74495e56cb | ||
|
|
6ca1816c95 | ||
|
|
2d7d105ba3 | ||
|
|
41b9426670 | ||
|
|
baf624c7a6 | ||
|
|
d10570fc0c | ||
|
|
30f6d79f9e | ||
|
|
760a87ab99 | ||
|
|
7098751f7f | ||
|
|
1df2a1a730 | ||
|
|
a52e35b52b | ||
|
|
a36a9e8c19 | ||
|
|
68b165db7b | ||
|
|
2ad6e9d60e | ||
|
|
a6cd64e3f9 | ||
|
|
371a00aff5 | ||
|
|
c071235ad7 |
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -40,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
|
||||
@@ -51,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,14 +19,14 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"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": {
|
||||
|
||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-calendar"
|
||||
version = "0.1.8"
|
||||
version = "0.1.16"
|
||||
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.8",
|
||||
"govoplan-access>=0.1.8",
|
||||
"govoplan-core>=0.1.16",
|
||||
"govoplan-access>=0.1.16",
|
||||
"defusedxml>=0.7,<1",
|
||||
"icalendar>=7.2",
|
||||
"python-dateutil>=2.9",
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.4"
|
||||
__version__ = "0.1.16"
|
||||
|
||||
@@ -1,14 +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):
|
||||
@@ -77,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,
|
||||
@@ -230,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"}
|
||||
@@ -268,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)
|
||||
@@ -310,15 +357,33 @@ class CalDAVClient:
|
||||
|
||||
|
||||
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
||||
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
|
||||
|
||||
|
||||
@@ -360,73 +425,164 @@ def parse_discovery_multistatus(payload: bytes) -> list[_DAVDiscoveryResponse]:
|
||||
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:
|
||||
@@ -446,25 +602,25 @@ def xml_escape(value: str) -> str:
|
||||
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def first_child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
|
||||
def first_child(element: Any, name: str) -> Any | None:
|
||||
for child in element:
|
||||
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."
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
def normalize_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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.8",
|
||||
version="0.1.16",
|
||||
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",
|
||||
),
|
||||
),
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
"""Add the durable CalDAV desired-state outbox on the development track.
|
||||
|
||||
Revision ID: af1b2c3d4e5f
|
||||
Revises: 9e0f1a2b3c4d
|
||||
Create Date: 2026-07-20 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
|
||||
downgrade as _downgrade,
|
||||
)
|
||||
from govoplan_calendar.backend.migrations.versions.af1b2c3d4e5f_calendar_caldav_outbox import (
|
||||
upgrade as _upgrade,
|
||||
)
|
||||
|
||||
|
||||
revision = "af1b2c3d4e5f"
|
||||
down_revision = "9e0f1a2b3c4d"
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_downgrade()
|
||||
+28
@@ -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()
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
"""Add the durable CalDAV desired-state outbox.
|
||||
|
||||
Revision ID: af1b2c3d4e5f
|
||||
Revises: 9e0f1a2b3c4d
|
||||
Create Date: 2026-07-20 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "af1b2c3d4e5f"
|
||||
down_revision = "9e0f1a2b3c4d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"calendar_outbox_operations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("operation_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("resource_href", sa.String(length=1000), nullable=False),
|
||||
sa.Column("payload_ics", sa.Text(), nullable=True),
|
||||
sa.Column("payload_fingerprint", sa.String(length=64), nullable=True),
|
||||
sa.Column("expected_etag", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False),
|
||||
sa.Column("max_attempts", sa.Integer(), nullable=False),
|
||||
sa.Column("available_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("lease_token", sa.String(length=36), nullable=True),
|
||||
sa.Column("lease_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("reconciled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("remote_etag", sa.String(length=255), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["event_id"],
|
||||
["calendar_events.id"],
|
||||
name=op.f("fk_calendar_outbox_operations_event_id_calendar_events"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["source_id"],
|
||||
["calendar_sync_sources.id"],
|
||||
name=op.f("fk_calendar_outbox_operations_source_id_calendar_sync_sources"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["core_scopes.id"],
|
||||
name=op.f("fk_calendar_outbox_operations_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_calendar_outbox_operations")),
|
||||
sa.UniqueConstraint(
|
||||
"idempotency_key",
|
||||
name="uq_calendar_outbox_operations_idempotency_key",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_due",
|
||||
"calendar_outbox_operations",
|
||||
["status", "available_at", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_lease",
|
||||
"calendar_outbox_operations",
|
||||
["status", "lease_expires_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_resource",
|
||||
"calendar_outbox_operations",
|
||||
["source_id", "resource_href", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_outbox_tenant_status",
|
||||
"calendar_outbox_operations",
|
||||
["tenant_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
for column in (
|
||||
"available_at",
|
||||
"event_id",
|
||||
"lease_expires_at",
|
||||
"lease_token",
|
||||
"operation_kind",
|
||||
"payload_fingerprint",
|
||||
"source_id",
|
||||
"status",
|
||||
"tenant_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_calendar_outbox_operations_{column}"),
|
||||
"calendar_outbox_operations",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("calendar_outbox_operations")
|
||||
+83
@@ -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")
|
||||
+59
@@ -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")
|
||||
+150
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import threading
|
||||
import unittest
|
||||
from collections.abc import Iterator
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_calendar.backend.caldav import (
|
||||
CalDAVClient,
|
||||
CalDAVError,
|
||||
absolute_dav_url,
|
||||
urllib_transport,
|
||||
)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = server.server_address
|
||||
yield f"http://{host}:{port}"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
class CalDAVUrlSecurityTests(unittest.TestCase):
|
||||
def test_transport_revalidates_dns_at_connection_time(self) -> None:
|
||||
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
|
||||
), patch(
|
||||
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||
side_effect=(public, private),
|
||||
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
|
||||
CalDAVError,
|
||||
"non-public network",
|
||||
):
|
||||
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
|
||||
socket_factory.assert_not_called()
|
||||
|
||||
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
||||
base_url = "https://dav.example.test/calendars/ada/"
|
||||
|
||||
self.assertEqual(
|
||||
absolute_dav_url(base_url, "/principals/users/ada/"),
|
||||
"https://dav.example.test/principals/users/ada/",
|
||||
)
|
||||
with self.assertRaisesRegex(CalDAVError, "configured collection origin"):
|
||||
absolute_dav_url(base_url, "https://evil.example.test/steal/")
|
||||
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
|
||||
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
|
||||
|
||||
def test_object_href_rejects_userinfo_query_and_fragment(self) -> None:
|
||||
client = CalDAVClient(collection_url="https://dav.example.test/calendars/ada")
|
||||
|
||||
with self.assertRaisesRegex(CalDAVError, "embedded credentials"):
|
||||
client.object_url("https://user:secret@dav.example.test/calendars/ada/event.ics")
|
||||
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
|
||||
client.object_url("/calendars/ada/event.ics?download=1")
|
||||
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
|
||||
client.object_url("/calendars/ada/event.ics#fragment")
|
||||
self.assertEqual(
|
||||
client.object_url("/calendars/ada/event.ics"),
|
||||
"https://dav.example.test/calendars/ada/event.ics",
|
||||
)
|
||||
|
||||
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
|
||||
forwarded_authorization: list[str | None] = []
|
||||
|
||||
class TargetHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
with running_http_server(TargetHandler) as target_url:
|
||||
class RedirectHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
self.send_response(302)
|
||||
self.send_header("Location", f"{target_url}/stolen.ics")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
with running_http_server(RedirectHandler) as redirect_url:
|
||||
status, _headers, _body = urllib_transport(
|
||||
"GET",
|
||||
f"{redirect_url}/event.ics",
|
||||
{"Authorization": "Bearer top-secret"},
|
||||
None,
|
||||
2,
|
||||
)
|
||||
|
||||
self.assertEqual(status, 302)
|
||||
self.assertEqual(forwarded_authorization, [])
|
||||
|
||||
def test_transport_preserves_same_origin_redirects(self) -> None:
|
||||
forwarded_authorization: list[str | None] = []
|
||||
|
||||
class RedirectHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
|
||||
if self.path == "/event.ics":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/redirected.ics")
|
||||
self.end_headers()
|
||||
return
|
||||
forwarded_authorization.append(self.headers.get("Authorization"))
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"calendar")
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
with running_http_server(RedirectHandler) as source_url:
|
||||
status, _headers, body = urllib_transport(
|
||||
"GET",
|
||||
f"{source_url}/event.ics",
|
||||
{"Authorization": "Bearer expected"},
|
||||
None,
|
||||
2,
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(body, b"calendar")
|
||||
self.assertEqual(forwarded_authorization, ["Bearer expected"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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&"<@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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.16",
|
||||
"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.8",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"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
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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.");
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
calendarPickerOptions,
|
||||
calendarPickerTenantId,
|
||||
type CalendarPickerOption
|
||||
} from "../src/features/calendar/calendarPickerLogic";
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function calendar(id: string, name: string, isDefault = false): CalendarPickerOption {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
is_default: isDefault
|
||||
};
|
||||
}
|
||||
|
||||
const input = [
|
||||
calendar("z", "Zulu"),
|
||||
calendar("b", "Bravo", true),
|
||||
calendar("a", "alpha")
|
||||
];
|
||||
const ordered = calendarPickerOptions(input);
|
||||
|
||||
assert(ordered.map((item) => item.id).join(",") === "b,a,z", "default calendar should lead, followed by names");
|
||||
assert(input.map((item) => item.id).join(",") === "z,b,a", "picker sorting must not mutate API data");
|
||||
assert(
|
||||
calendarPickerTenantId({ tenant: { id: "fallback" }, active_tenant: { id: "active" } }) === "active",
|
||||
"active tenant should partition picker requests"
|
||||
);
|
||||
assert(
|
||||
calendarPickerTenantId({ tenant: { id: "fallback" } }) === "fallback",
|
||||
"legacy tenant should remain a supported fallback"
|
||||
);
|
||||
|
||||
console.log("Calendar picker behavior checks passed.");
|
||||
@@ -0,0 +1,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.");
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".calendar-picker-test-build",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/calendar-picker.test.ts",
|
||||
"src/features/calendar/calendarPickerLogic.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user