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

1import logging 

2import time 

3from typing import Optional 

4from uuid import uuid4 

5 

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 

28 

29log = logging.getLogger(__name__) 

30MIN_CALENDAR_RRULE_INTERVAL_SECONDS = 24 * 60 * 60 

31 

32 

33#################### 

34# Calendar DB Schema 

35#################### 

36 

37 

38class Calendar(Base): 

39 __tablename__ = 'calendar' 

40 

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) 

48 

49 created_at = Column(BigInteger, nullable=False) 

50 updated_at = Column(BigInteger, nullable=False) 

51 

52 __table_args__ = (Index('ix_calendar_user', 'user_id'),) 

53 

54 

55class CalendarEvent(Base): 

56 __tablename__ = 'calendar_event' 

57 

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) 

72 

73 created_at = Column(BigInteger, nullable=False) 

74 updated_at = Column(BigInteger, nullable=False) 

75 

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 ) 

80 

81 

82class CalendarEventAttendee(Base): 

83 __tablename__ = 'calendar_event_attendee' 

84 

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) 

90 

91 created_at = Column(BigInteger, nullable=False) 

92 updated_at = Column(BigInteger, nullable=False) 

93 

94 __table_args__ = ( 

95 UniqueConstraint('event_id', 'user_id', name='uq_event_attendee'), 

96 Index('ix_calendar_event_attendee_user', 'user_id', 'status'), 

97 ) 

98 

99 

100#################### 

101# Pydantic Models 

102#################### 

103 

104 

105class CalendarModel(BaseModel): 

106 model_config = ConfigDict(from_attributes=True) 

107 

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 

114 

115 data: Optional[dict] = None 

116 meta: Optional[dict] = None 

117 

118 access_grants: list[AccessGrantModel] = Field(default_factory=list) 

119 

120 created_at: int 

121 updated_at: int 

122 

123 

124class CalendarEventModel(BaseModel): 

125 model_config = ConfigDict(from_attributes=True, extra='allow') 

126 

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 

141 

142 attendees: list['CalendarEventAttendeeModel'] = Field(default_factory=list) 

143 

144 created_at: int 

145 updated_at: int 

146 

147 

148class CalendarEventAttendeeModel(BaseModel): 

149 model_config = ConfigDict(from_attributes=True) 

150 

151 id: str 

152 event_id: str 

153 user_id: str 

154 status: str = 'pending' 

155 meta: Optional[dict] = None 

156 

157 created_at: int 

158 updated_at: int 

159 

160 

161#################### 

162# Forms 

163#################### 

164 

165 

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 

172 

173 

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 

180 

181 

182async def validate_calendar_rrule(value: Optional[str]) -> None: 

183 if value: 

184 from open_webui.utils.recurrence import rrule_interval_seconds 

185 

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) 

194 

195 

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 

209 

210 

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 

225 

226 

227class RSVPForm(BaseModel): 

228 status: str # 'accepted' | 'declined' | 'tentative' | 'pending' 

229 

230 

231#################### 

232# Response Models 

233#################### 

234 

235 

236class CalendarEventUserResponse(CalendarEventModel): 

237 user: Optional[UserResponse] = None 

238 

239 

240class CalendarEventListResponse(BaseModel): 

241 items: list[CalendarEventUserResponse] 

242 total: int 

243 

244 

245#################### 

246# Table Operations 

247#################### 

248 

249 

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) 

253 

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 

265 

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() 

273 

274 if calendars: 

275 return [CalendarModel.model_validate(c) for c in calendars] 

276 

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)] 

290 

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] 

296 

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()) 

307 

308 result = await db.execute(stmt) 

309 calendars = result.scalars().all() 

310 

311 if not calendars: 

312 return await self.get_or_create_defaults(user_id, db=db) 

313 

314 cal_ids = [c.id for c in calendars] 

315 grants_map = await AccessGrants.get_grants_by_resources('calendar', cal_ids, db=db) 

316 

317 return [await self._to_calendar_model(c, access_grants=grants_map.get(c.id, []), db=db) for c in calendars] 

318 

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 

324 

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) 

346 

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 

355 

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) 

367 

368 cal.updated_at = int(time.time_ns()) 

369 await db.commit() 

370 return await self._to_calendar_model(cal, db=db) 

371 

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) 

392 

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 

401 

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 ) 

409 

410 # Delete events 

411 await db.execute(delete(CalendarEvent).filter(CalendarEvent.calendar_id == id)) 

412 

413 # Delete calendar 

414 await db.execute(delete(Calendar).filter(Calendar.id == id)) 

415 await db.commit() 

416 

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 

424 

425 

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] 

434 

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) 

446 

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() 

473 

474 # Add attendees 

475 if form_data.attendees: 

476 await CalendarEventAttendees.set_attendees(event.id, form_data.attendees, db=db) 

477 

478 return await self._to_event_model(event, db=db) 

479 

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 

485 

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. 

495 

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] 

502 

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()] 

515 

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] 

519 

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()] 

528 

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)) 

535 

536 if not conditions: 

537 return [] 

538 

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 ) 

563 

564 result = await db.execute(stmt) 

565 items = result.all() 

566 

567 if not items: 

568 return [] 

569 

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)) 

579 

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 ) 

590 

591 return events 

592 

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] 

604 

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) 

619 

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 ) 

628 

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 ) 

638 

639 stmt = stmt.order_by(CalendarEvent.start_at.desc()) 

640 

641 count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) 

642 total = count_result.scalar() 

643 

644 if skip: 

645 stmt = stmt.offset(skip) 

646 if limit: 

647 stmt = stmt.limit(limit) 

648 

649 result = await db.execute(stmt) 

650 items = result.all() 

651 

652 if not items: 

653 return CalendarEventListResponse(items=[], total=total) 

654 

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)) 

664 

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 ) 

675 

676 return CalendarEventListResponse(items=events, total=total) 

677 

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 

687 

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]) 

703 

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']} 

708 

709 if 'attendees' in update_data and update_data['attendees'] is not None: 

710 await CalendarEventAttendees.set_attendees(id, update_data['attendees'], db=db) 

711 

712 event.updated_at = int(time.time_ns()) 

713 await db.commit() 

714 return await self._to_event_model(event, db=db) 

715 

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. 

724 

725 Per-event lookahead is read from meta.alert_minutes (falls back to 

726 default_lookahead_ns). Returns (event, user_timezone) pairs. 

727 

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 

735 

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 

740 

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() 

752 

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 

760 

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 

768 

769 if model.start_at <= now_ns + event_lookahead_ns: 

770 events.append((model, tz)) 

771 

772 return events 

773 

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 

783 

784 

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). 

790 

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 } 

802 

803 # Remove existing 

804 await db.execute(delete(CalendarEventAttendee).filter(CalendarEventAttendee.event_id == event_id)) 

805 

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)) 

821 

822 await db.commit() 

823 return models 

824 

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 

838 

839 att.status = status 

840 att.updated_at = int(time.time_ns()) 

841 await db.commit() 

842 return CalendarEventAttendeeModel.model_validate(att) 

843 

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()] 

850 

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()] 

858 

859 

860Calendars = CalendarTable() 

861CalendarEvents = CalendarEventTable() 

862CalendarEventAttendees = CalendarEventAttendeeTable()