mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
fix(calendar): cap RRULE expansion (#2902)
This commit is contained in:
@@ -460,6 +460,9 @@ def _event_to_dict(ev: CalendarEvent) -> dict:
|
|||||||
|
|
||||||
# ── Recurrence expansion ──
|
# ── Recurrence expansion ──
|
||||||
|
|
||||||
|
_RRULE_EXPANSION_LIMIT = 1000
|
||||||
|
|
||||||
|
|
||||||
def _expand_rrule(
|
def _expand_rrule(
|
||||||
ev: CalendarEvent, start: datetime, end: datetime
|
ev: CalendarEvent, start: datetime, end: datetime
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
@@ -482,6 +485,7 @@ def _expand_rrule(
|
|||||||
d = _event_to_dict(ev)
|
d = _event_to_dict(ev)
|
||||||
d["is_recurrence"] = False
|
d["is_recurrence"] = False
|
||||||
d["series_uid"] = ev.uid
|
d["series_uid"] = ev.uid
|
||||||
|
d["truncated"] = False
|
||||||
return [d]
|
return [d]
|
||||||
|
|
||||||
# Parse the rrule, applying it to the base dtstart.
|
# Parse the rrule, applying it to the base dtstart.
|
||||||
@@ -507,6 +511,7 @@ def _expand_rrule(
|
|||||||
d = _event_to_dict(ev)
|
d = _event_to_dict(ev)
|
||||||
d["is_recurrence"] = False
|
d["is_recurrence"] = False
|
||||||
d["series_uid"] = ev.uid
|
d["series_uid"] = ev.uid
|
||||||
|
d["truncated"] = False
|
||||||
# Malformed RRULE rows are fetched by the recurring SQL branch
|
# Malformed RRULE rows are fetched by the recurring SQL branch
|
||||||
# with only dtstart < end_dt — the base event may not actually
|
# with only dtstart < end_dt — the base event may not actually
|
||||||
# overlap the window. Only return if it does.
|
# overlap the window. Only return if it does.
|
||||||
@@ -519,22 +524,26 @@ def _expand_rrule(
|
|||||||
# (matching non-recurring overlap semantics: dtstart < end AND
|
# (matching non-recurring overlap semantics: dtstart < end AND
|
||||||
# dtend > start).
|
# dtend > start).
|
||||||
expand_start = start - duration
|
expand_start = start - duration
|
||||||
occurrences = rule.between(expand_start, end, inc=True)
|
|
||||||
if not occurrences:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
truncated = False
|
||||||
base = _event_to_dict(ev)
|
base = _event_to_dict(ev)
|
||||||
|
|
||||||
for occ_start in occurrences:
|
for occ_start in rule.xafter(expand_start, inc=True):
|
||||||
|
if occ_start >= end:
|
||||||
|
break
|
||||||
|
|
||||||
occ_end = occ_start + duration
|
occ_end = occ_start + duration
|
||||||
|
|
||||||
# Overlap filter: occurrence must intersect [start, end).
|
# Overlap filter: occurrence must intersect [start, end).
|
||||||
# This enforces exclusive-end semantics (occ_start >= end is
|
# This enforces exclusive-end semantics (occ_start >= end is
|
||||||
# excluded) and includes multi-day crossings (occ_end > start).
|
# excluded) and includes multi-day crossings (occ_end > start).
|
||||||
if occ_start >= end or occ_end <= start:
|
if occ_end <= start:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if len(results) >= _RRULE_EXPANSION_LIMIT:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
|
||||||
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
# Build the compound uid: {base_uid}::{date} or ::{datetime}
|
||||||
if ev.all_day:
|
if ev.all_day:
|
||||||
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
occ_uid = f"{ev.uid}::{occ_start.strftime('%Y-%m-%d')}"
|
||||||
@@ -545,6 +554,7 @@ def _expand_rrule(
|
|||||||
d["uid"] = occ_uid
|
d["uid"] = occ_uid
|
||||||
d["series_uid"] = ev.uid
|
d["series_uid"] = ev.uid
|
||||||
d["is_recurrence"] = True
|
d["is_recurrence"] = True
|
||||||
|
d["truncated"] = False
|
||||||
|
|
||||||
if ev.all_day:
|
if ev.all_day:
|
||||||
d["dtstart"] = occ_start.strftime("%Y-%m-%d")
|
d["dtstart"] = occ_start.strftime("%Y-%m-%d")
|
||||||
@@ -557,6 +567,10 @@ def _expand_rrule(
|
|||||||
|
|
||||||
results.append(d)
|
results.append(d)
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
for d in results:
|
||||||
|
d["truncated"] = True
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -786,8 +800,12 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
expanded.extend(_expand_rrule(e, start_dt, end_dt))
|
expanded.extend(_expand_rrule(e, start_dt, end_dt))
|
||||||
|
|
||||||
# Sort by occurrence start time for consistent frontend ordering.
|
# Sort by occurrence start time for consistent frontend ordering.
|
||||||
|
truncated = any(e.get("truncated") for e in expanded)
|
||||||
expanded.sort(key=lambda d: d["dtstart"])
|
expanded.sort(key=lambda d: d["dtstart"])
|
||||||
return {"events": expanded}
|
response: dict = {"events": expanded}
|
||||||
|
if truncated:
|
||||||
|
response["truncated"] = True
|
||||||
|
return response
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -319,3 +319,20 @@ def test_expand_metadata_inheritance():
|
|||||||
assert r["importance"] == "critical"
|
assert r["importance"] == "critical"
|
||||||
assert r["event_type"] == "work"
|
assert r["event_type"] == "work"
|
||||||
assert r["location"] == "Room 42"
|
assert r["location"] == "Room 42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_expand_daily_rrule_large_window_is_capped_and_marked_truncated():
|
||||||
|
"""Wide recurring windows must not materialize unbounded occurrence lists."""
|
||||||
|
cal = _import_calendar_helpers()
|
||||||
|
ev = _make_event(
|
||||||
|
uid="evt-daily-cap",
|
||||||
|
dtstart=datetime(2020, 1, 1, 9, 0),
|
||||||
|
dtend=datetime(2020, 1, 1, 10, 0),
|
||||||
|
rrule="FREQ=DAILY",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = cal._expand_rrule(ev, datetime(2020, 1, 1), datetime(2030, 1, 1))
|
||||||
|
|
||||||
|
assert len(results) == cal._RRULE_EXPANSION_LIMIT
|
||||||
|
assert results[-1]["uid"] == "evt-daily-cap::2022-09-26T09:00"
|
||||||
|
assert all(r["truncated"] is True for r in results)
|
||||||
|
|||||||
Reference in New Issue
Block a user