Coverage for open_webui/utils/calendar.py: 14%
45 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1"""
2Calendar utilities.
4RRULE expansion reusing the automation infra.
5"""
7import datetime as dt
8import logging
9from zoneinfo import ZoneInfo
11from dateutil.rrule import rrulestr
12from open_webui.utils.automations import _resolve_tz
14log = logging.getLogger(__name__)
17def expand_recurring_event(
18 event_dict: dict,
19 range_start_ns: int,
20 range_end_ns: int,
21 tz: str | None = None,
22 max_instances: int = 5000,
23) -> list[dict]:
24 """Expand a recurring event into individual instances within a date range.
26 Takes an event dict (from CalendarEventModel.model_dump()) and produces
27 one dict per occurrence, with adjusted start_at / end_at.
28 """
29 rrule_str = event_dict.get('rrule')
30 if not rrule_str:
31 return [event_dict]
32 if 'EXRULE' in rrule_str.upper():
33 log.warning(f'EXRULE is not supported for event {event_dict.get("id")}: {rrule_str}')
34 return [event_dict]
36 user_timezone = _resolve_tz(tz)
38 def to_local_datetime(timestamp_ns: int) -> dt.datetime:
39 return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
41 range_start = to_local_datetime(range_start_ns)
42 range_end = to_local_datetime(range_end_ns)
43 scan_start = range_start - dt.timedelta(days=1)
45 original_start_ns = event_dict['start_at']
46 original_start = to_local_datetime(original_start_ns)
47 rule_str = '\n'.join(part for part in rrule_str.split() if not part.upper().startswith('DTSTART')) or rrule_str
49 try:
50 # Anchor to the event's real start so day-of-week / day-of-month are correct
51 rule = rrulestr(rule_str, dtstart=original_start, ignoretz=True)
52 except Exception:
53 log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
54 return [event_dict]
56 original_end_ns = event_dict.get('end_at')
57 duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
59 instances = []
60 previous_start = None
61 for occurrence_start in rule.xafter(scan_start, count=max_instances, inc=True):
62 if occurrence_start >= range_end or occurrence_start == previous_start:
63 break
64 previous_start = occurrence_start
66 instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
68 if instance_start_ns >= range_start_ns:
69 instance = {
70 **event_dict,
71 'start_at': instance_start_ns,
72 'end_at': (instance_start_ns + duration_ns) if duration_ns else None,
73 'instance_id': f'{event_dict["id"]}_{instance_start_ns}',
74 }
75 instances.append(instance)
77 return instances
80def ns_from_date(year: int, month: int, day: int, tz: str | None = None) -> int:
81 """Create epoch nanoseconds from a date."""
82 if tz:
83 date_time = dt.datetime(year, month, day, tzinfo=ZoneInfo(tz))
84 else:
85 date_time = dt.datetime(year, month, day)
86 return int(date_time.timestamp() * 1_000_000_000)