Coverage for open_webui/routers/calendar.py: 55%

200 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 

4 

5from fastapi import APIRouter, Depends, HTTPException, Request, status 

6from open_webui.constants import ERROR_MESSAGES 

7from open_webui.events import EVENTS, publish_event 

8from open_webui.models.access_grants import AccessGrants 

9from open_webui.models.calendar import ( 

10 CalendarEventAttendees, 

11 CalendarEventForm, 

12 CalendarEventListResponse, 

13 CalendarEventModel, 

14 CalendarEvents, 

15 CalendarEventUpdateForm, 

16 CalendarEventUserResponse, 

17 CalendarForm, 

18 CalendarModel, 

19 Calendars, 

20 CalendarUpdateForm, 

21 RSVPForm, 

22) 

23from open_webui.models.config import Config 

24from open_webui.models.groups import Groups 

25from open_webui.models.users import UserModel 

26from open_webui.utils.access_control import filter_allowed_access_grants, has_permission 

27from open_webui.utils.auth import get_verified_user 

28from open_webui.utils.calendar import expand_recurring_event 

29 

30log = logging.getLogger(__name__) 

31 

32router = APIRouter() 

33 

34SCHEDULED_TASKS_CALENDAR_ID = '__scheduled_tasks__' 

35 

36 

37async def check_calendar_permission(request: Request, user): 

38 """Check global feature flag AND per-user permission for calendar access.""" 

39 config = await Config.get_many('calendar.enable', 'user.permissions') 

40 if not config.get('calendar.enable'): 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true

41 raise HTTPException( 

42 status_code=status.HTTP_403_FORBIDDEN, 

43 detail=ERROR_MESSAGES.UNAUTHORIZED, 

44 ) 

45 if user.role != 'admin' and not await has_permission(user.id, 'features.calendar', config.get('user.permissions')): 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true

46 raise HTTPException( 

47 status_code=status.HTTP_403_FORBIDDEN, 

48 detail=ERROR_MESSAGES.UNAUTHORIZED, 

49 ) 

50 

51 

52async def _user_has_automations(request: Request, user) -> bool: 

53 """Check if automations feature is available to this user.""" 

54 config = await Config.get_many('automations.enable', 'user.permissions') 

55 if not config.get('automations.enable', False): 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true

56 return False 

57 if user.role == 'admin': 57 ↛ 59line 57 didn't jump to line 59 because the condition on line 57 was always true

58 return True 

59 return await has_permission(user.id, 'features.automations', config.get('user.permissions')) 

60 

61 

62async def _check_calendar_access(calendar_id: str, user: UserModel, permission: str = 'write') -> CalendarModel: 

63 """Verify user has access to a calendar. Returns the calendar or raises 403/404.""" 

64 cal = await Calendars.get_calendar_by_id(calendar_id) 

65 if not cal: 

66 raise HTTPException(status_code=404, detail='Calendar not found') 

67 if cal.user_id == user.id or user.role == 'admin': 67 ↛ 69line 67 didn't jump to line 69 because the condition on line 67 was always true

68 return cal 

69 user_groups = await Groups.get_groups_by_member_id(user.id) 

70 user_group_ids = [g.id for g in user_groups] 

71 if await AccessGrants.has_access( 

72 user_id=user.id, 

73 resource_type='calendar', 

74 resource_id=cal.id, 

75 permission=permission, 

76 user_group_ids=user_group_ids, 

77 ): 

78 return cal 

79 raise HTTPException(status_code=403, detail='Access denied') 

80 

81 

82#################### 

83# Calendar CRUD (static paths first) 

84#################### 

85 

86 

87@router.get('/', response_model=list[CalendarModel]) 

88async def get_calendars(request: Request, user: UserModel = Depends(get_verified_user)): 

89 """List user's calendars (owned + shared), plus a virtual Scheduled Tasks calendar 

90 when automations are available.""" 

91 await check_calendar_permission(request, user) 

92 calendars = await Calendars.get_calendars_by_user(user.id) 

93 

