open-webui/backend/open_webui/utils/calendar.py
Classic298 909a2075d3
fix: Scheduled Tasks calendar shows extra runs for automations with a run count (#31604)
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
2026-09-30 19:43:41 +04:00

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)