Coverage for open_webui/models/calendar.py: 46%
432 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
1import logging
2import time
3from typing import Optional
4from uuid import uuid4
6from open_webui.internal.db import Base, get_async_db_context
7from open_webui.constants import ERROR_MESSAGES
8from open_webui.models.access_grants import AccessGrantModel, AccessGrants
9from open_webui.models.groups import Groups
10from open_webui.models.users import User, UserModel, UserResponse
11from pydantic import BaseModel, ConfigDict, Field
12from sqlalchemy import (
13 JSON,
14 BigInteger,
15 Boolean,
16 Column,
17 Index,
18 Text,
19 UniqueConstraint,
20 delete,
21 exists,
22 func,
23 or_,
24 select,
25 update,
26)
27from sqlalchemy.ext.asyncio import AsyncSession
29log = logging.getLogger(__name__)
30MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60
33####################
34# Calendar DB Schema
35####################
38class Calendar(Base):
39 __tablename__ = 'calendar'
41 id = Column(Text, primary_key=True)
42 user_id = Column(Text, nullable=False)
43 name = Column(Text, nullable=False)
44 color = Column(Text, nullable=True)
45 is_default = Column(Boolean, nullable=False, default=False)
46 data = Column(JSON, nullable=True)
47 meta = Column(JSON, nullable=True)
49 created_at = Column(BigInteger, nullable=False)
50 updated_at = Column(BigInteger, nullable=False)
52 __table_args__ = (Index('ix_calendar_user', 'user_id'),)
55class CalendarEvent(Base):
56 __tablename__ = 'calendar_event'
58 id = Column(Text, primary_key=True)
59 calendar_id = Column(Text, nullable=False)
60 user_id = Column(Text, nullable=False)
61 title = Column(Text, nullable=False)
62 description = Column(Text, nullable=True)
63 start_at = Column(BigInteger, nullable=False)
64 end_at = Column(BigInteger, nullable=True)
65 all_day = Column(Boolean, nullable=False, default=False)
66 rrule = Column(Text, nullable=True)
67 color = Column(Text, nullable=True)
68 location = Column(Text, nullable=True)
69 data = Column(JSON, nullable=True)
70 meta = Column(JSON, nullable=True)
71 is_cancelled = Column(Boolean, nullable=False, default=False)
73 created_at = Column(BigInteger, nullable=False)
74 updated_at = Column(BigInteger, nullable=False)
76 __table_args__ = (
77 Index('ix_calendar_event_calendar', 'calendar_id', 'start_at'),
78 Index('ix_calendar_event_user_date', 'user_id', 'start_at'),
79 )
82class CalendarEventAttendee(Base):
83 __tablename__ = 'calendar_event_attendee'
85 id = Column(Text, primary_key=True)
86 event_id = Column(Text, nullable=False)
87 user_id = Column(Text, nullable=False)
88 status = Column(Text, nullable=False, default='pending')
89 meta = Column(JSON, nullable=True)
91 created_at = Column(BigInteger, nullable=False)
92 updated_at = Column(BigInteger, nullable=False)
94 __table_args__ = (
95 UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'),
96 Index('ix_calendar_event_attendee_user', 'user_id', 'status'),
97 )
100####################
101# Pydantic Models
102####################
105class CalendarModel(BaseModel):
106 model_config = ConfigDict(from_attributes=True)
108 id: str
109 user_id: str
110 name: str
111 color: Optional[str] = None
112 is_default: bool = False
113 is_system: bool = False
115 data: Optional[dict] = None
116 meta: Optional[dict] = None
118 access_grants: list[AccessGrantModel] = Field(default_factory=list)
120 created_at: int
121 updated_at: int
124class CalendarEventModel(BaseModel):
125 model_config = ConfigDict(from_attributes=True, extra='allow')
127 id: str
128 calendar_id: str
129 user_id: str
130 title: str
131 description: Optional[str] = None
132 start_at: int
133 end_at: Optional[int] = None
134 all_day: bool = False
135 rrule: Optional[str] = None
136 color: Optional[str] = None
137 location: Optional[str] = None
138 data: Optional[dict] = None
139 meta: Optional[dict] = None
140 is_cancelled: bool = False
142 attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list)
144 created_at: int
145 updated_at: int
148class CalendarEventAttendeeModel(BaseModel):
149 model_config = ConfigDict(from_attributes=True)
151 id: str
152 event_id: str
153 user_id: str
154 status: str = 'pending'
155 meta: Optional[dict] = None
157 created_at: int
158 updated_at: int
161####################
162# Forms
163####################
166class CalendarForm(BaseModel):
167 name: str
168 color: Optional[str] = None
169 data: Optional[dict] = None
170 meta: Optional[dict] = None
171 access_grants: Optional[list[dict]] = None
174class CalendarUpdateForm(BaseModel):
175 name: Optional[str] = None
176 color: Optional[str] = None
177 data: Optional[dict] = None
178 meta: Optional[dict] = None
179 access_grants: Optional[list[dict]] = None
182async def validate_calendar_rrule(value: Optional[str]) -> None:
183 if value:
184 from open_webui.utils.recurrence import rrule_interval_seconds
186 try:
187 interval = await rrule_interval_seconds(value)
188 except ValueError:
189 raise
190 except Exception as e:
191 raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e)) from e
192 if interval is not None and interval < MIN_CALENDAR_RRULE_INTERVAL_SECONDS:
193 raise ValueError(ERROR_MESSAGES.CALENDAR_RRULE_TOO_FREQUENT)
196class CalendarEventForm(BaseModel):
197 calendar_id: str
198 title: str
199 description: Optional[str] = None
200 start_at: int
201 end_at: Optional[int] = None
202 all_day: bool = False
203 rrule: Optional[str] = None
204 color: Optional[str] = None
205 location: Optional[str] = None
206 data: Optional[dict] = None
207 meta: Optional[dict] = None
208 attendees: Optional[list[dict]] = None
211class CalendarEventUpdateForm(BaseModel):
212 calendar_id: Optional[str] = None
213 title: Optional[str] = None
214 description: Optional[str] = None
215 start_at: Optional[int] = None
216 end_at: Optional[int] = None
217 all_day: Optional[bool] = None
218 rrule: Optional[str] = None
219 color: Optional[str] = None
220 location: Optional[str] = None
221 data: Optional[dict] = None
222 meta: Optional[dict] = None
223 is_cancelled: Optional[bool] = None
224 attendees: Optional[list[dict]] = None
227class RSVPForm(BaseModel):
228 status: str # 'accepted' | 'declined' | 'tentative' | 'pending'
231####################
232# Response Models
233####################
236class CalendarEventUserResponse(CalendarEventModel):
237 user: Optional[UserResponse] = None
240class CalendarEventListResponse(BaseModel):
241 items: list[CalendarEventUserResponse]
242 total: int
245####################
246# Table Operations
247####################
250class CalendarTable:
251 async def _get_access_grants(self, calendar_id: str, db: Optional[AsyncSession] = None) -> list[AccessGrantModel]:
252 return await AccessGrants.get_grants_by_resource('calendar', calendar_id, db=db)
254 async def _to_calendar_model(
255 self,
256 cal: Calendar,
257 access_grants: Optional[list[AccessGrantModel]] = None,
258 db: Optional[AsyncSession] = None,
259 ) -> CalendarModel:
260 calendar_model = CalendarModel.model_validate(cal)
261 calendar_model.access_grants = (
262 access_grants if access_grants is not None else await self._get_access_grants(calendar_model.id, db=db)
263 )
264 return calendar_model
266 async def get_or_create_defaults(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
267 """Return user's calendars, creating 'Personal' default if none exist."""
268 async with get_async_db_context(db) as db:
269 result = await db.execute(
270 select(Calendar).filter(Calendar.user_id == user_id).order_by(Calendar.created_at.asc())
271 )
272 calendars = result.scalars().all()
274 if calendars:
275 return [CalendarModel.model_validate(c) for c in calendars]
277 now = int(time.time_ns())
278 cal = Calendar(
279 id=str(uuid4()),
280 user_id=user_id,
281 name='Personal',
282 color='#3b82f6',
283 is_default=True,
284 created_at=now,
285 updated_at=now,
286 )
287 db.add(cal)
288 await db.commit()
289 return [CalendarModel.model_validate(cal)]
291 async def get_calendars_by_user(self, user_id: str, db: Optional[AsyncSession] = None) -> list[CalendarModel]:
292 """Owned + shared calendars."""
293 async with get_async_db_context(db) as db:
294 user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
295 user_group_ids = [g.id for g in user_groups]
297 stmt = select(Calendar)
298 stmt = AccessGrants.has_permission_filter(
299 db=db,
300 query=stmt,
301 DocumentModel=Calendar,
302 filter={'user_id': user_id, 'group_ids': user_group_ids},
303 resource_type='calendar',
304 permission='read',
305 )
306 stmt = stmt.order_by(Calendar.created_at.asc())
308 result = await db.execute(stmt)
309 calendars = result.scalars().all()
311 if not calendars:
312 return await self.get_or_create_defaults(user_id, db=db)
314 cal_ids = [c.id for c in calendars]
315 grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db)
317 return [await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in calendars]
319 async def get_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarModel]:
320 async with get_async_db_context(db) as db:
321 result = await db.execute(select(Calendar).filter(Calendar.id == id))
322 cal = result.scalars().first()
323 return await self._to_calendar_model(cal, db=db) if cal else None
325 async def insert_new_calendar(
326 self, user_id: str, form_data: CalendarForm, db: Optional[AsyncSession] = None
327 ) -> Optional[CalendarModel]:
328 async with get_async_db_context(db) as db:
329 now = int(time.time_ns())
330 cal = Calendar(
331 id=str(uuid4()),
332 user_id=user_id,
333 name=form_data.name,
334 color=form_data.color,
335 is_default=False,
336 data=form_data.data,
337 meta=form_data.meta,
338 created_at=now,
339 updated_at=now,
340 )
341 db.add(cal)
342 await db.commit()
343 if form_data.access_grants is not None:
344 await AccessGrants.set_access_grants('calendar', cal.id, form_data.access_grants, db=db)
345 return await self._to_calendar_model(cal, db=db)
347 async def update_calendar_by_id(
348 self, id: str, form_data: CalendarUpdateForm, db: Optional[AsyncSession] = None
349 ) -> Optional[CalendarModel]:
350 async with get_async_db_context(db) as db:
351 result = await db.execute(select(Calendar).filter(Calendar.id == id))
352 cal = result.scalars().first()
353 if not cal:
354 return None
356 update_data = form_data.model_dump(exclude_unset=True)
357 if 'name' in update_data:
358 cal.name = update_data['name']
359 if 'color' in update_data:
360 cal.color = update_data['color']
361 if 'data' in update_data:
362 cal.data = {**(cal.data or {}), **update_data['data']}
363 if 'meta' in update_data:
364 cal.meta = {**(cal.meta or {}), **update_data['meta']}
365 if 'access_grants' in update_data:
366 await AccessGrants.set_access_grants('calendar', id, update_data['access_grants'], db=db)
368 cal.updated_at = int(time.time_ns())
369 await db.commit()
370 return await self._to_calendar_model(cal, db=db)
372 async def set_default_calendar(
373 self, user_id: str, calendar_id: str, db: Optional[AsyncSession] = None
374 ) -> Optional[CalendarModel]:
375 """Set a calendar as the user's default, clearing all others."""
376 async with get_async_db_context(db) as db:
377 # Clear all defaults for this user
378 await db.execute(
379 update(Calendar)
380 .where(Calendar.user_id == user_id, Calendar.is_default == True)
381 .values(is_default=False)
382 )
383 # Set the new default
384 result = await db.execute(select(Calendar).filter(Calendar.id == calendar_id, Calendar.user_id == user_id))
385 cal = result.scalars().first()
386 if not cal:
387 return None
388 cal.is_default = True
389 cal.updated_at = int(time.time_ns())
390 await db.commit()
391 return await self._to_calendar_model(cal, db=db)
393 async def delete_calendar_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
394 """Delete a non-default calendar. Cascades to events, attendees, and grants."""
395 try:
396 async with get_async_db_context(db) as db:
397 result = await db.execute(select(Calendar).filter(Calendar.id == id))
398 cal = result.scalars().first()
399 if not cal or cal.is_default:
400 return False
402 # Delete attendees for all events in this calendar
403 event_ids_result = await db.execute(select(CalendarEvent.id).filter(CalendarEvent.calendar_id == id))
404 event_ids = [r[0] for r in event_ids_result.all()]
405 if event_ids:
406 await db.execute(
407 delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
408 )
410 # Delete events
411 await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id))
413 # Delete calendar
414 await db.execute(delete(Calendar).filter(Calendar.id == id))
415 await db.commit()
417 # Revoke access grants in a separate transaction to avoid
418 # write-lock contention on SQLite when session sharing is off.
419 await AccessGrants.revoke_all_access('calendar', id)
420 return True
421 except Exception as e:
422 log.exception(f'Failed to delete calendar {id}: {e}')
423 return False
426class CalendarEventTable:
427 async def _get_attendees(
428 self, event_id: str, db: Optional[AsyncSession] = None
429 ) -> list[CalendarEventAttendeeModel]:
430 async with get_async_db_context(db) as db:
431 result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
432 rows = result.scalars().all()
433 return [CalendarEventAttendeeModel.model_validate(r) for r in rows]
435 async def _to_event_model(
436 self,
437 event: CalendarEvent,
438 attendees: Optional[list[CalendarEventAttendeeModel]] = None,
439 db: Optional[AsyncSession] = None,
440 ) -> CalendarEventModel:
441 event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
442 event_data['attendees'] = (
443 attendees if attendees is not None else await self._get_attendees(event_data['id'], db=db)
444 )
445 return CalendarEventModel.model_validate(event_data)
447 async def insert_new_event(
448 self, user_id: str, form_data: CalendarEventForm, db: Optional[AsyncSession] = None
449 ) -> Optional[CalendarEventModel]:
450 await validate_calendar_rrule(form_data.rrule)
451 async with get_async_db_context(db) as db:
452 now = int(time.time_ns())
453 event = CalendarEvent(
454 id=str(uuid4()),
455 calendar_id=form_data.calendar_id,
456 user_id=user_id,
457 title=form_data.title,
458 description=form_data.description,
459 start_at=form_data.start_at,
460 end_at=form_data.end_at,
461 all_day=form_data.all_day,
462 rrule=form_data.rrule,
463 color=form_data.color,
464 location=form_data.location,
465 data=form_data.data,
466 meta=form_data.meta,
467 is_cancelled=False,
468 created_at=now,
469 updated_at=now,
470 )
471 db.add(event)
472 await db.commit()
474 # Add attendees
475 if form_data.attendees:
476 await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db)
478 return await self._to_event_model(event, db=db)
480 async def get_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> Optional[CalendarEventModel]:
481 async with get_async_db_context(db) as db:
482 result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
483 event = result.scalars().first()
484 return await self._to_event_model(event, db=db) if event else None
486 async def get_events_by_range(
487 self,
488 user_id: str,
489 start: int,
490 end: int,
491 calendar_ids: Optional[list[str]] = None,
492 db: Optional[AsyncSession] = None,
493 ) -> list[CalendarEventUserResponse]:
494 """Fetch events visible to user within a date range.
496 Visible events = events in owned/shared calendars + events user attends.
497 Recurring events are fetched if they have any rrule (expansion in Python).
498 """
499 async with get_async_db_context(db) as db:
500 user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
501 user_group_ids = [g.id for g in user_groups]
503 # Get calendar IDs accessible to user
504 cal_stmt = select(Calendar.id)
505 cal_stmt = AccessGrants.has_permission_filter(
506 db=db,
507 query=cal_stmt,
508 DocumentModel=Calendar,
509 filter={'user_id': user_id, 'group_ids': user_group_ids},
510 resource_type='calendar',
511 permission='read',
512 )
513 cal_result = await db.execute(cal_stmt)
514 accessible_cal_ids = [r[0] for r in cal_result.all()]
516 if calendar_ids:
517 # Filter to requested calendars only
518 accessible_cal_ids = [c for c in accessible_cal_ids if c in calendar_ids]
520 # Also get event IDs where the user is an attendee, excluding invites they declined
521 attendee_event_ids_result = await db.execute(
522 select(CalendarEventAttendee.event_id).filter(
523 CalendarEventAttendee.user_id == user_id,
524 CalendarEventAttendee.status != 'declined',
525 )
526 )
527 attendee_event_ids = [r[0] for r in attendee_event_ids_result.all()]
529 # Build conditions for accessible events
530 conditions = []
531 if accessible_cal_ids:
532 conditions.append(CalendarEvent.calendar_id.in_(accessible_cal_ids))
533 if attendee_event_ids:
534 conditions.append(CalendarEvent.id.in_(attendee_event_ids))
536 if not conditions:
537 return []
539 # Build event query
540 stmt = (
541 select(CalendarEvent, User)
542 .outerjoin(User, User.id == CalendarEvent.user_id)
543 .filter(
544 CalendarEvent.is_cancelled == False,
545 or_(*conditions),
546 or_(
547 # Non-recurring: overlaps the range
548 (
549 CalendarEvent.rrule.is_(None)
550 & (CalendarEvent.start_at < end)
551 & or_(
552 CalendarEvent.end_at.is_(None) & (CalendarEvent.start_at >= start),
553 CalendarEvent.end_at.isnot(None)
554 & ((CalendarEvent.end_at > start) | (CalendarEvent.start_at >= start)),
555 )
556 ),
557 # Recurring: fetch all (expansion in Python)
558 CalendarEvent.rrule.isnot(None),
559 ),
560 )
561 .order_by(CalendarEvent.start_at.asc())
562 )
564 result = await db.execute(stmt)
565 items = result.all()
567 if not items:
568 return []
570 # Batch-load attendees for all events in one query (avoid N+1)
571 event_ids = [event.id for event, _user in items]
572 att_result = await db.execute(
573 select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
574 )
575 att_rows = att_result.scalars().all()
576 att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
577 for a in att_rows:
578 att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
580 events = []
581 for event, user in items:
582 event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
583 event_data['attendees'] = att_map.get(event.id, [])
584 events.append(
585 CalendarEventUserResponse(
586 **event_data,
587 user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
588 )
589 )
591 return events
593 async def search_events(
594 self,
595 user_id: str,
596 query: Optional[str] = None,
597 skip: int = 0,
598 limit: int = 30,
599 db: Optional[AsyncSession] = None,
600 ) -> CalendarEventListResponse:
601 async with get_async_db_context(db) as db:
602 user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
603 user_group_ids = [g.id for g in user_groups]
605 # Get accessible calendar IDs
606 cal_stmt = select(Calendar.id)
607 cal_stmt = AccessGrants.has_permission_filter(
608 db=db,
609 query=cal_stmt,
610 DocumentModel=Calendar,
611 filter={'user_id': user_id, 'group_ids': user_group_ids},
612 resource_type='calendar',
613 permission='read',
614 )
615 cal_result = await db.execute(cal_stmt)
616 accessible_cal_ids = [r[0] for r in cal_result.all()]
617 if not accessible_cal_ids:
618 return CalendarEventListResponse(items=[], total=0)
620 stmt = (
621 select(CalendarEvent, User)
622 .outerjoin(User, User.id == CalendarEvent.user_id)
623 .filter(
624 CalendarEvent.is_cancelled == False,
625 CalendarEvent.calendar_id.in_(accessible_cal_ids),
626 )
627 )
629 if query:
630 search = f'%{query}%'
631 stmt = stmt.filter(
632 or_(
633 CalendarEvent.title.ilike(search),
634 CalendarEvent.description.ilike(search),
635 CalendarEvent.location.ilike(search),
636 )
637 )
639 stmt = stmt.order_by(CalendarEvent.start_at.desc())
641 count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
642 total = count_result.scalar()
644 if skip:
645 stmt = stmt.offset(skip)
646 if limit:
647 stmt = stmt.limit(limit)
649 result = await db.execute(stmt)
650 items = result.all()
652 if not items:
653 return CalendarEventListResponse(items=[], total=total)
655 # Batch-load attendees
656 event_ids = [event.id for event, _user in items]
657 att_result = await db.execute(
658 select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id.in_(event_ids))
659 )
660 att_rows = att_result.scalars().all()
661 att_map: dict[str, list[CalendarEventAttendeeModel]] = {}
662 for a in att_rows:
663 att_map.setdefault(a.event_id, []).append(CalendarEventAttendeeModel.model_validate(a))
665 events = []
666 for event, user in items:
667 event_data = CalendarEventModel.model_validate(event).model_dump(exclude={'attendees'})
668 event_data['attendees'] = att_map.get(event.id, [])
669 events.append(
670 CalendarEventUserResponse(
671 **event_data,
672 user=(UserResponse(**UserModel.model_validate(user).model_dump()) if user else None),
673 )
674 )
676 return CalendarEventListResponse(items=events, total=total)
678 async def update_event_by_id(
679 self, id: str, form_data: CalendarEventUpdateForm, db: Optional[AsyncSession] = None
680 ) -> Optional[CalendarEventModel]:
681 await validate_calendar_rrule(form_data.rrule)
682 async with get_async_db_context(db) as db:
683 result = await db.execute(select(CalendarEvent).filter(CalendarEvent.id == id))
684 event = result.scalars().first()
685 if not event:
686 return None
688 update_data = form_data.model_dump(exclude_unset=True)
689 for field in [
690 'calendar_id',
691 'title',
692 'description',
693 'start_at',
694 'end_at',
695 'all_day',
696 'rrule',
697 'color',
698 'location',
699 'is_cancelled',
700 ]:
701 if field in update_data:
702 setattr(event, field, update_data[field])
704 if 'data' in update_data and update_data['data'] is not None:
705 event.data = {**(event.data or {}), **update_data['data']}
706 if 'meta' in update_data and update_data['meta'] is not None:
707 event.meta = {**(event.meta or {}), **update_data['meta']}
709 if 'attendees' in update_data and update_data['attendees'] is not None:
710 await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db)
712 event.updated_at = int(time.time_ns())
713 await db.commit()
714 return await self._to_event_model(event, db=db)
716 async def get_upcoming_events(
717 self,
718 now_ns: int,
719 default_lookahead_ns: int,
720 grace_ns: int = 0,
721 db: Optional[AsyncSession] = None,
722 ) -> list[tuple[CalendarEventModel, Optional[str]]]:
723 """Events starting between now and now + lookahead, for alert processing.
725 Per-event lookahead is read from meta.alert_minutes (falls back to
726 default_lookahead_ns). Returns (event, user_timezone) pairs.
728 *grace_ns* widens the SQL lower bound so that events whose start_at
729 is up to *grace_ns* nanoseconds in the past are still fetched. This
730 ensures "At time of event" alerts (alert_minutes=0) are not missed
731 when the scheduler polls a few seconds after the event's exact start
732 time.
733 """
734 from open_webui.models.users import User as UserRow
736 # Use the maximum possible lookahead (60 min) to cast a wide net;
737 # per-event filtering happens in Python after fetching.
738 max_lookahead_ns = max(default_lookahead_ns, 60 * 60 * 1_000_000_000)
739 upper = now_ns + max_lookahead_ns
741 async with get_async_db_context(db) as db:
742 result = await db.execute(
743 select(CalendarEvent, UserRow.timezone)
744 .outerjoin(UserRow, UserRow.id == CalendarEvent.user_id)
745 .filter(
746 CalendarEvent.is_cancelled == False,
747 CalendarEvent.start_at >= now_ns - grace_ns,
748 CalendarEvent.start_at <= upper,
749 )
750 )
751 rows = result.all()
753 events = []
754 for event, tz in rows: 754 ↛ 755line 754 didn't jump to line 755 because the loop on line 754 never started
755 model = CalendarEventModel.model_validate(event)
756 # meta is user-writable and this poll is shared by every user.
757 alert_minutes = (model.meta or {}).get('alert_minutes')
758 if not isinstance(alert_minutes, (int, float)):
759 alert_minutes = None
761 if alert_minutes is not None:
762 if alert_minutes < 0:
763 # alert_minutes < 0 means "no alert"
764 continue
765 event_lookahead_ns = alert_minutes * 60 * 1_000_000_000
766 else:
767 event_lookahead_ns = default_lookahead_ns
769 if model.start_at <= now_ns + event_lookahead_ns:
770 events.append((model, tz))
772 return events
774 async def delete_event_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool:
775 try:
776 async with get_async_db_context(db) as db:
777 await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == id))
778 await db.execute(delete(CalendarEvent).filter(CalendarEvent.id == id))
779 await db.commit()
780 return True
781 except Exception:
782 return False
785class CalendarEventAttendeeTable:
786 async def set_attendees(
787 self, event_id: str, attendees: list[dict], db: Optional[AsyncSession] = None
788 ) -> list[CalendarEventAttendeeModel]:
789 """Replace all attendees for an event ({user_id, meta?} per dict).
791 RSVP status is the attendee's alone to set (via update_rsvp): an existing
792 attendee keeps their status, a newly added one starts 'pending'. A
793 caller-supplied status is ignored so an organiser cannot set it for others.
794 """
795 async with get_async_db_context(db) as db:
796 existing_status = {
797 row.user_id: row.status
798 for row in (
799 await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
800 ).scalars()
801 }
803 # Remove existing
804 await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
806 now = int(time.time_ns())
807 models = []
808 for att in attendees:
809 user_id = att['user_id']
810 row = CalendarEventAttendee(
811 id=str(uuid4()),
812 event_id=event_id,
813 user_id=user_id,
814 status=existing_status.get(user_id, 'pending'),
815 meta=att.get('meta'),
816 created_at=now,
817 updated_at=now,
818 )
819 db.add(row)
820 models.append(CalendarEventAttendeeModel.model_validate(row))
822 await db.commit()
823 return models
825 async def update_rsvp(
826 self, event_id: str, user_id: str, status: str, db: Optional[AsyncSession] = None
827 ) -> Optional[CalendarEventAttendeeModel]:
828 async with get_async_db_context(db) as db:
829 result = await db.execute(
830 select(CalendarEventAttendee).filter(
831 CalendarEventAttendee.event_id == event_id,
832 CalendarEventAttendee.user_id == user_id,
833 )
834 )
835 att = result.scalars().first()
836 if not att:
837 return None
839 att.status = status
840 att.updated_at = int(time.time_ns())
841 await db.commit()
842 return CalendarEventAttendeeModel.model_validate(att)
844 async def get_attendees_by_event(
845 self, event_id: str, db: Optional[AsyncSession] = None
846 ) -> list[CalendarEventAttendeeModel]:
847 async with get_async_db_context(db) as db:
848 result = await db.execute(select(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id))
849 return [CalendarEventAttendeeModel.model_validate(r) for r in result.scalars().all()]
851 async def get_events_by_attendee(self, user_id: str, db: Optional[AsyncSession] = None) -> list[str]:
852 """Return event IDs where user is an attendee."""
853 async with get_async_db_context(db) as db:
854 result = await db.execute(
855 select(CalendarEventAttendee.event_id).filter(CalendarEventAttendee.user_id == user_id)
856 )
857 return [r[0] for r in result.all()]
860Calendars = CalendarTable()
861CalendarEvents = CalendarEventTable()
862CalendarEventAttendees = CalendarEventAttendeeTable()