94 if await _user_has_automations(request, user): 94 ↛ 109line 94 didn't jump to line 109 because the condition on line 94 was always true

95 now = int(time.time_ns()) 

96 calendars.append( 

97 CalendarModel( 

98 id=SCHEDULED_TASKS_CALENDAR_ID, 

99 user_id=user.id, 

100 name='Scheduled Tasks', 

101 color='#8b5cf6', 

102 is_default=False, 

103 is_system=True, 

104 created_at=now, 

105 updated_at=now, 

106 ) 

107 ) 

108 

109 return calendars 

110 

111 

112@router.post('/create', response_model=CalendarModel) 

113async def create_calendar(request: Request, form_data: CalendarForm, user: UserModel = Depends(get_verified_user)): 

114 """Create a new user calendar.""" 

115 await check_calendar_permission(request, user) 

116 # Strip public/user grants the requesting user is not permitted to assign 

117 # (matches the channel/notes/models pattern). Without this, any verified user 

118 # could create a calendar with `principal_id='*' permission='read'|'write'`, 

119 # making their events readable or writable by any other verified user. 

120 form_data.access_grants = await filter_allowed_access_grants( 

121 await Config.get('user.permissions'), 

122 user.id, 

123 user.role, 

124 form_data.access_grants, 

125 'sharing.public_calendars', 

126 ) 

127 calendar = await Calendars.insert_new_calendar(user.id, form_data) 

128 await publish_event( 

129 request, 

130 EVENTS.CALENDAR_CREATED, 

131 actor=user, 

132 subject_id=calendar.id, 

133 data={'name': calendar.name}, 

134 ) 

135 return calendar 

136 

137 

138#################### 

139# Event CRUD (before /{calendar_id} to avoid route conflicts) 

140#################### 

141 

142 

143@router.get('/events') 

144async def get_events( 

145 request: Request, 

146 start: str, 

147 end: str, 

148 calendar_ids: Optional[str] = None, 

149 user: UserModel = Depends(get_verified_user), 

150): 

151 """Get events in date range. 

152 

153 Args: 

154 start: ISO 8601 datetime string (e.g. 2026-04-01T00:00:00) 

155 end: ISO 8601 datetime string (e.g. 2026-05-01T00:00:00) 

156 calendar_ids: optional comma-separated list to filter 

157 

158 Includes: 

159 - Stored events from the database 

160 - Virtual events computed from active automation RRULEs (Scheduled Tasks calendar) 

161 """ 

162 await check_calendar_permission(request, user) 

163 from datetime import datetime 

164 

165 try: 

166 start_dt = datetime.fromisoformat(start.replace('Z', '+00:00')) 

167 end_dt = datetime.fromisoformat(end.replace('Z', '+00:00')) 

168 except ValueError: 

169 raise HTTPException(status_code=400, detail='Invalid date format. Use ISO 8601 (e.g. 2026-04-01T00:00:00)') 

170 

171 NS = 1_000_000 

172 start_ns = int(start_dt.timestamp() * 1000) * NS 

173 end_ns = int(end_dt.timestamp() * 1000) * NS 

174 cal_id_list = calendar_ids.split(',') if calendar_ids else None 

175 

176 # 1. Stored events 

177 events = await CalendarEvents.get_events_by_range( 

178 user_id=user.id, 

179 start=start_ns, 

180 end=end_ns, 

181 calendar_ids=cal_id_list, 

182 ) 

183 

184 # Expand recurring stored events 

185 expanded = [] 

186 for event in events: 

187 event_dict = event.model_dump() 

188 if event_dict.get('rrule'): 

189 instances = expand_recurring_event(event_dict, start_ns, end_ns, tz=user.timezone) 

190 for inst in instances: 

191 expanded.append(CalendarEventUserResponse(**{**inst, 'user': event.user})) 

192 else: 

193 expanded.append(event) 

194 

195 # 2. Virtual automation events (Scheduled Tasks calendar) 

