Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde8c50e55 | ||
|
|
8e36f8b3d2 | ||
|
|
3e5fc05ca3 | ||
|
|
3792e9ab8a | ||
|
|
ae0f3990f1 |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.20",
|
"version": "0.1.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.44",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-calendar"
|
name = "govoplan-calendar"
|
||||||
version = "0.1.20"
|
version = "0.1.24"
|
||||||
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.46",
|
||||||
"govoplan-access>=0.1.18",
|
"govoplan-access>=0.1.18",
|
||||||
"defusedxml>=0.7,<1",
|
"defusedxml>=0.7,<1",
|
||||||
"icalendar>=7.2",
|
"icalendar>=7.2",
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.20"
|
__version__ = "0.1.24"
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'calendar.external-sources-and-sync': {'consequence_classes': {'change_collection': 'Änderung der '
|
||||||
|
'Kalenderidentität, '
|
||||||
|
'Quellkonfiguration, '
|
||||||
|
'Anmeldeinformationen '
|
||||||
|
'und '
|
||||||
|
'Synchronisierungsrichtlinie',
|
||||||
|
'delete_or_remove_collection': 'Löschen '
|
||||||
|
'eines '
|
||||||
|
'lokalen '
|
||||||
|
'Kalenders '
|
||||||
|
'oder '
|
||||||
|
'Entfernen '
|
||||||
|
'einer '
|
||||||
|
'externen '
|
||||||
|
'Quelle '
|
||||||
|
'nach '
|
||||||
|
'expliziter '
|
||||||
|
'Ereignisbehandlung',
|
||||||
|
'execute_remote_move': 'Kopieren '
|
||||||
|
'aller '
|
||||||
|
'Zielressourcen '
|
||||||
|
'vor dem '
|
||||||
|
'bedingten '
|
||||||
|
'Löschen '
|
||||||
|
'von '
|
||||||
|
'Quellressourcen',
|
||||||
|
'force_full_sync': 'Lesen Sie die '
|
||||||
|
'vollständige '
|
||||||
|
'Remote-Quelle '
|
||||||
|
'erneut und '
|
||||||
|
'versöhnen Sie '
|
||||||
|
'sie mit dem '
|
||||||
|
'lokalen Staat',
|
||||||
|
'synchronize_source': 'Lesen und, '
|
||||||
|
'wo '
|
||||||
|
'konfiguriert, '
|
||||||
|
'Schreiben '
|
||||||
|
'des '
|
||||||
|
'Remote-Zustands '
|
||||||
|
'unter '
|
||||||
|
'Verwendung '
|
||||||
|
'von '
|
||||||
|
'Begrenzten '
|
||||||
|
'Synchronisationsnachweisen'}},
|
||||||
|
'calendar.manage-calendars-and-events': {'consequence_classes': {'delete_event': 'Löschen des '
|
||||||
|
'ausgewählten '
|
||||||
|
'Ereignisses '
|
||||||
|
'oder der '
|
||||||
|
'ausgewählten '
|
||||||
|
'Serie und '
|
||||||
|
'Synchronisieren '
|
||||||
|
'der '
|
||||||
|
'Warteschlange '
|
||||||
|
'bei Bedarf',
|
||||||
|
'save_event': 'Erstellen oder '
|
||||||
|
'Aktualisieren des '
|
||||||
|
'maßgeblichen '
|
||||||
|
'Ereignisses und '
|
||||||
|
'Synchronisieren '
|
||||||
|
'der Warteschlange '
|
||||||
|
'bei Bedarf'}},
|
||||||
|
'calendar.outbound-change-recovery': {'consequence_classes': {'reconcile_outbox': 'Vergleichen '
|
||||||
|
'Sie die '
|
||||||
|
'neueste '
|
||||||
|
'gewünschte '
|
||||||
|
'Generation mit '
|
||||||
|
'dem '
|
||||||
|
'Remote-Zustand, '
|
||||||
|
'bevor Sie '
|
||||||
|
'erneut '
|
||||||
|
'versuchen oder '
|
||||||
|
'verwerfen'}},
|
||||||
|
'calendar.privacy.data-subject-requests': {'limitations': ['Rohe ICS und Steckverbinder-Locatoren '
|
||||||
|
'sind nicht in den JSON-Export '
|
||||||
|
'eingebettet; autorisierte '
|
||||||
|
'Kalenderüberprüfung bleibt '
|
||||||
|
'verbindlich.',
|
||||||
|
'Event- und Teilnehmerlöschung ist '
|
||||||
|
'manuell, da sich Wiederholung, '
|
||||||
|
'gemeinsame Teilnahme, externe '
|
||||||
|
'Synchronisierung und institutionelle '
|
||||||
|
'Bindung überschneiden können.'],
|
||||||
|
'prerequisites': ['Die Datenschutzanfrage und die '
|
||||||
|
'Kalenderauswahl wurden unabhängig '
|
||||||
|
'autorisiert und bestätigt.',
|
||||||
|
'Der Rezensent versteht die '
|
||||||
|
'effektiven Kalenderspeicherungs- '
|
||||||
|
'und '
|
||||||
|
'Synchronisationsnachweispflichten.'],
|
||||||
|
'steps': ['Führen Sie die Kalenderanbietersuche aus '
|
||||||
|
'und überprüfen Sie die isolierten '
|
||||||
|
'Ereignis-, Teilnahme-, Präferenz-, '
|
||||||
|
'Synchronisierungs-, Outbox- und '
|
||||||
|
'Migrationsdatensätze.',
|
||||||
|
'Bewahren Sie alle Gründe für die '
|
||||||
|
'Aufbewahrung von Nachweisen mit der '
|
||||||
|
'Fallentscheidung auf und koordinieren Sie '
|
||||||
|
'jede Inhaltsänderung durch '
|
||||||
|
'Kalender-Lebenszykluskontrollen.',
|
||||||
|
'Führen Sie nur eine genehmigte Löschung der '
|
||||||
|
'persönlichen Ansichtspräferenz aus.',
|
||||||
|
'Überprüfen Sie die zugehörigen '
|
||||||
|
'Terminplanungs- oder E-Mail-Datensätze über '
|
||||||
|
'ihre eigenen DSAR-Anbieter, wenn diese '
|
||||||
|
'Module installiert sind.']}}
|
||||||
@@ -215,7 +215,82 @@ def add_event_raw_records(component: Event, event: Any) -> None:
|
|||||||
add_raw_property(component, "RELATED-TO", record)
|
add_raw_property(component, "RELATED-TO", record)
|
||||||
|
|
||||||
|
|
||||||
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 1000) -> list[dict[str, Any]]:
|
def expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
|
||||||
|
"""Return every occurrence in range, or explicitly reject an unsafe expansion."""
|
||||||
|
return expand_events_occurrences([event], range_start, range_end, limit=limit)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def expand_events_occurrences(
|
||||||
|
events: list[Any], range_start: datetime, range_end: datetime,
|
||||||
|
*, limit: int = 10_000, admission: Any = None,
|
||||||
|
) -> list[list[dict[str, Any]]]:
|
||||||
|
from govoplan_core.security.bounded_process import (
|
||||||
|
ProcessBudgetError, bounded_operation_admission, run_bounded_operation,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.worker_payload import (
|
||||||
|
WorkerPayloadError, decode_worker_payload, encode_worker_payload,
|
||||||
|
)
|
||||||
|
from govoplan_calendar.backend.recurrence_worker import RECURRENCE_LIMITS, expand_recurrences_worker
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return []
|
||||||
|
if type(limit) is not int or not 1 <= limit <= 10_000 or len(events) > 2_000:
|
||||||
|
raise ICalendarError("Recurrence expansion exceeds its limit; request a smaller range or fewer calendars.")
|
||||||
|
try:
|
||||||
|
if admission is None:
|
||||||
|
with bounded_operation_admission() as reserved:
|
||||||
|
return expand_events_occurrences(events, range_start, range_end, limit=limit, admission=reserved)
|
||||||
|
projections = [
|
||||||
|
{
|
||||||
|
"uid": item.uid,
|
||||||
|
"start_at": item.start_at,
|
||||||
|
"end_at": item.end_at,
|
||||||
|
"duration_seconds": getattr(item, "duration_seconds", None),
|
||||||
|
"all_day": item.all_day,
|
||||||
|
"timezone": getattr(item, "timezone", None),
|
||||||
|
"rrule": item.rrule,
|
||||||
|
"rdate": item.rdate or [],
|
||||||
|
"exdate": item.exdate or [],
|
||||||
|
}
|
||||||
|
for item in events
|
||||||
|
]
|
||||||
|
payload = encode_worker_payload(
|
||||||
|
{"events": projections, "start": range_start, "end": range_end, "limit": limit},
|
||||||
|
max_bytes=RECURRENCE_LIMITS.input_bytes,
|
||||||
|
)
|
||||||
|
result = decode_worker_payload(
|
||||||
|
run_bounded_operation(expand_recurrences_worker, payload, limits=RECURRENCE_LIMITS, admission=admission),
|
||||||
|
max_bytes=RECURRENCE_LIMITS.output_bytes,
|
||||||
|
)
|
||||||
|
if not isinstance(result, dict) or result.get("error"):
|
||||||
|
raise ICalendarError("Recurrence expansion is invalid or exceeds its limit; request a smaller range or fewer calendars.")
|
||||||
|
batches = result.get("occurrences")
|
||||||
|
if not isinstance(batches, list) or len(batches) != len(events):
|
||||||
|
raise ICalendarError("Recurrence worker returned an invalid result.")
|
||||||
|
count = 0
|
||||||
|
for source, batch in zip(events, batches, strict=True):
|
||||||
|
if not isinstance(batch, list):
|
||||||
|
raise ICalendarError("Recurrence worker returned an invalid result.")
|
||||||
|
count += len(batch)
|
||||||
|
if count > limit:
|
||||||
|
raise ICalendarError("Recurrence worker exceeded its result limit.")
|
||||||
|
for item in batch:
|
||||||
|
if (
|
||||||
|
not isinstance(item, dict)
|
||||||
|
or set(item) != {"uid", "recurrence_id", "start_at", "end_at", "all_day"}
|
||||||
|
or item["uid"] != source.uid
|
||||||
|
or not isinstance(item["recurrence_id"], str)
|
||||||
|
or not isinstance(item["start_at"], datetime)
|
||||||
|
or (item["end_at"] is not None and not isinstance(item["end_at"], datetime))
|
||||||
|
or type(item["all_day"]) is not bool
|
||||||
|
):
|
||||||
|
raise ICalendarError("Recurrence worker returned an invalid result.")
|
||||||
|
return batches
|
||||||
|
except (ProcessBudgetError, WorkerPayloadError) as exc:
|
||||||
|
raise ICalendarError("Recurrence expansion could not complete within its resource limits; narrow the request or retry later.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_event_occurrences(event: Any, range_start: datetime, range_end: datetime, *, limit: int = 10_000) -> list[dict[str, Any]]:
|
||||||
"""Expand an event's recurrence primitives within a range.
|
"""Expand an event's recurrence primitives within a range.
|
||||||
|
|
||||||
This is a backend primitive for later API/UI recurrence handling. It expands
|
This is a backend primitive for later API/UI recurrence handling. It expands
|
||||||
@@ -240,14 +315,20 @@ def expand_event_occurrences(event: Any, range_start: datetime, range_end: datet
|
|||||||
for exdate in temporal_values_from_records(event.exdate or []):
|
for exdate in temporal_values_from_records(event.exdate or []):
|
||||||
rules.exdate(exdate)
|
rules.exdate(exdate)
|
||||||
|
|
||||||
starts = rules.between(range_start - duration, range_end, inc=True)
|
|
||||||
occurrences: list[dict[str, Any]] = []
|
occurrences: list[dict[str, Any]] = []
|
||||||
for occurrence_start in starts:
|
# Never materialize rules.between(): dense rules allocate the whole range
|
||||||
|
# before a result limit can run. Sparse rules and pre-range scans are also
|
||||||
|
# covered by the disposable worker's CPU/wall/memory limits.
|
||||||
|
for scanned, occurrence_start in enumerate(rules, start=1):
|
||||||
|
if scanned > 100_000:
|
||||||
|
raise ICalendarError("Recurrence scan limit exceeded.")
|
||||||
occurrence_start = normalize_datetime(occurrence_start)
|
occurrence_start = normalize_datetime(occurrence_start)
|
||||||
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
|
if occurrence_start > range_end:
|
||||||
occurrences.append(occurrence_payload(occurrence_start, duration, event))
|
|
||||||
if len(occurrences) >= limit:
|
|
||||||
break
|
break
|
||||||
|
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
|
||||||
|
if len(occurrences) >= limit:
|
||||||
|
raise ICalendarError("Recurrence result limit exceeded.")
|
||||||
|
occurrences.append(occurrence_payload(occurrence_start, duration, event))
|
||||||
return occurrences
|
return occurrences
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_calendar.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -36,7 +39,9 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
@@ -615,7 +620,7 @@ def _open_xchange_provider_states(context):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="calendar",
|
id="calendar",
|
||||||
name="Calendar",
|
name="Calendar",
|
||||||
version="0.1.20",
|
version="0.1.24",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -686,6 +691,54 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="calendar.complete-bounded-availability",
|
||||||
|
title="Complete availability and bounded recurrence expansion",
|
||||||
|
summary="Availability either includes every busy occurrence or reports that the request could not complete.",
|
||||||
|
body=(
|
||||||
|
"Expanded ranges are limited to 400 days. A request admits at most 2,000 calendars and loads at most 2,000 "
|
||||||
|
"series, direct events, or override candidates per category. Recurrences run together in one disposable, "
|
||||||
|
"data-only worker with shared non-queuing admission, 5 seconds wall time, 3 seconds CPU, 256 MiB memory, "
|
||||||
|
"and 4 MiB input/output budgets. Results are limited to 10,000 occurrences in total; each series scans at "
|
||||||
|
"most 100,000 recurrence candidates. These are resource boundaries, not an arbitrary-code sandbox. "
|
||||||
|
"On SQLite and PostgreSQL, candidate identity/size projections enforce a shared 4 MiB data budget in SQL "
|
||||||
|
"before text/JSON fields reach the application; raw iCalendar source is never loaded for expansion. The full "
|
||||||
|
"expanded response has a separate 4 MiB JSON byte limit, including repeated descriptions and metadata. "
|
||||||
|
"Free/busy loads only scheduling fields, so large descriptions or attachments do not prevent an otherwise "
|
||||||
|
"bounded, complete availability answer. Unsupported database dialects fail explicitly. "
|
||||||
|
"An exceeded limit or unavailable worker causes an explicit error, never a partial free/busy answer. "
|
||||||
|
"Narrow the range or calendars, or retry if capacity is busy. A UI response limit only slices a successfully "
|
||||||
|
"completed result; it does not hide failed expansion. Existing tenant/calendar visibility, overrides, "
|
||||||
|
"cancellations, exclusions, timezone handling, and stored iCalendar properties remain unchanged. "
|
||||||
|
"Verifying an individual recurrence uses the same worker boundary; failure does not edit the occurrence."
|
||||||
|
),
|
||||||
|
layer="always", documentation_types=("user", "admin"),
|
||||||
|
audience=("user", "calendar_manager", "administrator"), order=19,
|
||||||
|
translations={"de": {
|
||||||
|
"title": "Vollständige Verfügbarkeit und begrenzte Serienberechnung",
|
||||||
|
"summary": "Die Verfügbarkeit enthält alle belegten Termine oder meldet ausdrücklich eine unvollendete Anfrage.",
|
||||||
|
"body": (
|
||||||
|
"Erweiterte Zeiträume sind auf 400 Tage begrenzt. Eine Anfrage umfasst höchstens 2.000 Kalender und lädt "
|
||||||
|
"jeweils höchstens 2.000 Serien-, Direkttermin- oder Ausnahmekandidaten. Serien werden gemeinsam in einem "
|
||||||
|
"kurzlebigen Worker ohne Datenbankzugriff berechnet: gemeinsame Zulassung ohne Warteschlange, 5 Sekunden "
|
||||||
|
"Laufzeit, 3 Sekunden CPU, 256 MiB Speicher und je 4 MiB Ein-/Ausgabe. Insgesamt sind höchstens 10.000 "
|
||||||
|
"Vorkommen erlaubt; pro Serie werden maximal 100.000 Kandidaten durchlaufen. Dies ist eine Ressourcengrenze, "
|
||||||
|
"keine Sandbox für beliebigen Code. Unter SQLite und PostgreSQL begrenzen Kennungs-/Größenprojektionen "
|
||||||
|
"bereits in SQL die gemeinsam geladenen Kandidatendaten auf 4 MiB, bevor Text-/JSON-Felder die Anwendung "
|
||||||
|
"erreichen; die rohe iCalendar-Quelle wird für die Erweiterung nicht geladen. Die vollständige erweiterte "
|
||||||
|
"Antwort hat ein separates JSON-Bytelimit von 4 MiB einschließlich wiederholter Beschreibungen und Metadaten. "
|
||||||
|
"Frei/Belegt lädt nur Planungsfelder, sodass große Beschreibungen oder Anhänge eine ansonsten begrenzte, "
|
||||||
|
"vollständige Verfügbarkeitsantwort nicht verhindern. Nicht unterstützte Datenbankdialekte melden einen "
|
||||||
|
"ausdrücklichen Fehler. Überschrittene Grenzen oder nicht verfügbare Worker erzeugen einen "
|
||||||
|
"ausdrücklichen Fehler, niemals eine unvollständige Frei-/Belegt-Antwort. Zeitraum oder Kalenderauswahl "
|
||||||
|
"verkleinern beziehungsweise bei ausgelasteter Kapazität später erneut versuchen. Ein UI-Ausgabelimit kürzt "
|
||||||
|
"nur ein vollständig berechnetes Ergebnis und verdeckt keine fehlgeschlagene Berechnung. Bestehende "
|
||||||
|
"Mandanten-/Kalendersichtbarkeit, Ausnahmen, Absagen, Ausschlüsse, Zeitzonenbehandlung und gespeicherte "
|
||||||
|
"iCalendar-Eigenschaften bleiben erhalten. Einzelne Serienvorkommen werden unter derselben Worker-Grenze "
|
||||||
|
"geprüft; ein Fehlschlag ändert das Vorkommen nicht."
|
||||||
|
),
|
||||||
|
}},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="calendar.privacy.data-subject-requests",
|
id="calendar.privacy.data-subject-requests",
|
||||||
title="Review Calendar data in a data-subject request",
|
title="Review Calendar data in a data-subject request",
|
||||||
@@ -776,7 +829,8 @@ manifest = ModuleManifest(
|
|||||||
title="Calendar in product navigation and Quick Access",
|
title="Calendar in product navigation and Quick Access",
|
||||||
summary="Use Calendar in Meetings and decisions and keep an optional compact agenda beside current work.",
|
summary="Use Calendar in Meetings and decisions and keep an optional compact agenda beside current work.",
|
||||||
body=(
|
body=(
|
||||||
"Calendar contributes its workspace to Meetings and decisions. When Quick Access is enabled, "
|
"Calendar contributes its workspace to the stable Calendar destination at /agenda in Meetings and decisions. "
|
||||||
|
"The owner route /calendar remains available through All available tools and as a compatible deep link. When Quick Access is enabled, "
|
||||||
"the owner-rendered agenda shows at most seven authorized events across the current or explicitly selected temporal "
|
"the owner-rendered agenda shows at most seven authorized events across the current or explicitly selected temporal "
|
||||||
"context and links to the full workspace. Event selection returns a typed Calendar reference; accounts with event-write "
|
"context and links to the full workspace. Event selection returns a typed Calendar reference; accounts with event-write "
|
||||||
"permission can launch the full owner-rendered creation dialog at that date. Calendar rechecks tenant, scope, private "
|
"permission can launch the full owner-rendered creation dialog at that date. Calendar rechecks tenant, scope, private "
|
||||||
@@ -792,7 +846,8 @@ manifest = ModuleManifest(
|
|||||||
"title": "Kalender in Produktnavigation und Schnellzugriff",
|
"title": "Kalender in Produktnavigation und Schnellzugriff",
|
||||||
"summary": "Den Kalender unter Termine und Entscheidungen sowie optional als kompakte Agenda neben der aktuellen Arbeit verwenden.",
|
"summary": "Den Kalender unter Termine und Entscheidungen sowie optional als kompakte Agenda neben der aktuellen Arbeit verwenden.",
|
||||||
"body": (
|
"body": (
|
||||||
"Calendar ordnet seinen Arbeitsbereich Termine und Entscheidungen zu. Ist der Schnellzugriff aktiviert, "
|
"Calendar ordnet seinen Arbeitsbereich dem stabilen Produktziel Kalender unter /agenda in Termine und Entscheidungen zu. "
|
||||||
|
"Der Eigentümerpfad /calendar bleibt unter Alle verfügbaren Werkzeuge und als kompatibler Direktlink erreichbar. Ist der Schnellzugriff aktiviert, "
|
||||||
"zeigt die vom Kalender gerenderte Agenda höchstens sieben berechtigte Termine im aktuellen oder ausdrücklich "
|
"zeigt die vom Kalender gerenderte Agenda höchstens sieben berechtigte Termine im aktuellen oder ausdrücklich "
|
||||||
"gewählten Zeitkontext. Die Terminauswahl liefert eine typisierte Kalenderreferenz; mit Schreibberechtigung lässt "
|
"gewählten Zeitkontext. Die Terminauswahl liefert eine typisierte Kalenderreferenz; mit Schreibberechtigung lässt "
|
||||||
"sich der vollständige Termineditor für dieses Datum öffnen. Calendar prüft Mandant, Berechtigung, private "
|
"sich der vollständige Termineditor für dieses Datum öffnen. Calendar prüft Mandant, Berechtigung, private "
|
||||||
@@ -840,7 +895,9 @@ manifest = ModuleManifest(
|
|||||||
id="calendar.manage-calendars-and-events",
|
id="calendar.manage-calendars-and-events",
|
||||||
title="Use 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.",
|
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. Scheduling may promote a selected tentative hold in place and submit unused holds for idempotent release through the neutral Calendar capability. Calendar owns the resulting local tombstone, synchronized outbox operation, retry and reconciliation evidence; an already released or absent event is an accepted replay rather than a duplicate failure.",
|
body="Documentation books sit beside Calendars, Calendar display, and the relevant event, source, "
|
||||||
|
"move, or recovery dialog title. Field help remains beside its label. "
|
||||||
|
"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. Scheduling may promote a selected tentative hold in place and submit unused holds for idempotent release through the neutral Calendar capability. Calendar owns the resulting local tombstone, synchronized outbox operation, retry and reconciliation evidence; an already released or absent event is an accepted replay rather than a duplicate failure.",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("user", "calendar_manager"),
|
audience=("user", "calendar_manager"),
|
||||||
related_modules=("scheduling", "notifications"),
|
related_modules=("scheduling", "notifications"),
|
||||||
@@ -852,6 +909,9 @@ manifest = ModuleManifest(
|
|||||||
"Arbeitswochen- und Tagesansicht bearbeiten."
|
"Arbeitswochen- und Tagesansicht bearbeiten."
|
||||||
),
|
),
|
||||||
"body": (
|
"body": (
|
||||||
|
"Dokumentationsbücher stehen neben Kalender, Kalenderanzeige und dem jeweiligen Dialogtitel "
|
||||||
|
"für Ereignisse, Quellen, Verschiebungen oder Wiederherstellung. Feldhilfe bleibt neben der "
|
||||||
|
"Feldbezeichnung. "
|
||||||
"Calendar merkt sich gewählte Ansicht und Präferenzen. Ereignisse können mit der entsprechenden Berechtigung angelegt, "
|
"Calendar merkt sich gewählte Ansicht und Präferenzen. Ereignisse können mit der entsprechenden Berechtigung angelegt, "
|
||||||
"bearbeitet, verschoben, in ihrer Dauer geändert, wiederholt, importiert, exportiert oder gelöscht werden. Ganztägige "
|
"bearbeitet, verschoben, in ihrer Dauer geändert, wiederholt, importiert, exportiert oder gelöscht werden. Ganztägige "
|
||||||
"Ereignisse verwenden Datumswerte statt lokaler Uhrzeiten; zeitgebundene Ereignisse bewahren zeitzonenbezogene Start- und "
|
"Ereignisse verwenden Datumswerte statt lokaler Uhrzeiten; zeitgebundene Ereignisse bewahren zeitzonenbezogene Start- und "
|
||||||
@@ -884,7 +944,9 @@ manifest = ModuleManifest(
|
|||||||
id="calendar.external-sources-and-sync",
|
id="calendar.external-sources-and-sync",
|
||||||
title="Connect and synchronize external calendars",
|
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.",
|
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.",
|
body="Documentation books sit beside Calendars, Calendar display, and the relevant event, source, "
|
||||||
|
"move, or recovery dialog title. Field help remains beside its label. "
|
||||||
|
"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"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "calendar_manager", "operator"),
|
audience=("user", "calendar_manager", "operator"),
|
||||||
related_modules=("connectors", "audit", "ops"),
|
related_modules=("connectors", "audit", "ops"),
|
||||||
@@ -896,6 +958,9 @@ manifest = ModuleManifest(
|
|||||||
"ICS-/webcal-, Microsoft-Graph- und Exchange-Web-Services-Quellen."
|
"ICS-/webcal-, Microsoft-Graph- und Exchange-Web-Services-Quellen."
|
||||||
),
|
),
|
||||||
"body": (
|
"body": (
|
||||||
|
"Dokumentationsbücher stehen neben Kalender, Kalenderanzeige und dem jeweiligen Dialogtitel "
|
||||||
|
"für Ereignisse, Quellen, Verschiebungen oder Wiederherstellung. Feldhilfe bleibt neben der "
|
||||||
|
"Feldbezeichnung. "
|
||||||
"Jede externe Quelle hält URL, Synchronisationsrichtung, Status und Zugangsdatenverweis gemeinsam mit der "
|
"Jede externe Quelle hält URL, Synchronisationsrichtung, Status und Zugangsdatenverweis gemeinsam mit der "
|
||||||
"Calendar-Sammlung. Open-Xchange verwendet den bewährten CalDAV-Transport und bewahrt dabei Connector-Profil-, "
|
"Calendar-Sammlung. Open-Xchange verwendet den bewährten CalDAV-Transport und bewahrt dabei Connector-Profil-, "
|
||||||
"Identitäts-/Gruppenzuordnungs- und Ressourcenkalenderverweise. Manuelle und geplante Synchronisation zeichnet begrenzte "
|
"Identitäts-/Gruppenzuordnungs- und Ressourcenkalenderverweise. Manuelle und geplante Synchronisation zeichnet begrenzte "
|
||||||
@@ -1166,6 +1231,31 @@ manifest = ModuleManifest(
|
|||||||
order=50,
|
order=50,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_surfaces=(
|
||||||
|
ProductSurfaceContribution(
|
||||||
|
id="meetings.calendar",
|
||||||
|
module_id="calendar",
|
||||||
|
label="i18n:govoplan-core.product_surface.calendar",
|
||||||
|
description="i18n:govoplan-core.product_surface.calendar_description",
|
||||||
|
icon="calendar",
|
||||||
|
entry_path="/agenda",
|
||||||
|
route_path="/calendar",
|
||||||
|
surface_ids=("calendar.nav.calendar", "calendar.route.calendar"),
|
||||||
|
presentations=("task", "reader"),
|
||||||
|
search_source_ids=("calendar.events",),
|
||||||
|
help_context_ids=("calendar.page",),
|
||||||
|
documentation_topic_ids=("calendar.quick-access-and-product-area",),
|
||||||
|
required_any=("calendar:event:read",),
|
||||||
|
order=10,
|
||||||
|
unavailable=ProductAvailabilityExplanation(
|
||||||
|
reason="authorization",
|
||||||
|
title="i18n:govoplan-core.product_surface.unavailable",
|
||||||
|
description="i18n:govoplan-core.product_surface.unavailable_description",
|
||||||
|
resolution="i18n:govoplan-core.product_surface.unavailable_resolution",
|
||||||
|
responsible_role="i18n:govoplan-core.access_administrator",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
quick_access_tools=(
|
quick_access_tools=(
|
||||||
QuickAccessTool(
|
QuickAccessTool(
|
||||||
id="calendar.agenda",
|
id="calendar.agenda",
|
||||||
@@ -1208,5 +1298,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
"""Data-only recurrence work; resource boundary, not an arbitrary-code sandbox."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from govoplan_core.security.bounded_process import ProcessLimits
|
||||||
|
from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload
|
||||||
|
|
||||||
|
RECURRENCE_LIMITS = ProcessLimits(
|
||||||
|
wall_seconds=5, cpu_seconds=3, memory_bytes=256 * 1024 * 1024,
|
||||||
|
input_bytes=4 * 1024 * 1024, output_bytes=4 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def expand_recurrences_worker(payload: bytes) -> bytes:
|
||||||
|
from govoplan_calendar.backend.ical import _expand_event_occurrences
|
||||||
|
|
||||||
|
value = decode_worker_payload(payload, max_bytes=RECURRENCE_LIMITS.input_bytes)
|
||||||
|
try:
|
||||||
|
if not isinstance(value, dict) or set(value) != {"events", "start", "end", "limit"}:
|
||||||
|
raise ValueError("Invalid request")
|
||||||
|
if not isinstance(value["events"], list) or len(value["events"]) > 2_000:
|
||||||
|
raise ValueError("Invalid event count")
|
||||||
|
if type(value["limit"]) is not int or not 1 <= value["limit"] <= 10_000:
|
||||||
|
raise ValueError("Invalid result limit")
|
||||||
|
if not isinstance(value["start"], datetime) or not isinstance(value["end"], datetime):
|
||||||
|
raise ValueError("Invalid range")
|
||||||
|
remaining = value["limit"]
|
||||||
|
batches = []
|
||||||
|
for event in value["events"]:
|
||||||
|
if not isinstance(event, dict) or set(event) != {
|
||||||
|
"uid", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
|
||||||
|
}:
|
||||||
|
raise ValueError("Invalid event")
|
||||||
|
batch = _expand_event_occurrences(
|
||||||
|
SimpleNamespace(**event), value["start"], value["end"], limit=remaining,
|
||||||
|
)
|
||||||
|
remaining -= len(batch)
|
||||||
|
batches.append(batch)
|
||||||
|
result = {"occurrences": batches}
|
||||||
|
except (ValueError, TypeError, OverflowError, KeyError, AttributeError):
|
||||||
|
result = {"error": "invalid_or_excessive_recurrence"}
|
||||||
|
return encode_worker_payload(result, max_bytes=RECURRENCE_LIMITS.output_bytes)
|
||||||
@@ -11,9 +11,10 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any, Callable, Iterable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import LargeBinary, Text, case, cast, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.security.outbound_http import (
|
from govoplan_core.security.outbound_http import (
|
||||||
@@ -58,7 +59,9 @@ from govoplan_calendar.backend.ews import (
|
|||||||
)
|
)
|
||||||
from govoplan_calendar.backend.graph import graph_event_payload
|
from govoplan_calendar.backend.graph import graph_event_payload
|
||||||
from govoplan_calendar.backend.ical import (
|
from govoplan_calendar.backend.ical import (
|
||||||
|
ICalendarError,
|
||||||
expand_event_occurrences,
|
expand_event_occurrences,
|
||||||
|
expand_events_occurrences,
|
||||||
normalized_recurrence_id,
|
normalized_recurrence_id,
|
||||||
parse_vevent,
|
parse_vevent,
|
||||||
parse_vevents,
|
parse_vevents,
|
||||||
@@ -102,6 +105,19 @@ CALENDAR_EVENT_RESOURCE = "calendar_event"
|
|||||||
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
|
||||||
REMOTE_SYNC_MAX_ITEMS = 10_000
|
REMOTE_SYNC_MAX_ITEMS = 10_000
|
||||||
MAX_OCCURRENCE_RANGE_DAYS = 400
|
MAX_OCCURRENCE_RANGE_DAYS = 400
|
||||||
|
MAX_OCCURRENCE_CANDIDATES = 2_000
|
||||||
|
MAX_OCCURRENCE_RESULTS = 10_000
|
||||||
|
MAX_OCCURRENCE_PROJECTION_BYTES = 4 * 1024 * 1024
|
||||||
|
MAX_OCCURRENCE_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||||
|
OCCURRENCE_AVAILABILITY_FIELDS = (
|
||||||
|
"id", "calendar_id", "uid", "recurrence_id", "status", "transparency",
|
||||||
|
"start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
|
||||||
|
)
|
||||||
|
OCCURRENCE_DETAIL_FIELDS = OCCURRENCE_AVAILABILITY_FIELDS + (
|
||||||
|
"tenant_id", "sequence", "summary", "description", "location", "classification",
|
||||||
|
"organizer", "attendees", "categories", "reminders", "attachments", "related_to",
|
||||||
|
"source_kind", "source_href", "etag", "icalendar", "created_at", "updated_at", "metadata_",
|
||||||
|
)
|
||||||
DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = {
|
DEFAULT_CALENDAR_VIEW_PREFERENCES: dict[str, bool | int] = {
|
||||||
"dim_weekends": True,
|
"dim_weekends": True,
|
||||||
"dim_off_hours": True,
|
"dim_off_hours": True,
|
||||||
@@ -3850,6 +3866,60 @@ def list_events(
|
|||||||
return query.all()
|
return query.all()
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_occurrence_candidates(query, fields: tuple[str, ...], remaining: list[int]):
|
||||||
|
"""Project only needed columns, hiding oversized values inside the SQL read.
|
||||||
|
|
||||||
|
A separate size-check query followed by ORM loading would race an update and
|
||||||
|
would still decode all JSON/Text in the driver before checking the budget.
|
||||||
|
CASE and the measured values here share one statement snapshot; a cumulative
|
||||||
|
SQL budget bounds all payload columns before the driver can decode them.
|
||||||
|
"""
|
||||||
|
dialect = query.session.get_bind().dialect.name
|
||||||
|
if dialect not in {"sqlite", "postgresql"}:
|
||||||
|
raise CalendarError("Bounded calendar projections require SQLite or PostgreSQL.")
|
||||||
|
columns = [getattr(CalendarEvent, name) for name in fields]
|
||||||
|
sizes = []
|
||||||
|
for column in columns:
|
||||||
|
text_value = cast(column, Text)
|
||||||
|
byte_length = func.length(cast(text_value, LargeBinary)) if dialect == "sqlite" else func.octet_length(text_value)
|
||||||
|
sizes.append(func.coalesce(byte_length, 0))
|
||||||
|
size = sum(sizes)
|
||||||
|
sizes_query = query.with_entities(
|
||||||
|
CalendarEvent.id.label("candidate_id"), size.label("projection_bytes"),
|
||||||
|
).subquery()
|
||||||
|
budgeted = select(
|
||||||
|
sizes_query,
|
||||||
|
func.sum(sizes_query.c.projection_bytes).over(order_by=sizes_query.c.candidate_id).label("cumulative_bytes"),
|
||||||
|
).subquery()
|
||||||
|
# The window is evaluated on small identity/size rows before selecting any
|
||||||
|
# payload. Even a buffering driver/sort can receive at most the remaining
|
||||||
|
# aggregate bytes, rather than thousands of individually allowed big events.
|
||||||
|
projected = query.session.query(
|
||||||
|
budgeted.c.projection_bytes,
|
||||||
|
*(case((budgeted.c.cumulative_bytes <= remaining[0], column), else_=None).label(name)
|
||||||
|
for name, column in zip(fields, columns, strict=True)),
|
||||||
|
).join(CalendarEvent, CalendarEvent.id == budgeted.c.candidate_id).order_by(budgeted.c.candidate_id)
|
||||||
|
rows = []
|
||||||
|
iterator = iter(projected.yield_per(1))
|
||||||
|
try:
|
||||||
|
for row in iterator:
|
||||||
|
remaining[0] -= row.projection_bytes
|
||||||
|
if remaining[0] < 0:
|
||||||
|
raise CalendarError("Calendar candidate projection exceeds its byte limit; request fewer calendars or a smaller range.")
|
||||||
|
rows.append(SimpleNamespace(**{name: getattr(row, name) for name in fields}))
|
||||||
|
finally:
|
||||||
|
close = getattr(iterator, "close", None)
|
||||||
|
if close is not None:
|
||||||
|
close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _occurrence_json_default(value: object) -> str:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
raise CalendarError("Calendar occurrence data cannot be encoded safely.")
|
||||||
|
|
||||||
|
|
||||||
def list_event_occurrences(
|
def list_event_occurrences(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -3859,6 +3929,25 @@ def list_event_occurrences(
|
|||||||
calendar_id: str | None = None,
|
calendar_id: str | None = None,
|
||||||
visible_calendar_ids: Iterable[str] | None = None,
|
visible_calendar_ids: Iterable[str] | None = None,
|
||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
|
_availability_only: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
from govoplan_core.security.bounded_process import ProcessBudgetError, bounded_operation_admission
|
||||||
|
|
||||||
|
try:
|
||||||
|
with bounded_operation_admission() as admission:
|
||||||
|
return _list_event_occurrences(
|
||||||
|
session, tenant_id=tenant_id, start_at=start_at, end_at=end_at,
|
||||||
|
calendar_id=calendar_id, visible_calendar_ids=visible_calendar_ids,
|
||||||
|
limit=limit, admission=admission, availability_only=_availability_only,
|
||||||
|
)
|
||||||
|
except (ProcessBudgetError, ICalendarError) as exc:
|
||||||
|
raise CalendarError("Calendar expansion could not complete within its limits; request a smaller range or fewer calendars, or retry later.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _list_event_occurrences(
|
||||||
|
session: Session, *, tenant_id: str, start_at: datetime, end_at: datetime,
|
||||||
|
calendar_id: str | None, visible_calendar_ids: Iterable[str] | None,
|
||||||
|
limit: int | None, admission: Any, availability_only: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return range-bounded events with recurring series fully reconciled."""
|
"""Return range-bounded events with recurring series fully reconciled."""
|
||||||
|
|
||||||
@@ -3879,13 +3968,17 @@ def list_event_occurrences(
|
|||||||
normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids))
|
normalized_calendar_ids = tuple(dict.fromkeys(visible_calendar_ids))
|
||||||
if not normalized_calendar_ids:
|
if not normalized_calendar_ids:
|
||||||
return []
|
return []
|
||||||
|
if len(normalized_calendar_ids) > MAX_OCCURRENCE_CANDIDATES:
|
||||||
|
raise CalendarError("Too many calendars for complete expansion; request fewer calendars.")
|
||||||
base_query = base_query.filter(
|
base_query = base_query.filter(
|
||||||
CalendarEvent.calendar_id.in_(normalized_calendar_ids)
|
CalendarEvent.calendar_id.in_(normalized_calendar_ids)
|
||||||
)
|
)
|
||||||
if calendar_id:
|
if calendar_id:
|
||||||
base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id)
|
base_query = base_query.filter(CalendarEvent.calendar_id == calendar_id)
|
||||||
|
|
||||||
recurring_masters = (
|
fields = OCCURRENCE_AVAILABILITY_FIELDS if availability_only else OCCURRENCE_DETAIL_FIELDS
|
||||||
|
projection_budget = [MAX_OCCURRENCE_PROJECTION_BYTES]
|
||||||
|
recurring_masters = _bounded_occurrence_candidates(
|
||||||
base_query.filter(
|
base_query.filter(
|
||||||
CalendarEvent.recurrence_id.is_(None),
|
CalendarEvent.recurrence_id.is_(None),
|
||||||
or_(
|
or_(
|
||||||
@@ -3894,9 +3987,11 @@ def list_event_occurrences(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
||||||
.all()
|
.limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
|
||||||
)
|
)
|
||||||
direct_events = (
|
if len(recurring_masters) > MAX_OCCURRENCE_CANDIDATES:
|
||||||
|
raise CalendarError("Too many recurring series for complete expansion; request fewer calendars.")
|
||||||
|
direct_events = _bounded_occurrence_candidates(
|
||||||
base_query.filter(
|
base_query.filter(
|
||||||
or_(
|
or_(
|
||||||
CalendarEvent.end_at.is_(None),
|
CalendarEvent.end_at.is_(None),
|
||||||
@@ -3905,8 +4000,10 @@ def list_event_occurrences(
|
|||||||
CalendarEvent.start_at <= range_end,
|
CalendarEvent.start_at <= range_end,
|
||||||
)
|
)
|
||||||
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
.order_by(CalendarEvent.start_at.asc(), CalendarEvent.id.asc())
|
||||||
.all()
|
.limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
|
||||||
)
|
)
|
||||||
|
if len(direct_events) > MAX_OCCURRENCE_CANDIDATES:
|
||||||
|
raise CalendarError("Too many events for complete expansion; request a smaller range or fewer calendars.")
|
||||||
|
|
||||||
series_keys = {
|
series_keys = {
|
||||||
(event.calendar_id, event.uid) for event in recurring_masters
|
(event.calendar_id, event.uid) for event in recurring_masters
|
||||||
@@ -3914,12 +4011,16 @@ def list_event_occurrences(
|
|||||||
overrides: list[CalendarEvent] = []
|
overrides: list[CalendarEvent] = []
|
||||||
if series_keys:
|
if series_keys:
|
||||||
series_uids = {uid for _calendar_id, uid in series_keys}
|
series_uids = {uid for _calendar_id, uid in series_keys}
|
||||||
overrides = [
|
override_candidates = _bounded_occurrence_candidates(
|
||||||
event
|
base_query.filter(
|
||||||
for event in base_query.filter(
|
|
||||||
CalendarEvent.recurrence_id.is_not(None),
|
CalendarEvent.recurrence_id.is_not(None),
|
||||||
CalendarEvent.uid.in_(series_uids),
|
CalendarEvent.uid.in_(series_uids),
|
||||||
).all()
|
).limit(MAX_OCCURRENCE_CANDIDATES + 1), fields, projection_budget,
|
||||||
|
)
|
||||||
|
if len(override_candidates) > MAX_OCCURRENCE_CANDIDATES:
|
||||||
|
raise CalendarError("Too many overrides for complete expansion; request fewer calendars.")
|
||||||
|
overrides = [
|
||||||
|
event for event in override_candidates
|
||||||
if (event.calendar_id, event.uid) in series_keys
|
if (event.calendar_id, event.uid) in series_keys
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3934,19 +4035,31 @@ def list_event_occurrences(
|
|||||||
)[recurrence_key] = override
|
)[recurrence_key] = override
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
|
response_bytes = 2 # JSON list brackets; charge the complete unsliced result.
|
||||||
|
|
||||||
|
def append_occurrence(event, **kwargs) -> None:
|
||||||
|
nonlocal response_bytes
|
||||||
|
if len(results) >= MAX_OCCURRENCE_RESULTS:
|
||||||
|
raise CalendarError("Too many occurrences for a complete result; request a smaller range or fewer calendars.")
|
||||||
|
payload = expanded_event_response(event, availability_only=availability_only, **kwargs)
|
||||||
|
response_bytes += len(json.dumps(payload, default=_occurrence_json_default, ensure_ascii=True).encode("utf-8")) + 2
|
||||||
|
if response_bytes > MAX_OCCURRENCE_RESPONSE_BYTES:
|
||||||
|
raise CalendarError("Expanded calendar response exceeds its byte limit; request a smaller range or fewer calendars.")
|
||||||
|
results.append(payload)
|
||||||
|
|
||||||
consumed_event_ids: set[str] = set()
|
consumed_event_ids: set[str] = set()
|
||||||
recurring_master_ids = {event.id for event in recurring_masters}
|
recurring_master_ids = {event.id for event in recurring_masters}
|
||||||
recurring_master_by_series = {
|
recurring_master_by_series = {
|
||||||
(event.calendar_id, event.uid): event for event in recurring_masters
|
(event.calendar_id, event.uid): event for event in recurring_masters
|
||||||
}
|
}
|
||||||
for master in recurring_masters:
|
expanded_batches = expand_events_occurrences(
|
||||||
|
recurring_masters, range_start, range_end,
|
||||||
|
limit=MAX_OCCURRENCE_RESULTS, admission=admission,
|
||||||
|
)
|
||||||
|
for master, expanded in zip(recurring_masters, expanded_batches, strict=True):
|
||||||
series_key = (master.calendar_id, master.uid)
|
series_key = (master.calendar_id, master.uid)
|
||||||
series_overrides = overrides_by_series.get(series_key, {})
|
series_overrides = overrides_by_series.get(series_key, {})
|
||||||
for occurrence in expand_event_occurrences(
|
for occurrence in expanded:
|
||||||
master,
|
|
||||||
range_start,
|
|
||||||
range_end,
|
|
||||||
):
|
|
||||||
occurrence_recurrence_id = str(
|
occurrence_recurrence_id = str(
|
||||||
occurrence.get("recurrence_id") or ""
|
occurrence.get("recurrence_id") or ""
|
||||||
)
|
)
|
||||||
@@ -3967,26 +4080,19 @@ def list_event_occurrences(
|
|||||||
range_start=range_start,
|
range_start=range_start,
|
||||||
range_end=range_end,
|
range_end=range_end,
|
||||||
):
|
):
|
||||||
results.append(
|
append_occurrence(
|
||||||
expanded_event_response(
|
|
||||||
override,
|
override,
|
||||||
series_event_id=master.id,
|
series_event_id=master.id,
|
||||||
recurrence_id=(
|
recurrence_id=(override.recurrence_id or occurrence_recurrence_id),
|
||||||
override.recurrence_id
|
|
||||||
or occurrence_recurrence_id
|
|
||||||
),
|
|
||||||
is_override=True,
|
is_override=True,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
results.append(
|
append_occurrence(
|
||||||
expanded_event_response(
|
|
||||||
master,
|
master,
|
||||||
series_event_id=master.id,
|
series_event_id=master.id,
|
||||||
recurrence_id=occurrence_recurrence_id,
|
recurrence_id=occurrence_recurrence_id,
|
||||||
occurrence=occurrence,
|
occurrence=occurrence,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
for event in direct_events:
|
for event in direct_events:
|
||||||
if event.id in recurring_master_ids or event.id in consumed_event_ids:
|
if event.id in recurring_master_ids or event.id in consumed_event_ids:
|
||||||
@@ -4002,20 +4108,18 @@ def list_event_occurrences(
|
|||||||
is_override = True
|
is_override = True
|
||||||
if event.status.upper() == "CANCELLED":
|
if event.status.upper() == "CANCELLED":
|
||||||
continue
|
continue
|
||||||
results.append(
|
append_occurrence(
|
||||||
expanded_event_response(
|
|
||||||
event,
|
event,
|
||||||
series_event_id=series_event_id,
|
series_event_id=series_event_id,
|
||||||
recurrence_id=normalized_recurrence_id(event.recurrence_id),
|
recurrence_id=normalized_recurrence_id(event.recurrence_id),
|
||||||
is_override=is_override,
|
is_override=is_override,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
sorted_results = sorted(
|
sorted_results = sorted(
|
||||||
results,
|
results,
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
item["start_at"],
|
item["start_at"],
|
||||||
item["calendar_id"],
|
item["calendar_id"],
|
||||||
item["summary"],
|
item.get("summary", ""),
|
||||||
item["instance_id"],
|
item["instance_id"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -4044,8 +4148,16 @@ def expanded_event_response(
|
|||||||
recurrence_id: str | None,
|
recurrence_id: str | None,
|
||||||
occurrence: dict[str, Any] | None = None,
|
occurrence: dict[str, Any] | None = None,
|
||||||
is_override: bool = False,
|
is_override: bool = False,
|
||||||
|
availability_only: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = event_response(event)
|
payload = (
|
||||||
|
{
|
||||||
|
"id": event.id, "calendar_id": event.calendar_id, "uid": event.uid,
|
||||||
|
"start_at": response_datetime(event.start_at), "end_at": response_datetime(event.end_at),
|
||||||
|
"all_day": event.all_day, "status": event.status, "transparency": event.transparency,
|
||||||
|
}
|
||||||
|
if availability_only else event_response(event)
|
||||||
|
)
|
||||||
if occurrence is not None:
|
if occurrence is not None:
|
||||||
payload["start_at"] = response_datetime(occurrence["start_at"])
|
payload["start_at"] = response_datetime(occurrence["start_at"])
|
||||||
payload["end_at"] = response_datetime(occurrence.get("end_at"))
|
payload["end_at"] = response_datetime(occurrence.get("end_at"))
|
||||||
@@ -4074,24 +4186,12 @@ def list_freebusy(
|
|||||||
range_end = normalize_datetime(end_at)
|
range_end = normalize_datetime(end_at)
|
||||||
if range_end < range_start:
|
if range_end < range_start:
|
||||||
raise CalendarError("Free/busy end must be after start")
|
raise CalendarError("Free/busy end must be after start")
|
||||||
events: list[dict[str, Any]] = []
|
# One admission and one worker budget for the entire requested collection,
|
||||||
if calendar_ids:
|
# not one independently reset expansion allowance per calendar.
|
||||||
for calendar_id in dict.fromkeys(calendar_ids):
|
|
||||||
events.extend(
|
|
||||||
list_event_occurrences(
|
|
||||||
session,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
calendar_id=calendar_id,
|
|
||||||
start_at=range_start,
|
|
||||||
end_at=range_end,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
events = list_event_occurrences(
|
events = list_event_occurrences(
|
||||||
session,
|
session, tenant_id=tenant_id, start_at=range_start, end_at=range_end,
|
||||||
tenant_id=tenant_id,
|
visible_calendar_ids=calendar_ids or None,
|
||||||
start_at=range_start,
|
_availability_only=True,
|
||||||
end_at=range_end,
|
|
||||||
)
|
)
|
||||||
busy = [
|
busy = [
|
||||||
{
|
{
|
||||||
@@ -4600,11 +4700,14 @@ def recurrence_occurrence(
|
|||||||
if recurrence_start is None:
|
if recurrence_start is None:
|
||||||
raise CalendarError("Invalid recurrence_id")
|
raise CalendarError("Invalid recurrence_id")
|
||||||
recurrence_key = normalized_recurrence_id(recurrence_id)
|
recurrence_key = normalized_recurrence_id(recurrence_id)
|
||||||
|
try:
|
||||||
candidates = expand_event_occurrences(
|
candidates = expand_event_occurrences(
|
||||||
master,
|
master,
|
||||||
recurrence_start - timedelta(seconds=1),
|
recurrence_start - timedelta(seconds=1),
|
||||||
recurrence_start + timedelta(seconds=1),
|
recurrence_start + timedelta(seconds=1),
|
||||||
)
|
)
|
||||||
|
except ICalendarError as exc:
|
||||||
|
raise CalendarError("The recurrence could not be verified within its limits; the occurrence was not changed.") from exc
|
||||||
occurrence = next(
|
occurrence = next(
|
||||||
(
|
(
|
||||||
item
|
item
|
||||||
|
|||||||
+30
-1
@@ -3,11 +3,40 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_calendar.backend.ical import expand_event_occurrences, event_to_ics, parse_vevent
|
from govoplan_calendar.backend.ical import ICalendarError, _expand_event_occurrences, expand_event_occurrences, event_to_ics, parse_vevent
|
||||||
|
|
||||||
|
|
||||||
class ICalendarParsingTests(unittest.TestCase):
|
class ICalendarParsingTests(unittest.TestCase):
|
||||||
|
def test_recurrence_limit_rejects_without_materializing_between(self) -> None:
|
||||||
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
event = SimpleNamespace(
|
||||||
|
uid="bounded@example.test", start_at=start, end_at=None, duration_seconds=None, all_day=False,
|
||||||
|
rrule={"FREQ": "SECONDLY", "COUNT": "121"}, rdate=[], exdate=[],
|
||||||
|
)
|
||||||
|
with patch("dateutil.rrule.rruleset.between", side_effect=AssertionError("Unbounded allocation")):
|
||||||
|
with self.assertRaisesRegex(ICalendarError, "result limit"):
|
||||||
|
_expand_event_occurrences(
|
||||||
|
event, start, datetime(2026, 7, 1, 0, 2, tzinfo=timezone.utc), limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_worker_ack_is_rejected(self) -> None:
|
||||||
|
from govoplan_core.security.worker_payload import encode_worker_payload
|
||||||
|
|
||||||
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
event = SimpleNamespace(
|
||||||
|
uid="expected@example.test", start_at=start, end_at=None, all_day=False,
|
||||||
|
rrule={"FREQ": "DAILY", "COUNT": "1"}, rdate=[], exdate=[],
|
||||||
|
)
|
||||||
|
wrong = encode_worker_payload({"occurrences": [[{
|
||||||
|
"uid": "different@example.test", "recurrence_id": "20260701T000000Z",
|
||||||
|
"start_at": start, "end_at": None, "all_day": False,
|
||||||
|
}]]})
|
||||||
|
with patch("govoplan_core.security.bounded_process.run_bounded_operation", return_value=wrong):
|
||||||
|
with self.assertRaisesRegex(ICalendarError, "invalid result"):
|
||||||
|
expand_event_occurrences(event, start, start)
|
||||||
|
|
||||||
def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None:
|
def test_parse_vevent_preserves_unknown_properties_and_params(self) -> None:
|
||||||
payload = """BEGIN:VCALENDAR
|
payload = """BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
|
|||||||
@@ -104,17 +104,20 @@ class CalendarInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
for component in (
|
for component in (
|
||||||
"ActionBlockerHint",
|
"ActionBlockerHint",
|
||||||
"ConfirmDialog",
|
"ConfirmDialog",
|
||||||
"DocumentationHelpLink",
|
"titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}",
|
||||||
"useUnsavedDraftGuard",
|
"useUnsavedDraftGuard",
|
||||||
):
|
):
|
||||||
self.assertIn(component, event_dialog)
|
self.assertIn(component, event_dialog)
|
||||||
for component in (
|
for component in (
|
||||||
"ActionBlockerHint",
|
"ActionBlockerHint",
|
||||||
"DocumentationHelpLink",
|
"titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}",
|
||||||
"useUnsavedDraftGuard",
|
"useUnsavedDraftGuard",
|
||||||
):
|
):
|
||||||
self.assertIn(component, collection_dialog)
|
self.assertIn(component, collection_dialog)
|
||||||
for component in ("DocumentationHelpLink", "useUnsavedDraftGuard"):
|
for component in (
|
||||||
|
"titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}",
|
||||||
|
"useUnsavedDraftGuard",
|
||||||
|
):
|
||||||
self.assertIn(component, settings_panel)
|
self.assertIn(component, settings_panel)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, create_mock_engine, event as sqlalchemy_event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy.orm import Query, Session, sessionmaker
|
||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||||
from govoplan_calendar.backend.db.models import CalendarEvent
|
from govoplan_calendar.backend.db.models import CalendarEvent
|
||||||
@@ -16,6 +18,8 @@ from govoplan_calendar.backend.schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_calendar.backend.service import (
|
from govoplan_calendar.backend.service import (
|
||||||
CalendarError,
|
CalendarError,
|
||||||
|
_bounded_occurrence_candidates,
|
||||||
|
OCCURRENCE_DETAIL_FIELDS,
|
||||||
create_calendar,
|
create_calendar,
|
||||||
create_event,
|
create_event,
|
||||||
delete_event,
|
delete_event,
|
||||||
@@ -73,6 +77,159 @@ class CalendarRecurrenceAndPreferenceTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_freebusy_returns_all_1001_hourly_occurrences(self) -> None:
|
||||||
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
create_event(
|
||||||
|
self.session, tenant_id="tenant-1", user_id=None,
|
||||||
|
payload=CalendarEventCreateRequest(
|
||||||
|
calendar_id=self.calendar.id, uid="dense@example.test", summary="Dense series",
|
||||||
|
start_at=start, end_at=start + timedelta(minutes=1),
|
||||||
|
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
busy = list_freebusy(
|
||||||
|
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
|
||||||
|
start_at=start, end_at=start + timedelta(days=43),
|
||||||
|
)
|
||||||
|
self.assertEqual(1001, len(busy))
|
||||||
|
self.assertEqual(start + timedelta(hours=1000), busy[-1]["start_at"])
|
||||||
|
|
||||||
|
def test_freebusy_over_budget_fails_without_partial_busy_slots(self) -> None:
|
||||||
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
create_event(
|
||||||
|
self.session, tenant_id="tenant-1", user_id=None,
|
||||||
|
payload=CalendarEventCreateRequest(
|
||||||
|
calendar_id=self.calendar.id, uid="excessive@example.test", summary="Excessive series",
|
||||||
|
start_at=start, end_at=start + timedelta(seconds=1),
|
||||||
|
rrule={"FREQ": "MINUTELY", "COUNT": "10001"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
with self.assertRaisesRegex(CalendarError, "could not complete"):
|
||||||
|
list_freebusy(
|
||||||
|
self.session, tenant_id="tenant-1", calendar_ids=[self.calendar.id],
|
||||||
|
start_at=start, end_at=start + timedelta(days=8),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, self.session.query(CalendarEvent).count())
|
||||||
|
|
||||||
|
def test_full_expansion_byte_cap_and_lightweight_complete_freebusy(self) -> None:
|
||||||
|
from govoplan_calendar.backend import service
|
||||||
|
|
||||||
|
start = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
create_event(
|
||||||
|
self.session, tenant_id="tenant-1", user_id=None,
|
||||||
|
payload=CalendarEventCreateRequest(
|
||||||
|
calendar_id=self.calendar.id, uid="heavy@example.test", summary="Heavy series",
|
||||||
|
description="x" * 65536, start_at=start, end_at=start + timedelta(minutes=1),
|
||||||
|
rrule={"FREQ": "HOURLY", "COUNT": "1001"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
calendar_id = self.calendar.id
|
||||||
|
self.session.expunge_all()
|
||||||
|
with patch.object(service, "expanded_event_response", wraps=service.expanded_event_response) as expand:
|
||||||
|
with self.assertRaisesRegex(CalendarError, "response exceeds its byte limit"):
|
||||||
|
list_event_occurrences(self.session, tenant_id="tenant-1", start_at=start, end_at=start + timedelta(days=43), limit=1)
|
||||||
|
self.assertLess(expand.call_count, 70)
|
||||||
|
statements = []
|
||||||
|
loaded = []
|
||||||
|
def capture(conn, cursor, statement, parameters, context, executemany):
|
||||||
|
statements.append(statement)
|
||||||
|
def capture_loaded(session, instance):
|
||||||
|
if isinstance(instance, CalendarEvent):
|
||||||
|
loaded.append(instance)
|
||||||
|
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
|
||||||
|
sqlalchemy_event.listen(self.session, "loaded_as_persistent", capture_loaded)
|
||||||
|
try:
|
||||||
|
with patch.object(service, "event_response", side_effect=AssertionError("Full event data is unnecessary for availability")):
|
||||||
|
busy = list_freebusy(self.session, tenant_id="tenant-1", calendar_ids=[calendar_id], start_at=start, end_at=start + timedelta(days=43))
|
||||||
|
self.assertEqual(1001, len(busy))
|
||||||
|
self.assertEqual([], loaded)
|
||||||
|
for field in ("description", "raw_ics", "icalendar", "metadata", "attendees", "attachments"):
|
||||||
|
self.assertNotIn(f"calendar_events.{field}", "\n".join(statements))
|
||||||
|
finally:
|
||||||
|
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
|
||||||
|
sqlalchemy_event.remove(self.session, "loaded_as_persistent", capture_loaded)
|
||||||
|
|
||||||
|
def test_aggregate_sql_projection_hides_over_budget_values_before_driver_decoding(self) -> None:
|
||||||
|
from govoplan_calendar.backend import service
|
||||||
|
|
||||||
|
first = self.recurring_master()
|
||||||
|
second = create_event(
|
||||||
|
self.session, tenant_id="tenant-1", user_id=None,
|
||||||
|
payload=CalendarEventCreateRequest(
|
||||||
|
calendar_id=self.calendar.id, uid="second@example.test", summary="Second",
|
||||||
|
start_at=first.start_at, end_at=first.end_at, rrule={"FREQ": "DAILY", "COUNT": "2"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
first.description = second.description = "x" * 1500
|
||||||
|
self.session.commit()
|
||||||
|
self.session.expunge_all()
|
||||||
|
captured = []
|
||||||
|
def capture(conn, cursor, statement, parameters, context, executemany):
|
||||||
|
captured.append((statement, parameters))
|
||||||
|
sqlalchemy_event.listen(self.engine, "before_cursor_execute", capture)
|
||||||
|
try:
|
||||||
|
with patch.object(service, "MAX_OCCURRENCE_PROJECTION_BYTES", 3000), patch.object(service, "expand_events_occurrences") as worker:
|
||||||
|
with self.assertRaisesRegex(CalendarError, "candidate projection exceeds its byte limit"):
|
||||||
|
list_event_occurrences(
|
||||||
|
self.session, tenant_id="tenant-1", start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
worker.assert_not_called()
|
||||||
|
finally:
|
||||||
|
sqlalchemy_event.remove(self.engine, "before_cursor_execute", capture)
|
||||||
|
self.assertEqual(1, len(captured))
|
||||||
|
statement, parameters = captured[0]
|
||||||
|
self.assertIn("sum(", statement)
|
||||||
|
self.assertIn("OVER (ORDER BY", statement)
|
||||||
|
with self.engine.connect() as connection:
|
||||||
|
raw = list(connection.exec_driver_sql(statement, parameters))
|
||||||
|
self.assertEqual(2, len(raw))
|
||||||
|
self.assertIsNotNone(raw[0][1])
|
||||||
|
self.assertTrue(all(value is None for value in raw[1][1:]))
|
||||||
|
|
||||||
|
def test_sql_projection_compiles_postgresql_without_binary_json_casts(self) -> None:
|
||||||
|
statements = []
|
||||||
|
session = Session(bind=create_mock_engine("postgresql://", lambda *args, **kwargs: None))
|
||||||
|
def inspect_query(query):
|
||||||
|
statements.append(str(query.statement.compile(dialect=postgresql.dialect())))
|
||||||
|
return iter(())
|
||||||
|
with patch.object(Query, "__iter__", inspect_query):
|
||||||
|
self.assertEqual([], _bounded_occurrence_candidates(
|
||||||
|
session.query(CalendarEvent).filter(CalendarEvent.tenant_id == "tenant-1").limit(2001),
|
||||||
|
OCCURRENCE_DETAIL_FIELDS, [4096],
|
||||||
|
))
|
||||||
|
self.assertIn("octet_length(CAST(calendar_events.icalendar AS TEXT))", statements[0])
|
||||||
|
self.assertIn("CASE WHEN", statements[0])
|
||||||
|
self.assertIn("OVER (ORDER BY", statements[0])
|
||||||
|
self.assertNotIn("BYTEA", statements[0])
|
||||||
|
|
||||||
|
def test_candidate_budget_is_not_bypassed_by_response_limit(self) -> None:
|
||||||
|
self.recurring_master()
|
||||||
|
self.session.commit()
|
||||||
|
with patch("govoplan_calendar.backend.service.MAX_OCCURRENCE_CANDIDATES", 0):
|
||||||
|
with self.assertRaisesRegex(CalendarError, "Too many recurring series"):
|
||||||
|
list_event_occurrences(
|
||||||
|
self.session, tenant_id="tenant-1", limit=1,
|
||||||
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admission_rejection_precedes_database_projection(self) -> None:
|
||||||
|
from govoplan_core.security.bounded_process import ProcessBudgetError
|
||||||
|
|
||||||
|
with patch("govoplan_core.security.bounded_process.bounded_operation_admission", side_effect=ProcessBudgetError("busy")):
|
||||||
|
with patch.object(self.session, "query") as query:
|
||||||
|
with self.assertRaisesRegex(CalendarError, "could not complete"):
|
||||||
|
list_event_occurrences(
|
||||||
|
self.session, tenant_id="tenant-1",
|
||||||
|
start_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||||
|
end_at=datetime(2026, 7, 31, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
query.assert_not_called()
|
||||||
|
|
||||||
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
|
def test_occurrence_override_and_cancellation_reconcile_list_and_freebusy(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.20",
|
"version": "0.1.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"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"
|
"test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.44",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ export function CalendarCollectionDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
|
title={isEdit ? "i18n:govoplan-calendar.edit_calendar.a47a2a7a" : "i18n:govoplan-calendar.add_calendar.8fadb5bc"}
|
||||||
|
titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}
|
||||||
size="large"
|
size="large"
|
||||||
footerClassName="calendar-event-dialog-footer"
|
footerClassName="calendar-event-dialog-footer"
|
||||||
closeDisabled={saving}
|
closeDisabled={saving}
|
||||||
@@ -373,9 +374,6 @@ export function CalendarCollectionDialog({
|
|||||||
}>
|
}>
|
||||||
|
|
||||||
<DialogForm id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
<DialogForm id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||||
<div className="calendar-dialog-documentation">
|
|
||||||
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
|
|
||||||
</div>
|
|
||||||
{migrationLocked && (
|
{migrationLocked && (
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
reason={{
|
reason={{
|
||||||
@@ -675,6 +673,7 @@ export function CalendarCollectionDeleteDialog({
|
|||||||
open
|
open
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
title={`${actionLabel} calendar`}
|
title={`${actionLabel} calendar`}
|
||||||
|
titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}
|
||||||
className="calendar-delete-dialog"
|
className="calendar-delete-dialog"
|
||||||
footerClassName="calendar-event-dialog-footer"
|
footerClassName="calendar-event-dialog-footer"
|
||||||
closeDisabled={saving}
|
closeDisabled={saving}
|
||||||
@@ -689,9 +688,6 @@ export function CalendarCollectionDeleteDialog({
|
|||||||
}>
|
}>
|
||||||
|
|
||||||
<div className="calendar-delete-dialog-body">
|
<div className="calendar-delete-dialog-body">
|
||||||
<div className="calendar-dialog-documentation">
|
|
||||||
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
|
|
||||||
</div>
|
|
||||||
<p className="calendar-delete-warning">
|
<p className="calendar-delete-warning">
|
||||||
{loadingEventCount ?
|
{loadingEventCount ?
|
||||||
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
|
"i18n:govoplan-calendar.loading_event_count.716ad3c2" :
|
||||||
@@ -703,6 +699,8 @@ export function CalendarCollectionDeleteDialog({
|
|||||||
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
|
<legend>{eventCount} event{eventCount === 1 ? "" : "s"}</legend>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
data-help-context-id="calendar.collection-editor"
|
||||||
|
data-help-module-id="calendar"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="calendar-delete-event-action"
|
name="calendar-delete-event-action"
|
||||||
value="delete"
|
value="delete"
|
||||||
@@ -713,6 +711,8 @@ export function CalendarCollectionDeleteDialog({
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
|
data-help-context-id="calendar.collection-editor"
|
||||||
|
data-help-module-id="calendar"
|
||||||
type="radio"
|
type="radio"
|
||||||
name="calendar-delete-event-action"
|
name="calendar-delete-event-action"
|
||||||
value="move"
|
value="move"
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ export function CalendarEventDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
|
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
|
||||||
|
titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}
|
||||||
className="calendar-vevent-dialog"
|
className="calendar-vevent-dialog"
|
||||||
footerClassName="calendar-event-dialog-footer"
|
footerClassName="calendar-event-dialog-footer"
|
||||||
closeDisabled={saving}
|
closeDisabled={saving}
|
||||||
@@ -289,7 +290,7 @@ export function CalendarEventDialog({
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
{event && canDelete &&
|
{event && canDelete &&
|
||||||
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
|
<Button type="button" variant="danger" helpContextId="calendar.event-editor" helpModuleId="calendar" onClick={requestDelete} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
|
||||||
<Trash2 size={16} /> i18n:govoplan-calendar.delete.f6fdbe48
|
<Trash2 size={16} /> i18n:govoplan-calendar.delete.f6fdbe48
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -302,9 +303,6 @@ export function CalendarEventDialog({
|
|||||||
}>
|
}>
|
||||||
|
|
||||||
<DialogForm id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
<DialogForm id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||||
<div className="calendar-dialog-documentation">
|
|
||||||
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
|
|
||||||
</div>
|
|
||||||
{!canWrite && (
|
{!canWrite && (
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
tone="info"
|
tone="info"
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export function CalendarMigrationDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
title="Remote calendar move"
|
title="Remote calendar move"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />}
|
||||||
className="calendar-migration-dialog"
|
className="calendar-migration-dialog"
|
||||||
closeDisabled={working}
|
closeDisabled={working}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -113,7 +114,6 @@ export function CalendarMigrationDialog({
|
|||||||
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
|
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
|
||||||
) : (
|
) : (
|
||||||
<div className="calendar-migration-body">
|
<div className="calendar-migration-body">
|
||||||
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
|
|
||||||
{error && (
|
{error && (
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export function CalendarOutboxDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
title="i18n:govoplan-calendar.outbound_changes.7038a839"
|
title="i18n:govoplan-calendar.outbound_changes.7038a839"
|
||||||
|
titleHelp={<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />}
|
||||||
className="calendar-outbox-dialog"
|
className="calendar-outbox-dialog"
|
||||||
closeDisabled={Boolean(busyOperationId)}
|
closeDisabled={Boolean(busyOperationId)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
@@ -111,7 +112,6 @@ export function CalendarOutboxDialog({
|
|||||||
>
|
>
|
||||||
<div className="calendar-outbox-body">
|
<div className="calendar-outbox-body">
|
||||||
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
|
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
|
||||||
<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
<ActionToolbar justify="between" className="calendar-outbox-toolbar">
|
<ActionToolbar justify="between" className="calendar-outbox-toolbar">
|
||||||
<SegmentedControl<OutboxFilter>
|
<SegmentedControl<OutboxFilter>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ToolbarGroup, ActionToolbar,
|
|||||||
Button,
|
Button,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
|
TextWithHelp,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
TableActionGroup,
|
TableActionGroup,
|
||||||
@@ -799,7 +800,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548" className="calendar-loading-frame">
|
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548" className="calendar-loading-frame">
|
||||||
<div className="calendar-shell">
|
<div className="calendar-shell">
|
||||||
<aside className="calendar-sidebar">
|
<aside className="calendar-sidebar">
|
||||||
<div className="calendar-sidebar-heading">i18n:govoplan-calendar.calendars.94445018</div>
|
<TextWithHelp as="div" className="calendar-sidebar-heading" help={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}>i18n:govoplan-calendar.calendars.94445018</TextWithHelp>
|
||||||
<div className="calendar-list">
|
<div className="calendar-list">
|
||||||
{calendars.map((calendar) => {
|
{calendars.map((calendar) => {
|
||||||
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
|
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
|
||||||
@@ -930,7 +931,6 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
|||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
|
|
||||||
<ToolbarGroup align="end" className="calendar-toolbar-right">
|
<ToolbarGroup align="end" className="calendar-toolbar-right">
|
||||||
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
|
|
||||||
<AdminIconButton
|
<AdminIconButton
|
||||||
label="i18n:govoplan-calendar.refresh.56e3badc"
|
label="i18n:govoplan-calendar.refresh.56e3badc"
|
||||||
icon={<RefreshCw size={18} />}
|
icon={<RefreshCw size={18} />}
|
||||||
|
|||||||
@@ -125,10 +125,7 @@ export default function CalendarSettingsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentGrid columns={2} collapseAt="workspace" className="calendar-settings-panel">
|
<ContentGrid columns={2} collapseAt="workspace" className="calendar-settings-panel">
|
||||||
<div className="calendar-settings-documentation">
|
<Card title="Calendar display" titleHelp={<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />}>
|
||||||
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
|
|
||||||
</div>
|
|
||||||
<Card title="Calendar display">
|
|
||||||
<FormGrid columns={1} collapseAt="standard" className="">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Dim weekends"
|
label="Dim weekends"
|
||||||
|
|||||||
+3
-2
@@ -6,6 +6,7 @@ import type {
|
|||||||
QuickAccessToolsUiCapability,
|
QuickAccessToolsUiCapability,
|
||||||
SettingsSectionsUiCapability
|
SettingsSectionsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
|
import { generatedTranslations as productSurfaceTranslations } from "@govoplan/core-webui/outcome-product-surface-translations";
|
||||||
import "./styles/calendar.css";
|
import "./styles/calendar.css";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import CalendarPicker from "./features/calendar/CalendarPicker";
|
import CalendarPicker from "./features/calendar/CalendarPicker";
|
||||||
@@ -19,8 +20,8 @@ const CalendarSettingsPanel = lazy(
|
|||||||
|
|
||||||
const eventRead = ["calendar:event:read"];
|
const eventRead = ["calendar:event:read"];
|
||||||
const translations = {
|
const translations = {
|
||||||
en: generatedTranslations.en,
|
en: { ...generatedTranslations.en, ...productSurfaceTranslations.en },
|
||||||
de: generatedTranslations.de
|
de: { ...generatedTranslations.de, ...productSurfaceTranslations.de }
|
||||||
};
|
};
|
||||||
|
|
||||||
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
const calendarPicker: CalendarPickerUiCapability = { CalendarPicker };
|
||||||
|
|||||||
Reference in New Issue
Block a user