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.
This commit is contained in:
2026-09-08 12:19:36 +02:00
parent 3e5fc05ca3
commit 8e36f8b3d2
10 changed files with 542 additions and 80 deletions
+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