This commit is contained in:
Timothy Jaeryang Baek
2026-08-29 16:14:32 -04:00
parent 348751b6a5
commit a93c508038
3 changed files with 46 additions and 16 deletions
+1 -1
View File
@@ -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}'
+32 -1
View File
@@ -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'
+13 -14
View File
@@ -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