196 if await _user_has_automations(request, user) and ( 

197 cal_id_list is None or SCHEDULED_TASKS_CALENDAR_ID in cal_id_list 

198 ): 

199 try: 

200 from open_webui.models.automations import AutomationRuns, Automations 

201 

202 # Future runs: expand RRULEs for active automations only 

203 active_automations = await Automations.get_active_by_user(user.id) 

204 for auto in active_automations: 

205 rrule_str = auto.data.get('rrule', '') if auto.data else '' 

206 if not rrule_str: 

207 continue 

208 

209 virtual = { 

210 'id': f'auto_{auto.id}', 

211 'calendar_id': SCHEDULED_TASKS_CALENDAR_ID, 

212 'user_id': user.id, 

213 'title': auto.name, 

214 'description': auto.data.get('prompt', '') if auto.data else '', 

215 'start_at': auto.next_run_at or 0, 

216 'end_at': None, 

217 'all_day': False, 

218 'rrule': rrule_str, 

219 'color': None, 

220 'location': None, 

221 'data': None, 

222 'meta': {'automation_id': auto.id}, 

223 'is_cancelled': False, 

224 'attendees': [], 

225 'created_at': auto.created_at, 

226 'updated_at': auto.updated_at, 

227 'user': None, 

228 } 

229 

230 # Only expand into the future — past runs are handled below 

231 now_ns = int(time.time_ns()) 

232 rrule_start = max(start_ns, now_ns) 

233 instances = expand_recurring_event(virtual, rrule_start, end_ns, tz=user.timezone) 

234 for inst in instances: 

235 expanded.append(CalendarEventUserResponse(**inst)) 

236 

237 # Past runs: single range query joined with automation 

238 runs_with_auto = await AutomationRuns.get_runs_by_user_range(user.id, start_ns, end_ns) 

239 for run, auto in runs_with_auto: 

240 expanded.append( 

241 CalendarEventUserResponse( 

242 id=f'run_{run.id}', 

243 calendar_id=SCHEDULED_TASKS_CALENDAR_ID, 

244 user_id=user.id, 

245 title=auto.name, 

246 description=run.error if run.status == 'error' else '', 

247 start_at=run.created_at, 

248 end_at=None, 

249 all_day=False, 

250 color=None, 

251 location=None, 

252 data=None, 

253 meta={ 

254 'automation_id': auto.id, 

255 'run_id': run.id, 

256 'chat_id': run.chat_id, 

257 'status': run.status, 

258 }, 

259 is_cancelled=False, 

260 attendees=[], 

261 created_at=run.created_at, 

262 updated_at=run.created_at, 

263 user=None, 

264 ) 

265 ) 

266 except Exception as e: 

267 log.warning(f'Failed to compute automation events: {e}', exc_info=True) 

268 

269 return [e.model_dump() if hasattr(e, 'model_dump') else e for e in expanded] 

270 

271 

272@router.post('/events/create', response_model=CalendarEventModel) 

273async def create_event(request: Request, form_data: CalendarEventForm, user: UserModel = Depends(get_verified_user)): 

274 await check_calendar_permission(request, user) 

275 await _check_calendar_access(form_data.calendar_id, user, 'write') 

276 try: 

277 event = await CalendarEvents.insert_new_event(user.id, form_data) 

278 except ValueError as e: 

279 raise HTTPException(status_code=422, detail=str(e)) from e 

280 await publish_event( 

281 request, 

282 EVENTS.CALENDAR_EVENT_CREATED, 

283 actor=user, 

284 subject_id=event.id, 

285 data={'calendar_id': event.calendar_id, 'title': event.title}, 

286 ) 

287 return event 

288 

289 

290@router.get('/events/search', response_model=CalendarEventListResponse) 

291async def search_events( 

292 request: Request, 

293 query: Optional[str] = None, 

294 skip: int = 0, 

295 limit: int = 30, 

296 user: UserModel = Depends(get_verified_user), 

297): 

298 await check_calendar_permission(request, user) 

299 return await CalendarEvents.search_events(user_id=user.id, query=query, skip=skip, limit=limit) 

