From a93c5080380447e3ab9113cacb73b61c57cbc308 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sat, 29 Aug 2026 16:14:32 -0400 Subject: [PATCH] refac --- backend/open_webui/constants.py | 2 +- backend/open_webui/models/calendar.py | 33 ++++++++++++++++++++++++++- backend/open_webui/utils/calendar.py | 27 +++++++++++----------- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/backend/open_webui/constants.py b/backend/open_webui/constants.py index 3ce8d6cf26..c6c78de35a 100644 --- a/backend/open_webui/constants.py +++ b/backend/open_webui/constants.py @@ -3,7 +3,6 @@ from __future__ import annotations import errno from enum import Enum - _ERRNO_MESSAGES = { errno.ENAMETOOLONG: 'File name is too long.', errno.ENOSPC: 'The server is out of storage space.', @@ -121,6 +120,7 @@ class ERROR_MESSAGES(str, Enum): AUTOMATION_COUNT_REQUIRES_DTSTART = ( 'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window' ) + CALENDAR_RRULE_TOO_FREQUENT = 'Recurring events cannot repeat more often than daily' FEATURE_DISABLED = lambda name='': f'{name} is disabled' INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}' diff --git a/backend/open_webui/models/calendar.py b/backend/open_webui/models/calendar.py index b61bf036fa..835313c703 100644 --- a/backend/open_webui/models/calendar.py +++ b/backend/open_webui/models/calendar.py @@ -4,10 +4,12 @@ from typing import Optional from uuid import uuid4 from open_webui.internal.db import Base, get_async_db_context +from open_webui.constants import ERROR_MESSAGES from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups from open_webui.models.users import User, UserModel, UserResponse -from pydantic import BaseModel, ConfigDict, Field +from open_webui.utils.automations import rrule_interval_seconds +from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy import ( JSON, BigInteger, @@ -26,6 +28,7 @@ from sqlalchemy import ( from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) +MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60 #################### @@ -191,6 +194,20 @@ class CalendarEventForm(BaseModel): meta: Optional[dict] = None attendees: Optional[list[dict]] = None + @field_validator('rrule') + @classmethod + def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]: + if value: + try: + interval = rrule_interval_seconds(value) + except ValueError: + raise + except Exception as e: + raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) + if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS: + raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT) + return value + class CalendarEventUpdateForm(BaseModel): calendar_id: Optional[str] = None @@ -207,6 +224,20 @@ class CalendarEventUpdateForm(BaseModel): is_cancelled: Optional[bool] = None attendees: Optional[list[dict]] = None + @field_validator('rrule') + @classmethod + def reject_sub_daily_rrule(cls, value: Optional[str]) -> Optional[str]: + if value: + try: + interval = rrule_interval_seconds(value) + except ValueError: + raise + except Exception as e: + raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) + if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS: + raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT) + return value + class RSVPForm(BaseModel): status: str # 'accepted' | 'declined' | 'tentative' | 'pending' diff --git a/backend/open_webui/utils/calendar.py b/backend/open_webui/utils/calendar.py index 16d0f2b659..1ce590203a 100644 --- a/backend/open_webui/utils/calendar.py +++ b/backend/open_webui/utils/calendar.py @@ -8,6 +8,7 @@ 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__) @@ -25,18 +26,17 @@ def expand_recurring_event( Takes an event dict (from CalendarEventModel.model_dump()) and produces one dict per occurrence, with adjusted start_at / end_at. """ - from dateutil.rrule import rrulestr - 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: - if user_timezone: - return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000, tz=user_timezone).replace(tzinfo=None) - return dt.datetime.fromtimestamp(timestamp_ns / 1_000_000_000) + 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) @@ -44,10 +44,11 @@ def expand_recurring_event( original_start_ns = event_dict['start_at'] original_start = to_local_datetime(original_start_ns) + rule_str = '\n'.join(line for line in rrule_str.splitlines() if not line.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(rrule_str, dtstart=original_start, ignoretz=True) + 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] @@ -56,13 +57,13 @@ def expand_recurring_event( duration_ns = (original_end_ns - original_start_ns) if original_end_ns else None instances = [] - occurrence_start = rule.after(scan_start, inc=True) + previous_start = None + for occurrence_start in rule.xafter(scan_start, count=max_instances, inc=True): + if occurrence_start >= range_end or occurrence_start == previous_start: + break + previous_start = occurrence_start - while occurrence_start and occurrence_start < range_end and len(instances) < max_instances: - if user_timezone: - instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000) - else: - instance_start_ns = int(occurrence_start.timestamp() * 1_000_000_000) + instance_start_ns = int(occurrence_start.replace(tzinfo=user_timezone).timestamp() * 1_000_000_000) if instance_start_ns >= range_start_ns: instance = { @@ -73,8 +74,6 @@ def expand_recurring_event( } instances.append(instance) - occurrence_start = rule.after(occurrence_start) - return instances