mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-11 03:38:02 +00:00
An automation whose schedule ends after a fixed number of runs (COUNT in its RRULE) showed extra future runs in the Scheduled Tasks calendar after each run. With COUNT=3, the calendar kept showing three upcoming runs after the first and second run, although only the remaining ones execute. The calendar now counts runs from the schedule's own start date, the same way automations are actually run, so it shows exactly the runs that are still going to happen. The 5000-entry display limit now only counts entries inside the visible range, so very frequent schedules that started shortly before it no longer show up short or empty. Fixes #31600
91 lines
3.3 KiB
Python
91 lines
3.3 KiB
Python
"""
|
|
Calendar utilities.
|
|
|
|
RRULE expansion reusing the automation infra.
|
|
"""
|
|
|
|
import datetime as dt
|
|
import logging
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from dateutil.rrule import rrulestr
|
|
from open_webui.utils.automations import _resolve_tz
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def expand_recurring_event(
|
|
event_dict: dict,
|
|
range_start_ns: int,
|
|
range_end_ns: int,
|
|
tz: str | None = None,
|
|
max_instances: int = 5000,
|
|
) -> list[dict]:
|
|
"""Expand a recurring event into individual instances within a date range.
|
|
|
|
Takes an event dict (from CalendarEventModel.model_dump()) and produces
|
|
one dict per occurrence, with adjusted start_at / end_at.
|
|
"""
|
|
rrule_str = event_dict.get('rrule')
|
|
if not rrule_str:
|
|
return [event_dict]
|
|
if 'EXRULE' in rrule_str.upper():
|
|
log.warning(f'EXRULE is not supported for event {event_dict.get("id")}: {rrule_str}')
|
|
return [event_dict]
|
|
|
|
user_timezone = _resolve_tz(tz)
|
|
|
|
def to_local_datetime(timestamp_ns: int) -> dt.datetime:
|
|
return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None)
|
|
|
|
range_start = to_local_datetime(range_start_ns)
|
|
range_end = to_local_datetime(range_end_ns)
|
|
|
|
original_start_ns = event_dict['start_at']
|
|
original_start = to_local_datetime(original_start_ns)
|
|
rule_str = '\n'.join(part for part in rrule_str.split() if not part.upper().startswith('DTSTART')) or rrule_str
|
|
|
|
try:
|
|
# Anchor to the event's real start so day-of-week / day-of-month are correct
|
|
rule = rrulestr(rule_str, dtstart=original_start, ignoretz=True)
|
|
except Exception:
|
|
log.warning(f'Failed to parse RRULE for event {event_dict.get("id")}: {rrule_str}')
|
|
return [event_dict]
|
|
|
|
original_end_ns = event_dict.get('end_at')
|
|
duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None
|
|
# Look back by the event length so occurrences still running at range start are found
|
|
event_length = dt.timedelta(microseconds=max(duration_ns or 0, 0) // 1000)
|
|
scan_start = range_start - dt.timedelta(days=1) - event_length
|
|
|
|
instances = []
|
|
previous_start = None
|
|
for occurrence_start in rule.xafter(scan_start, inc=True):
|
|
if occurrence_start >= range_end or occurrence_start == previous_start:
|
|
break
|
|
previous_start = occurrence_start
|
|
|
|
instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000)
|
|
instance_end_ns = (instance_start_ns + duration_ns) if duration_ns else None
|
|
|
|
if instance_start_ns >= range_start_ns or (instance_end_ns and instance_end_ns > range_start_ns):
|
|
instance = {
|
|
**event_dict,
|
|
'start_at': instance_start_ns,
|
|
'end_at': instance_end_ns,
|
|
'instance_id': f'{event_dict["id"]}_{instance_start_ns}',
|
|
}
|
|
instances.append(instance)
|
|
if len(instances) >= max_instances:
|
|
break
|
|
|
|
return instances
|
|
|
|
|
|
def ns_from_date(year: int, month: int, day: int, tz: str | None = None) -> int:
|
|
"""Create epoch nanoseconds from a date."""
|
|
if tz:
|
|
date_time = dt.datetime(year, month, day, tzinfo=ZoneInfo(tz))
|
|
else:
|
|
date_time = dt.datetime(year, month, day)
|
|
return int(date_time.timestamp() * 1_000_000_000)
|