300 

301 

302@router.get('/events/{event_id}', response_model=CalendarEventModel) 

303async def get_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): 

304 await check_calendar_permission(request, user) 

305 event = await CalendarEvents.get_event_by_id(event_id) 

306 if not event: 306 ↛ 309line 306 didn't jump to line 309 because the condition on line 306 was always true

307 raise HTTPException(status_code=404, detail='Event not found') 

308 

309 await _check_calendar_access(event.calendar_id, user, 'read') 

310 

311 return event 

312 

313 

314@router.post('/events/{event_id}/update', response_model=CalendarEventModel) 

315async def update_event( 

316 request: Request, event_id: str, form_data: CalendarEventUpdateForm, user: UserModel = Depends(get_verified_user) 

317): 

318 await check_calendar_permission(request, user) 

319 event = await CalendarEvents.get_event_by_id(event_id) 

320 if not event: 320 ↛ 323line 320 didn't jump to line 323 because the condition on line 320 was always true

321 raise HTTPException(status_code=404, detail='Event not found') 

322 

323 await _check_calendar_access(event.calendar_id, user, 'write') 

324 

325 # A new calendar_id in the form moves the event; require write access on the 

326 # destination too, mirroring create_event. Without this, write on the source 

327 # calendar alone is enough to inject an event into any other calendar. 

328 if form_data.calendar_id is not None and form_data.calendar_id != event.calendar_id: 

329 await _check_calendar_access(form_data.calendar_id, user, 'write') 

330 

331 try: 

332 updated = await CalendarEvents.update_event_by_id(event_id, form_data) 

333 except ValueError as e: 

334 raise HTTPException(status_code=422, detail=str(e)) from e 

335 if not updated: 

336 raise HTTPException(status_code=500, detail='Failed to update') 

337 await publish_event( 

338 request, 

339 EVENTS.CALENDAR_EVENT_UPDATED, 

340 actor=user, 

341 subject_id=updated.id, 

342 data={'calendar_id': updated.calendar_id, 'title': updated.title}, 

343 ) 

344 return updated 

345 

346 

347@router.delete('/events/{event_id}/delete') 

348async def delete_event(request: Request, event_id: str, user: UserModel = Depends(get_verified_user)): 

349 await check_calendar_permission(request, user) 

350 event = await CalendarEvents.get_event_by_id(event_id) 

351 if not event: 351 ↛ 354line 351 didn't jump to line 354 because the condition on line 351 was always true

352 raise HTTPException(status_code=404, detail='Event not found') 

353 

354 await _check_calendar_access(event.calendar_id, user, 'write') 

355 

356 result = await CalendarEvents.delete_event_by_id(event_id) 

357 if not result: 

358 raise HTTPException(status_code=500, detail='Failed to delete') 

359 await publish_event( 

360 request, 

361 EVENTS.CALENDAR_EVENT_DELETED, 

362 actor=user, 

363 subject_id=event_id, 

364 data={'calendar_id': event.calendar_id, 'title': event.title}, 

365 ) 

366 return {'status': True} 

367 

368 

369@router.post('/events/{event_id}/rsvp', response_model=dict) 

370async def rsvp_event( 

371 request: Request, event_id: str, form_data: RSVPForm, user: UserModel = Depends(get_verified_user) 

372): 

373 """Update own RSVP status for an event.""" 

374 await check_calendar_permission(request, user) 

375 if form_data.status not in ('accepted', 'declined', 'tentative', 'pending'): 375 ↛ 378line 375 didn't jump to line 378 because the condition on line 375 was always true

376 raise HTTPException(status_code=400, detail='Invalid status') 

377 

378 result = await CalendarEventAttendees.update_rsvp(event_id, user.id, form_data.status) 

379 if not result: 

380 raise HTTPException(status_code=404, detail='Not an attendee of this event') 

381 await publish_event( 

382 request, 

383 EVENTS.CALENDAR_EVENT_RSVP_UPDATED, 

384 actor=user, 

385 subject_id=event_id, 

386 data={'status': result.status}, 

387 ) 

