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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1import logging
2import time
3from typing import Optional
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
30log = logging.getLogger(__name__)
32router = APIRouter()
34SCHEDULED_TASKS_CALENDAR_ID = '__scheduled_tasks__'
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 )
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'))
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')
82####################
83# Calendar CRUD (static paths first)
84####################
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)
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 )
109 return calendars
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
138####################
139# Event CRUD (before /{calendar_id} to avoid route conflicts)
140####################
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.
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
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
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)')
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
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 )
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)
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
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
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 }
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))
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)
269 return [e.model_dump() if hasattr(e, 'model_dump') else e for e in expanded]
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
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)
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')
309 await _check_calendar_access(event.calendar_id, user, 'read')
311 return event
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')
323 await _check_calendar_access(event.calendar_id, user, 'write')
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')
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
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')
354 await _check_calendar_access(event.calendar_id, user, 'write')
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}
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')
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}
391####################
392# Calendar by ID (dynamic path — MUST come after /events* routes)
393####################
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
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')
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')
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 )
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
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)
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')
449 cal = await _check_calendar_access(calendar_id, user, 'write')
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')
455 # Block deletion of default calendar
456 if cal.is_default:
457 raise HTTPException(status_code=400, detail='Default calendar cannot be deleted')
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}
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