Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e36f8b3d2 | ||
|
|
3e5fc05ca3 |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.22",
|
"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.22"
|
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.22"
|
__version__ = "0.1.24"
|
||||||
|
|||||||
@@ -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_start > range_end:
|
||||||
|
break
|
||||||
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
|
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
|
||||||
occurrences.append(occurrence_payload(occurrence_start, duration, event))
|
|
||||||
if len(occurrences) >= limit:
|
if len(occurrences) >= limit:
|
||||||
break
|
raise ICalendarError("Recurrence result limit exceeded.")
|
||||||
|
occurrences.append(occurrence_payload(occurrence_start, duration, event))
|
||||||
return occurrences
|
return occurrences
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
@@ -618,7 +620,7 @@ def _open_xchange_provider_states(context):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="calendar",
|
id="calendar",
|
||||||
name="Calendar",
|
name="Calendar",
|
||||||
version="0.1.22",
|
version="0.1.24",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -689,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",
|
||||||
@@ -779,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 "
|
||||||
@@ -795,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 "
|
||||||
@@ -1169,6 +1221,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",
|
||||||
|
|||||||
+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,25 +4080,18 @@ 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=(override.recurrence_id or occurrence_recurrence_id),
|
||||||
recurrence_id=(
|
is_override=True,
|
||||||
override.recurrence_id
|
|
||||||
or occurrence_recurrence_id
|
|
||||||
),
|
|
||||||
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:
|
||||||
@@ -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,25 +4186,13 @@ 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 = list_event_occurrences(
|
||||||
events.extend(
|
session, tenant_id=tenant_id, start_at=range_start, end_at=range_end,
|
||||||
list_event_occurrences(
|
visible_calendar_ids=calendar_ids or None,
|
||||||
session,
|
_availability_only=True,
|
||||||
tenant_id=tenant_id,
|
)
|
||||||
calendar_id=calendar_id,
|
|
||||||
start_at=range_start,
|
|
||||||
end_at=range_end,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
events = list_event_occurrences(
|
|
||||||
session,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
start_at=range_start,
|
|
||||||
end_at=range_end,
|
|
||||||
)
|
|
||||||
busy = [
|
busy = [
|
||||||
{
|
{
|
||||||
"calendar_id": event["calendar_id"],
|
"calendar_id": event["calendar_id"],
|
||||||
@@ -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)
|
||||||
candidates = expand_event_occurrences(
|
try:
|
||||||
master,
|
candidates = expand_event_occurrences(
|
||||||
recurrence_start - timedelta(seconds=1),
|
master,
|
||||||
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
|
||||||
|
|||||||
@@ -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.22",
|
"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",
|
||||||
|
|||||||
+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