388 return {'status': True, 'rsvp': result.status} 

389 

390 

391#################### 

392# Calendar by ID (dynamic path — MUST come after /events* routes) 

393#################### 

394 

395 

396@router.get('/{calendar_id}', response_model=CalendarModel) 

397async def get_calendar_by_id(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): 

398 await check_calendar_permission(request, user) 

399 cal = await _check_calendar_access(calendar_id, user, 'read') 

400 return cal 

401 

402 

403@router.post('/{calendar_id}/update', response_model=CalendarModel) 

404async def update_calendar( 

405 request: Request, calendar_id: str, form_data: CalendarUpdateForm, user: UserModel = Depends(get_verified_user) 

406): 

407 await check_calendar_permission(request, user) 

408 cal = await _check_calendar_access(calendar_id, user, 'write') 

409 

410 # Only owner/admin can change access grants 

411 if form_data.access_grants is not None and cal.user_id != user.id and user.role != 'admin': 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 raise HTTPException(status_code=403, detail='Only owner can manage sharing') 

413 

414 # Strip public/user grants the requesting user is not permitted to assign 

415 # (matches the channel/notes/models pattern). The owner-only check above 

416 # only restricts WHO can set grants; this filter restricts WHICH grants 

417 # they may set, so a non-admin owner cannot make their calendar 

418 # publicly readable/writable without the corresponding sharing permission. 

419 if form_data.access_grants is not None: 

420 form_data.access_grants = await filter_allowed_access_grants( 

421 await Config.get('user.permissions'), 

422 user.id, 

423 user.role, 

424 form_data.access_grants, 

425 'sharing.public_calendars', 

426 ) 

427 

428 updated = await Calendars.update_calendar_by_id(calendar_id, form_data) 

429 if not updated: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 raise HTTPException(status_code=500, detail='Failed to update') 

431 await publish_event( 

432 request, 

433 EVENTS.CALENDAR_UPDATED, 

434 actor=user, 

435 subject_id=updated.id, 

436 data={'name': updated.name}, 

437 ) 

438 return updated 

439 

440 

441@router.delete('/{calendar_id}/delete') 

442async def delete_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): 

443 await check_calendar_permission(request, user) 

444 

445 # Block deletion of the virtual Scheduled Tasks calendar 

446 if calendar_id == SCHEDULED_TASKS_CALENDAR_ID: 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true

447 raise HTTPException(status_code=400, detail='System calendars cannot be deleted') 

448 

449 cal = await _check_calendar_access(calendar_id, user, 'write') 

450 

451 # Only owner/admin can delete 

452 if cal.user_id != user.id and user.role != 'admin': 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

453 raise HTTPException(status_code=403, detail='Only owner can delete calendar') 

454 

455 # Block deletion of default calendar 

456 if cal.is_default: 

457 raise HTTPException(status_code=400, detail='Default calendar cannot be deleted') 

458 

459 result = await Calendars.delete_calendar_by_id(calendar_id) 

460 if not result: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 raise HTTPException(status_code=500, detail='Failed to delete') 

462 await publish_event( 

463 request, 

464 EVENTS.CALENDAR_DELETED, 

465 actor=user, 

466 subject_id=calendar_id, 

467 data={'name': cal.name}, 

468 ) 

469 return {'status': True} 

470 

471 

472@router.post('/{calendar_id}/default') 

473async def set_default_calendar(request: Request, calendar_id: str, user: UserModel = Depends(get_verified_user)): 

474 await check_calendar_permission(request, user) 

475 cal = await Calendars.set_default_calendar(user.id, calendar_id) 

476 if not cal: 

477 raise HTTPException(status_code=404, detail='Calendar not found') 

478 await publish_event( 

479 request, 

480 EVENTS.CALENDAR_DEFAULT_UPDATED, 

481 actor=user, 

482 subject_id=cal.id, 

483 data={'name': cal.name}, 

484 ) 

485 return cal