1 Commits
Author SHA1 Message Date
zemion 8e36f8b3d2 fix(calendar): bound recurrence work without hiding busy intervals
Module Package Release / publish-packages (push) Successful in 14s
Release v0.1.24. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:36 +02:00
10 changed files with 542 additions and 80 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.23",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-calendar"
version = "0.1.23"
version = "0.1.24"
description = "GovOPlaN calendar module with VEVENT storage and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.44",
"govoplan-core>=0.1.46",
"govoplan-access>=0.1.18",
"defusedxml>=0.7,<1",
"icalendar>=7.2",
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.23"
__version__ = "0.1.24"
+86 -5
View File
@@ -215,7 +215,82 @@ def add_event_raw_records(component: Event, event: Any) -> None:
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.
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 []):
rules.exdate(exdate)
starts = rules.between(range_start - duration, range_end, inc=True)
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)
if occurrence_start > range_end:
break
if occurrence_overlaps(occurrence_start, duration, range_start, range_end):
occurrences.append(occurrence_payload(occurrence_start, duration, event))
if len(occurrences) >= limit:
break
raise ICalendarError("Recurrence result limit exceeded.")
occurrences.append(occurrence_payload(occurrence_start, duration, event))
return occurrences
+49 -1
View File
@@ -620,7 +620,7 @@ def _open_xchange_provider_states(context):
manifest = ModuleManifest(
id="calendar",
name="Calendar",
version="0.1.23",
version="0.1.24",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -691,6 +691,54 @@ manifest = ModuleManifest(
),
),
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(
id="calendar.privacy.data-subject-requests",
title="Review Calendar data in a data-subject request",
+44
View File
@@ -0,0 +1,44 @@
"""Data-only recurrence work; resource boundary, not an arbitrary-code sandbox."""
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from govoplan_core.security.bounded_process import ProcessLimits
from govoplan_core.security.worker_payload import decode_worker_payload, encode_worker_payload
RECURRENCE_LIMITS = ProcessLimits(
wall_seconds=5, cpu_seconds=3, memory_bytes=256 * 1024 * 1024,
input_bytes=4 * 1024 * 1024, output_bytes=4 * 1024 * 1024,
)
def expand_recurrences_worker(payload: bytes) -> bytes:
from govoplan_calendar.backend.ical import _expand_event_occurrences
value = decode_worker_payload(payload, max_bytes=RECURRENCE_LIMITS.input_bytes)
try:
if not isinstance(value, dict) or set(value) != {"events", "start", "end", "limit"}:
raise ValueError("Invalid request")
if not isinstance(value["events"], list) or len(value["events"]) > 2_000:
raise ValueError("Invalid event count")
if type(value["limit"]) is not int or not 1 <= value["limit"] <= 10_000:
raise ValueError("Invalid result limit")
if not isinstance(value["start"], datetime) or not isinstance(value["end"], datetime):
raise ValueError("Invalid range")
remaining = value["limit"]
batches = []
for event in value["events"]:
if not isinstance(event, dict) or set(event) != {
"uid", "start_at", "end_at", "duration_seconds", "all_day", "timezone", "rrule", "rdate", "exdate",
}:
raise ValueError("Invalid event")
batch = _expand_event_occurrences(
SimpleNamespace(**event), value["start"], value["end"], limit=remaining,
)
remaining -= len(batch)
batches.append(batch)
result = {"occurrences": batches}
except (ValueError, TypeError, OverflowError, KeyError, AttributeError):
result = {"error": "invalid_or_excessive_recurrence"}
return encode_worker_payload(result, max_bytes=RECURRENCE_LIMITS.output_bytes)
+168 -65
View File
@@ -11,9 +11,10 @@ import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
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 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.ical import (
ICalendarError,
expand_event_occurrences,
expand_events_occurrences,
normalized_recurrence_id,
parse_vevent,
parse_vevents,
@@ -102,6 +105,19 @@ CALENDAR_EVENT_RESOURCE = "calendar_event"
SOURCE_EVENT_CLEANUP_BATCH_SIZE = 500
REMOTE_SYNC_MAX_ITEMS = 10_000
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] = {
"dim_weekends": True,
"dim_off_hours": True,
@@ -3850,6 +3866,60 @@ def list_events(
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(
session: Session,
*,
@@ -3859,6 +3929,25 @@ def list_event_occurrences(
calendar_id: str | None = None,
visible_calendar_ids: Iterable[str] | 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]]:
"""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))
if not normalized_calendar_ids:
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(
CalendarEvent.calendar_id.in_(normalized_calendar_ids)
)
if 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(
CalendarEvent.recurrence_id.is_(None),
or_(
@@ -3894,9 +3987,11 @@ def list_event_occurrences(
),
)
.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(
or_(
CalendarEvent.end_at.is_(None),
@@ -3905,8 +4000,10 @@ def list_event_occurrences(
CalendarEvent.start_at <= range_end,
)
.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 = {
(event.calendar_id, event.uid) for event in recurring_masters
@@ -3914,12 +4011,16 @@ def list_event_occurrences(
overrides: list[CalendarEvent] = []
if series_keys:
series_uids = {uid for _calendar_id, uid in series_keys}
overrides = [
event
for event in base_query.filter(
override_candidates = _bounded_occurrence_candidates(
base_query.filter(
CalendarEvent.recurrence_id.is_not(None),
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
]
@@ -3934,19 +4035,31 @@ def list_event_occurrences(
)[recurrence_key] = override
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()
recurring_master_ids = {event.id for event in recurring_masters}
recurring_master_by_series = {
(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_overrides = overrides_by_series.get(series_key, {})
for occurrence in expand_event_occurrences(
master,
range_start,
range_end,
):
for occurrence in expanded:
occurrence_recurrence_id = str(
occurrence.get("recurrence_id") or ""
)
@@ -3967,25 +4080,18 @@ def list_event_occurrences(
range_start=range_start,
range_end=range_end,
):
results.append(
expanded_event_response(
override,
series_event_id=master.id,
recurrence_id=(
override.recurrence_id
or occurrence_recurrence_id
),
is_override=True,
)
append_occurrence(
override,
series_event_id=master.id,
recurrence_id=(override.recurrence_id or occurrence_recurrence_id),
is_override=True,
)
continue
results.append(
expanded_event_response(
master,
series_event_id=master.id,
recurrence_id=occurrence_recurrence_id,
occurrence=occurrence,
)
append_occurrence(
master,
series_event_id=master.id,
recurrence_id=occurrence_recurrence_id,
occurrence=occurrence,
)
for event in direct_events:
@@ -4002,20 +4108,18 @@ def list_event_occurrences(
is_override = True
if event.status.upper() == "CANCELLED":
continue
results.append(
expanded_event_response(
event,
series_event_id=series_event_id,
recurrence_id=normalized_recurrence_id(event.recurrence_id),
is_override=is_override,
)
append_occurrence(
event,
series_event_id=series_event_id,
recurrence_id=normalized_recurrence_id(event.recurrence_id),
is_override=is_override,
)
sorted_results = sorted(
results,
key=lambda item: (
item["start_at"],
item["calendar_id"],
item["summary"],
item.get("summary", ""),
item["instance_id"],
),
)
@@ -4044,8 +4148,16 @@ def expanded_event_response(
recurrence_id: str | None,
occurrence: dict[str, Any] | None = None,
is_override: bool = False,
availability_only: bool = False,
) -> 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:
payload["start_at"] = response_datetime(occurrence["start_at"])
payload["end_at"] = response_datetime(occurrence.get("end_at"))
@@ -4074,25 +4186,13 @@ def list_freebusy(
range_end = normalize_datetime(end_at)
if range_end < range_start:
raise CalendarError("Free/busy end must be after start")
events: list[dict[str, Any]] = []
if calendar_ids:
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(
session,
tenant_id=tenant_id,
start_at=range_start,
end_at=range_end,
)
# One admission and one worker budget for the entire requested collection,
# not one independently reset expansion allowance per calendar.
events = list_event_occurrences(
session, tenant_id=tenant_id, start_at=range_start, end_at=range_end,
visible_calendar_ids=calendar_ids or None,
_availability_only=True,
)
busy = [
{
"calendar_id": event["calendar_id"],
@@ -4600,11 +4700,14 @@ def recurrence_occurrence(
if recurrence_start is None:
raise CalendarError("Invalid recurrence_id")
recurrence_key = normalized_recurrence_id(recurrence_id)
candidates = expand_event_occurrences(
master,
recurrence_start - timedelta(seconds=1),
recurrence_start + timedelta(seconds=1),
)
try:
candidates = expand_event_occurrences(
master,
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(
(
item
+30 -1
View File
@@ -3,11 +3,40 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
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):
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:
payload = """BEGIN:VCALENDAR
VERSION:2.0
+160 -3
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
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.orm import sessionmaker
from sqlalchemy import create_engine, create_mock_engine, event as sqlalchemy_event
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Query, Session, sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_calendar.backend.db.models import CalendarEvent
@@ -16,6 +18,8 @@ from govoplan_calendar.backend.schemas import (
)
from govoplan_calendar.backend.service import (
CalendarError,
_bounded_occurrence_candidates,
OCCURRENCE_DETAIL_FIELDS,
create_calendar,
create_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(
self,
) -> None:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/calendar-webui",
"version": "0.1.23",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "src/index.ts",