Coverage for open_webui/routers/users.py: 74%
498 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
1from __future__ import annotations
3import base64
4import io
5import logging
6import time
7from collections import Counter
8from datetime import datetime, timedelta
9from typing import Optional
11from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
12from fastapi.responses import FileResponse, Response, StreamingResponse
13from open_webui.constants import ERROR_MESSAGES
14from open_webui.events import EVENTS, publish_event
15from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
16from open_webui.internal.db import get_async_session
17from open_webui.models.auths import Auths
18from open_webui.models.config import Config
19from open_webui.models.chat_messages import ChatMessages
20from open_webui.models.chats import Chats
21from open_webui.models.groups import Groups
22from open_webui.models.oauth_sessions import OAuthSessions
23from open_webui.models.users import (
24 InterfaceSettings,
25 UserGroupIdsListResponse,
26 UserGroupIdsModel,
27 UserInfoListResponse,
28 UserInfoResponse,
29 UserModel,
30 UserRoleUpdateForm,
31 Users,
32 UserSettings,
33 UserStatus,
34 UserUpdateForm,
35)
36from open_webui.models.access_grants import AccessGrants
37from open_webui.models.knowledge import Knowledges
38from open_webui.models.models import Models
39from open_webui.models.tools import Tools
40from open_webui.utils.access_control import get_permissions, has_permission
41from open_webui.utils.auth import (
42 get_admin_user,
43 get_password_hash,
44 get_verified_user,
45 revoke_user_tokens,
46 validate_password,
47)
48from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables
49from pydantic import BaseModel, ConfigDict, Field
50from sqlalchemy.ext.asyncio import AsyncSession
52log = logging.getLogger(__name__)
54router = APIRouter()
57def merge_user_ui_settings(defaults: dict, settings: dict) -> dict:
58 merged = dict(defaults)
59 for key, value in settings.items():
60 if value is None:
61 continue
63 default_value = merged.get(key)
64 merged[key] = (
65 merge_user_ui_settings(default_value, value)
66 if isinstance(default_value, dict) and isinstance(value, dict)
67 else value
68 )
69 return merged
72############################
73# GetUsers
74# A house is only as strong as its care for the least of
75# its members. Let none here be counted without being served.
76############################
79PAGE_ITEM_COUNT = 30
82@router.get('/', response_model=UserGroupIdsListResponse)
83async def get_users(
84 query: str | None = None,
85 order_by: str | None = None,
86 direction: str | None = None,
87 page: int | None = 1,
88 user=Depends(get_admin_user),
89 db: AsyncSession = Depends(get_async_session),
90):
91 limit = PAGE_ITEM_COUNT
93 page = max(1, page)
94 skip = (page - 1) * limit
96 filter = {}
97 if query:
98 filter['query'] = query
100 result = await Users.get_users(
101 filter=filter,
102 sort={'order_by': order_by, 'direction': direction},
103 skip=skip,
104 limit=limit,
105 db=db,
106 )
108 users = result['users']
109 total = result['total']
111 # Fetch groups for all users in a single query to avoid N+1
112 user_ids = [user.id for user in users]
113 user_groups = await Groups.get_groups_by_member_ids(user_ids, db=db)
115 return {
116 'users': [
117 UserGroupIdsModel(
118 **{
119 **user.model_dump(),
120 'group_ids': [group.id for group in user_groups.get(user.id, [])],
121 }
122 )
123 for user in users
124 ],
125 'total': total,
126 }
129@router.get('/all', response_model=UserInfoListResponse)
130async def get_all_users(
131 user=Depends(get_admin_user),
132 db: AsyncSession = Depends(get_async_session),
133):
134 return await Users.get_users(db=db)
137@router.get('/search', response_model=UserInfoListResponse)
138async def search_users(
139 query: str | None = None,
140 order_by: str | None = None,
141 direction: str | None = None,
142 page: int | None = 1,
143 user=Depends(get_verified_user),
144 db: AsyncSession = Depends(get_async_session),
145):
146 limit = PAGE_ITEM_COUNT
148 page = max(1, page)
149 skip = (page - 1) * limit
151 filter = {}
152 if query:
153 filter['query'] = query
155 return await Users.get_users(
156 filter=filter,
157 sort={'order_by': order_by, 'direction': direction},
158 skip=skip,
159 limit=limit,
160 db=db,
161 )
164############################
165# User Groups
166############################
169@router.get('/groups')
170async def get_user_groups(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
171 return await Groups.get_groups_by_member_id(user.id, db=db)
174############################
175# User Permissions
176############################
179@router.get('/permissions')
180async def get_user_permissisions(
181 request: Request,
182 user=Depends(get_verified_user),
183 db: AsyncSession = Depends(get_async_session),
184):
185 user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
187 return user_permissions
190############################
191# User Default Permissions
192############################
193class WorkspacePermissions(BaseModel):
194 models: bool = False
195 knowledge: bool = False
196 prompts: bool = False
197 tools: bool = False
198 skills: bool = False
199 models_import: bool = False
200 models_export: bool = False
201 prompts_import: bool = False
202 prompts_export: bool = False
203 tools_import: bool = False
204 tools_export: bool = False
205 skills_import: bool = False
206 skills_export: bool = False
209class SharingPermissions(BaseModel):
210 models: bool = False
211 public_models: bool = False
212 knowledge: bool = False
213 public_knowledge: bool = False
214 prompts: bool = False
215 public_prompts: bool = False
216 tools: bool = False
217 public_tools: bool = False
218 skills: bool = False
219 public_skills: bool = False
220 notes: bool = False
221 public_notes: bool = False
222 folders: bool = False
223 public_chats: bool = False
224 open_chats: bool = False
225 public_calendars: bool = False
228class AccessGrantsPermissions(BaseModel):
229 allow_users: bool = True
230 allow_groups: bool = True
233class ChatPermissions(BaseModel):
234 model_config = ConfigDict(populate_by_name=True)
236 controls: bool = True
237 valves: bool = True
238 system_prompt: bool = True
239 params: bool = True
240 file_upload: bool = True
241 web_upload: bool = True
242 delete: bool = True
243 delete_message: bool = True
244 continue_response: bool = True
245 regenerate_response: bool = True
246 rate_response: bool = True
247 edit: bool = True
248 share: bool = True
249 export: bool = True
250 import_: bool = Field(default=True, alias='import')
251 stt: bool = True
252 tts: bool = True
253 call: bool = True
254 multiple_models: bool = True
255 temporary: bool = True
256 temporary_enforced: bool = False
259class FeaturesPermissions(BaseModel):
260 api_keys: bool = False
261 notes: bool = True
262 channels: bool = True
263 folders: bool = True
264 direct_tool_servers: bool = False
266 web_search: bool = True
267 image_generation: bool = True
268 code_interpreter: bool = True
269 memories: bool = True
270 automations: bool = False
271 calendar: bool = True
272 webhooks: bool = False
275class SettingsPermissions(BaseModel):
276 interface: bool = True
279class UserPermissions(BaseModel):
280 workspace: WorkspacePermissions
281 sharing: SharingPermissions
282 access_grants: AccessGrantsPermissions
283 chat: ChatPermissions
284 features: FeaturesPermissions
285 settings: SettingsPermissions
288class UserUsageTotals(BaseModel):
289 lifetime_tokens: int = 0
290 input_tokens: int = 0
291 output_tokens: int = 0
292 peak_daily_tokens: int = 0
293 longest_chat_seconds: int = 0
294 current_streak: int = 0
295 longest_streak: int = 0
296 total_chats: int = 0
297 active_days: int = 0
298 models_used: int = 0
299 messages: int = 0
300 user_messages: int = 0
301 assistant_messages: int = 0
304class UserUsageHeatmapEntry(BaseModel):
305 date: str
306 messages: int = 0
307 chats: int = 0
308 tokens: int = 0
309 models: dict[str, int] = Field(default_factory=dict)
312class UserUsageModelEntry(BaseModel):
313 model_id: str
314 messages: int = 0
315 input_tokens: int = 0
316 output_tokens: int = 0
317 total_tokens: int = 0
320class UserUsageToolEntry(BaseModel):
321 name: str
322 count: int
325class UserUsageInsights(BaseModel):
326 most_used_model: Optional[str] = None
327 average_tokens_per_chat: float = 0
328 average_messages_per_active_day: float = 0
329 user_message_share: float = 0
330 assistant_message_share: float = 0
333class UserUsagePeriod(BaseModel):
334 start_date: int
335 end_date: int
336 days: int
339class UserUsageResponse(BaseModel):
340 totals: UserUsageTotals
341 heatmap: list[UserUsageHeatmapEntry]
342 weekly_heatmap: list[UserUsageHeatmapEntry]
343 cumulative_heatmap: list[UserUsageHeatmapEntry]
344 insights: UserUsageInsights
345 top_models: list[UserUsageModelEntry]
346 top_tools: list[UserUsageToolEntry] = []
347 period: UserUsagePeriod
350def _week_start(date: datetime) -> datetime:
351 return date - timedelta(days=date.weekday())
354def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]:
355 weeks: dict[str, dict] = {}
356 for day in heatmap:
357 week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d')
358 entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()})
359 entry['messages'] += day.get('messages', 0)
360 entry['chats'] += day.get('chats', 0)
361 entry['tokens'] += day.get('tokens', 0)
362 entry['models'].update(day.get('models', {}))
364 return [
365 {
366 **weeks[key],
367 'models': dict(weeks[key]['models']),
368 }
369 for key in sorted(weeks)
370 ]
373def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]:
374 totals = {'messages': 0, 'chats': 0, 'tokens': 0}
375 models: Counter[str] = Counter()
376 cumulative = []
377 for day in heatmap:
378 totals['messages'] += day.get('messages', 0)
379 totals['chats'] += day.get('chats', 0)
380 totals['tokens'] += day.get('tokens', 0)
381 models.update(day.get('models', {}))
382 cumulative.append(
383 {
384 'date': day['date'],
385 **totals,
386 'models': dict(models),
387 }
388 )
389 return cumulative
392def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
393 longest = 0
394 current_run = 0
395 for day in heatmap:
396 if day.get('messages', 0) > 0:
397 current_run += 1
398 longest = max(longest, current_run)
399 else:
400 current_run = 0
402 current = 0
403 for day in reversed(heatmap):
404 if day.get('messages', 0) <= 0:
405 break
406 current += 1
408 return {'current': current, 'longest': longest}
411@router.get('/default/permissions', response_model=UserPermissions)
412async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
413 user_permissions = await Config.get('user.permissions')
414 return {
415 'workspace': WorkspacePermissions(**user_permissions.get('workspace', {})),
416 'sharing': SharingPermissions(**user_permissions.get('sharing', {})),
417 'access_grants': AccessGrantsPermissions(**user_permissions.get('access_grants', {})),
418 'chat': ChatPermissions(**user_permissions.get('chat', {})),
419 'features': FeaturesPermissions(**user_permissions.get('features', {})),
420 'settings': SettingsPermissions(**user_permissions.get('settings', {})),
421 }
424@router.post('/default/permissions')
425async def update_default_user_permissions(request: Request, form_data: UserPermissions, user=Depends(get_admin_user)):
426 user_permissions = form_data.model_dump(by_alias=True)
427 await Config.upsert({'user.permissions': user_permissions})
428 await publish_event(
429 request,
430 EVENTS.USER_PERMISSIONS_UPDATED,
431 actor=user,
432 subject_id='user.permissions',
433 subject_type='config',
434 )
435 return user_permissions
438@router.get('/default/permissions/defaults', response_model=UserPermissions)
439async def get_default_user_permissions_defaults(user=Depends(get_admin_user)):
440 from open_webui.config import DEFAULT_USER_PERMISSIONS
442 return {
443 'workspace': WorkspacePermissions(**DEFAULT_USER_PERMISSIONS.get('workspace', {})),
444 'sharing': SharingPermissions(**DEFAULT_USER_PERMISSIONS.get('sharing', {})),
445 'access_grants': AccessGrantsPermissions(**DEFAULT_USER_PERMISSIONS.get('access_grants', {})),
446 'chat': ChatPermissions(**DEFAULT_USER_PERMISSIONS.get('chat', {})),
447 'features': FeaturesPermissions(**DEFAULT_USER_PERMISSIONS.get('features', {})),
448 'settings': SettingsPermissions(**DEFAULT_USER_PERMISSIONS.get('settings', {})),
449 }
452############################
453# GetUserSettingsBySessionUser
454############################
457@router.get('/user/settings', response_model=UserSettings | None)
458async def get_user_settings_by_session_user(
459 raw: bool = False,
460 user=Depends(get_verified_user),
461):
462 # user already fetched by get_verified_user — no need to refetch
463 if raw:
464 return user.settings
466 default_interface_settings = await Config.get('ui.default_interface_settings')
467 if not isinstance(default_interface_settings, dict) or not default_interface_settings: 467 ↛ 470line 467 didn't jump to line 470 because the condition on line 467 was always true
468 return user.settings
470 user_settings = user.settings.model_dump() if isinstance(user.settings, UserSettings) else dict(user.settings or {})
471 ui_settings = user_settings.get('ui') if isinstance(user_settings.get('ui'), dict) else {}
472 user_settings['ui'] = merge_user_ui_settings(default_interface_settings, ui_settings)
474 return UserSettings.model_validate(user_settings)
477############################
478# UpdateUserSettingsBySessionUser
479############################
482@router.post('/user/settings/update', response_model=UserSettings)
483async def update_user_settings_by_session_user(
484 request: Request,
485 form_data: UserSettings,
486 user=Depends(get_verified_user),
487 db: AsyncSession = Depends(get_async_session),
488):
489 updated_user_settings = form_data.model_dump(exclude_unset=True)
490 ui_settings = updated_user_settings.get('ui')
492 if isinstance(ui_settings, dict):
493 if user.role != 'admin' and not await has_permission( 493 ↛ 497line 493 didn't jump to line 497 because the condition on line 493 was never true
494 user.id, 'settings.interface', await Config.get('user.permissions'), db=db
495 ):
496 # Omitted fields are unchanged, so unauthorized Interface fields can be discarded.
497 for key in InterfaceSettings.model_fields:
498 ui_settings.pop(key, None)
500 if ( 500 ↛ 508line 500 didn't jump to line 508 because the condition on line 500 was never true
501 user.role != 'admin'
502 and 'system' in ui_settings
503 and (
504 not await has_permission(user.id, 'chat.controls', await Config.get('user.permissions'), db=db)
505 or not await has_permission(user.id, 'chat.system_prompt', await Config.get('user.permissions'), db=db)
506 )
507 ):
508 ui_settings.pop('system', None)
510 if ( 510 ↛ 518line 510 didn't jump to line 518 because the condition on line 510 was never true
511 user.role != 'admin'
512 and 'params' in ui_settings
513 and (
514 not await has_permission(user.id, 'chat.controls', await Config.get('user.permissions'), db=db)
515 or not await has_permission(user.id, 'chat.params', await Config.get('user.permissions'), db=db)
516 )
517 ):
518 ui_settings.pop('params', None)
520 if ( 520 ↛ 530line 520 didn't jump to line 530 because the condition on line 520 was never true
521 user.role != 'admin'
522 and ui_settings is not None
523 and 'toolServers' in ui_settings
524 and not await has_permission(
525 user.id,
526 'features.direct_tool_servers',
527 await Config.get('user.permissions'),
528 )
529 ):
530 ui_settings.pop('toolServers', None)
532 ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
533 if ( 533 ↛ 545line 533 didn't jump to line 545 because the condition on line 533 was never true
534 user.role != 'admin'
535 and (
536 'notifications' in updated_user_settings
537 or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
538 )
539 and not await has_permission(
540 user.id,
541 'features.webhooks',
542 await Config.get('user.permissions'),
543 )
544 ):
545 updated_user_settings.pop('notifications', None)
546 if isinstance(ui_notifications, dict):
547 ui_notifications.pop('webhook_url', None)
549 user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
550 if user: 550 ↛ 559line 550 didn't jump to line 559 because the condition on line 550 was always true
551 await publish_event(
552 request,
553 EVENTS.USER_SETTINGS_UPDATED,
554 actor=user,
555 subject_id=user.id,
556 )
557 return user.settings
558 else:
559 raise HTTPException(
560 status_code=status.HTTP_400_BAD_REQUEST,
561 detail=ERROR_MESSAGES.USER_NOT_FOUND,
562 )
565############################
566# GetUserStatusBySessionUser
567############################
570@router.get('/user/status')
571async def get_user_status_by_session_user(
572 request: Request,
573 user=Depends(get_verified_user),
574):
575 if not await Config.get('users.enable_status'): 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true
576 raise HTTPException(
577 status_code=status.HTTP_403_FORBIDDEN,
578 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
579 )
580 # user already fetched by get_verified_user — no need to refetch
581 return user
584############################
585# UpdateUserStatusBySessionUser
586############################
589@router.post('/user/status/update')
590async def update_user_status_by_session_user(
591 request: Request,
592 form_data: UserStatus,
593 user=Depends(get_verified_user),
594 db: AsyncSession = Depends(get_async_session),
595):
596 if not await Config.get('users.enable_status'): 596 ↛ 597line 596 didn't jump to line 597 because the condition on line 596 was never true
597 raise HTTPException(
598 status_code=status.HTTP_403_FORBIDDEN,
599 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
600 )
601 # user already fetched by get_verified_user — no need to refetch
602 updated = await Users.update_user_status_by_id(user.id, form_data, db=db)
603 if updated: 603 ↛ 611line 603 didn't jump to line 611 because the condition on line 603 was always true
604 await publish_event(
605 request,
606 EVENTS.USER_STATUS_UPDATED,
607 actor=user,
608 subject_id=user.id,
609 )
610 return updated
611 raise HTTPException(
612 status_code=status.HTTP_400_BAD_REQUEST,
613 detail=ERROR_MESSAGES.USER_NOT_FOUND,
614 )
617############################
618# GetUserInfoBySessionUser
619############################
622@router.get('/user/info', response_model=dict | None)
623async def get_user_info_by_session_user(user=Depends(get_verified_user)):
624 # user already fetched by get_verified_user — no need to refetch
625 return user.info
628class UserVariablesForm(BaseModel):
629 variables: dict = Field(default_factory=dict)
632class UserVariablesResponse(BaseModel):
633 variables: dict[str, str] = Field(default_factory=dict)
636############################
637# GetUserVariablesBySessionUser
638############################
641@router.get('/user/variables', response_model=UserVariablesResponse)
642async def get_user_variables_by_session_user(user=Depends(get_verified_user)):
643 return UserVariablesResponse(variables=normalize_user_variables(user.variables))
646############################
647# UpdateUserVariablesBySessionUser
648############################
651@router.post('/user/variables/update', response_model=UserVariablesResponse)
652async def update_user_variables_by_session_user(
653 form_data: UserVariablesForm,
654 user=Depends(get_verified_user),
655 db: AsyncSession = Depends(get_async_session),
656):
657 try:
658 variables = validate_user_variables(form_data.variables)
659 except ChatVariablesError as exc:
660 raise HTTPException(
661 status_code=status.HTTP_400_BAD_REQUEST,
662 detail=str(exc),
663 )
665 updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db)
666 if not updated: 666 ↛ 667line 666 didn't jump to line 667 because the condition on line 666 was never true
667 raise HTTPException(
668 status_code=status.HTTP_400_BAD_REQUEST,
669 detail=ERROR_MESSAGES.USER_NOT_FOUND,
670 )
671 return UserVariablesResponse(variables=variables)
674############################
675# UpdateUserInfoBySessionUser
676############################
679@router.post('/user/info/update', response_model=dict | None)
680async def update_user_info_by_session_user( # PATCH-style merge
681 form_data: dict,
682 user=Depends(get_verified_user),
683 db: AsyncSession = Depends(get_async_session),
684):
685 """Merge caller-supplied fields into the current user's info dict.
687 Uses the auth-time snapshot of ``user.info`` as the merge base. This does
688 NOT eliminate lost-update races on concurrent same-user writes; real safety
689 would need row locking or an optimistic-concurrency version column.
690 """
691 merged_info = {**(user.info or {}), **form_data}
692 updated = await Users.update_user_by_id(user.id, {'info': merged_info}, db=db)
693 if not updated: 693 ↛ 694line 693 didn't jump to line 694 because the condition on line 693 was never true
694 raise HTTPException(
695 status_code=status.HTTP_400_BAD_REQUEST,
696 detail=ERROR_MESSAGES.USER_NOT_FOUND,
697 )
698 return updated.info
701############################
702# GetUserUsageBySessionUser
703############################
706@router.get('/usage', response_model=UserUsageResponse)
707async def get_user_usage_by_session_user(
708 days: Optional[int] = Query(None, ge=7, le=732),
709 start_date: Optional[int] = Query(None),
710 end_date: Optional[int] = Query(None),
711 user=Depends(get_verified_user),
712 db: AsyncSession = Depends(get_async_session),
713):
714 now = int(time.time())
715 period_end = end_date or now
716 if start_date is not None:
717 period_start = start_date
718 else:
719 days = days or 730
720 period_start = period_end - ((days - 1) * 86400)
722 if period_start > period_end:
723 raise HTTPException(
724 status_code=status.HTTP_400_BAD_REQUEST,
725 detail='start_date must be before end_date',
726 )
728 period_days = max(1, int((period_end - period_start) / 86400) + 1)
730 lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db)
731 period_summary = await ChatMessages.get_user_usage_summary(
732 user.id, period_start, period_end, timezone=user.timezone, db=db
733 )
734 chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db)
735 heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db)
736 top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db)
737 top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db)
739 streaks = _calculate_streaks(heatmap)
740 total_messages = period_summary.get('messages', 0)
741 total_chats = chat_stats.get('total_chats', 0)
742 active_days = period_summary.get('active_days', 0)
743 assistant_messages = period_summary.get('assistant_messages', 0)
744 user_messages = period_summary.get('user_messages', 0)
746 return UserUsageResponse(
747 totals=UserUsageTotals(
748 lifetime_tokens=lifetime_summary.get('total_tokens', 0),
749 input_tokens=lifetime_summary.get('input_tokens', 0),
750 output_tokens=lifetime_summary.get('output_tokens', 0),
751 peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0),
752 longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0),
753 current_streak=streaks['current'],
754 longest_streak=streaks['longest'],
755 total_chats=total_chats,
756 active_days=active_days,
757 models_used=lifetime_summary.get('models_used', 0),
758 messages=total_messages,
759 user_messages=user_messages,
760 assistant_messages=assistant_messages,
761 ),
762 heatmap=heatmap,
763 weekly_heatmap=_build_weekly_heatmap(heatmap),
764 cumulative_heatmap=_build_cumulative_heatmap(heatmap),
765 insights=UserUsageInsights(
766 most_used_model=top_models[0]['model_id'] if top_models else None,
767 average_tokens_per_chat=(
768 round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0
769 ),
770 average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0,
771 user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0,
772 assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0,
773 ),
774 top_models=top_models,
775 top_tools=top_tools,
776 period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days),
777 )
780############################
781# GetUserById
782############################
785class UserActiveResponse(UserStatus):
786 name: str
787 profile_image_url: str | None = None
788 groups: list | None = []
790 is_active: bool
791 model_config = ConfigDict(extra='allow')
794@router.get('/{user_id}', response_model=UserActiveResponse)
795async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
797 user = await Users.get_user_by_id(user_id, db=db)
798 if user:
799 groups = await Groups.get_groups_by_member_id(user_id, db=db)
800 return UserActiveResponse(
801 **{
802 **user.model_dump(),
803 'groups': [{'id': group.id, 'name': group.name} for group in groups],
804 'is_active': await Users.is_user_active(user_id, db=db),
805 }
806 )
807 else:
808 raise HTTPException(
809 status_code=status.HTTP_400_BAD_REQUEST,
810 detail=ERROR_MESSAGES.USER_NOT_FOUND,
811 )
814@router.get('/{user_id}/info', response_model=UserInfoResponse)
815async def get_user_info_by_id(
816 user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
817):
818 user = await Users.get_user_by_id(user_id, db=db)
819 if user:
820 groups = await Groups.get_groups_by_member_id(user_id, db=db)
821 return UserInfoResponse(
822 **{
823 **user.model_dump(),
824 'groups': [{'id': group.id, 'name': group.name} for group in groups],
825 'is_active': await Users.is_user_active(user_id, db=db),
826 }
827 )
828 else:
829 raise HTTPException(
830 status_code=status.HTTP_400_BAD_REQUEST,
831 detail=ERROR_MESSAGES.USER_NOT_FOUND,
832 )
835@router.get('/{user_id}/oauth/sessions')
836async def get_user_oauth_sessions_by_id(
837 user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
838):
839 sessions = await OAuthSessions.get_sessions_by_user_id(user_id, db=db)
840 if sessions and len(sessions) > 0: 840 ↛ 841line 840 didn't jump to line 841 because the condition on line 840 was never true
841 return sessions
842 else:
843 raise HTTPException(
844 status_code=status.HTTP_400_BAD_REQUEST,
845 detail=ERROR_MESSAGES.USER_NOT_FOUND,
846 )
849############################
850# GetUserProfileImageById
851############################
854@router.get('/{user_id}/profile/image')
855async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
856 user = await Users.get_user_by_id(user_id)
857 if user:
858 if user.profile_image_url: 858 ↛ 888line 858 didn't jump to line 888 because the condition on line 858 was always true
859 if user.profile_image_url.startswith('http'): 859 ↛ 860line 859 didn't jump to line 860 because the condition on line 859 was never true
860 if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
861 return Response(
862 status_code=status.HTTP_302_FOUND,
863 headers={'Location': user.profile_image_url},
864 )
865 # When forwarding is disabled, fall through to the
866 # default image to prevent client-side IP/UA/Referer
867 # leaks via 302 redirect to external origins.
868 elif user.profile_image_url.startswith('data:image'): 868 ↛ 869line 868 didn't jump to line 869 because the condition on line 868 was never true
869 try:
870 header, base64_data = user.profile_image_url.split(',', 1)
871 image_data = base64.b64decode(base64_data)
872 image_buffer = io.BytesIO(image_data)
873 media_type = header.split(';')[0].lstrip('data:').lower()
875 if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
876 return FileResponse(f'{STATIC_DIR}/user.png')
878 return StreamingResponse(
879 image_buffer,
880 media_type=media_type,
881 headers={
882 'Content-Disposition': 'inline',
883 'X-Content-Type-Options': 'nosniff',
884 },
885 )
886 except Exception as e:
887 pass
888 return FileResponse(f'{STATIC_DIR}/user.png')
889 else:
890 raise HTTPException(
891 status_code=status.HTTP_400_BAD_REQUEST,
892 detail=ERROR_MESSAGES.USER_NOT_FOUND,
893 )
896############################
897# GetUserActiveStatusById
898############################
901@router.get('/{user_id}/active', response_model=dict)
902async def get_user_active_status_by_id(
903 user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
904):
905 return {
906 'active': await Users.is_user_active(user_id, db=db),
907 }
910############################
911# UpdateUserById
912############################
915@router.post('/{user_id}/update', response_model=UserModel | None)
916async def update_user_by_id(
917 request: Request,
918 user_id: str,
919 form_data: UserUpdateForm,
920 session_user: UserModel = Depends(get_admin_user),
921 db: AsyncSession = Depends(get_async_session),
922):
923 # Prevent modification of the primary admin user by other admins
924 try:
925 first_user = await Users.get_first_user(db=db)
926 if first_user:
927 if user_id == first_user.id:
928 if session_user.id != user_id:
929 # If the user trying to update is the primary admin, and they are not the primary admin themselves
930 raise HTTPException(
931 status_code=status.HTTP_403_FORBIDDEN,
932 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
933 )
935 if form_data.role is not None and form_data.role != 'admin':
936 # If the primary admin is trying to change their own role, prevent it
937 raise HTTPException(
938 status_code=status.HTTP_403_FORBIDDEN,
939 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
940 )
942 except HTTPException:
943 raise
944 except Exception as e:
945 log.error(f'Error checking primary admin status: {e}')
946 raise HTTPException(
947 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
948 detail='Could not verify primary admin status.',
949 )
951 user = await Users.get_user_by_id(user_id, db=db)
953 if user:
954 if form_data.email is not None and form_data.email.lower() != user.email:
955 email_user = await Users.get_user_by_email(form_data.email.lower(), db=db)
956 if email_user:
957 raise HTTPException(
958 status_code=status.HTTP_400_BAD_REQUEST,
959 detail=ERROR_MESSAGES.EMAIL_TAKEN,
960 )
962 if form_data.password:
963 try:
964 validate_password(form_data.password)
965 except Exception as e:
966 raise HTTPException(400, detail=str(e))
968 hashed = await get_password_hash(form_data.password)
969 if await Auths.update_user_password_by_id(user_id, hashed, db=db):
970 await revoke_user_tokens(request, user_id)
972 # Build update dict from only the provided fields
973 update_data = {}
974 if form_data.role is not None:
975 update_data['role'] = form_data.role
976 if form_data.name is not None:
977 update_data['name'] = form_data.name
978 if form_data.email is not None:
979 update_data['email'] = form_data.email.lower()
980 await Auths.update_email_by_id(user_id, form_data.email.lower(), db=db)
981 if form_data.profile_image_url is not None:
982 update_data['profile_image_url'] = form_data.profile_image_url
984 if update_data:
985 updated_user = await Users.update_user_by_id(
986 user_id,
987 update_data,
988 db=db,
989 )
990 else:
991 updated_user = user
993 if updated_user: 993 ↛ anywhereline 993 didn't jump anywhere: it always raised an exception.
994 updated_fields = [field for field in update_data.keys() if field != 'role']
995 role_changed = updated_user.role != user.role
997 if updated_fields:
998 await publish_event(
999 request,
1000 EVENTS.USER_UPDATED,
1001 actor=session_user,
1002 subject_id=user_id,
1003 data={'updated_fields': updated_fields},
1004 )
1006 if role_changed:
1007 await publish_event(
1008 request,
1009 EVENTS.USER_ROLE_UPDATED,
1010 actor=session_user,
1011 subject_id=user_id,
1012 data={'role': updated_user.role},
1013 )
1015 if form_data.password:
1016 await publish_event(
1017 request,
1018 EVENTS.AUTH_PASSWORD_CHANGED,
1019 actor=session_user,
1020 subject_id=user_id,
1021 subject_type='user',
1022 source='admin',
1023 )
1024 return updated_user
1026 raise HTTPException(
1027 status_code=status.HTTP_400_BAD_REQUEST,
1028 detail=ERROR_MESSAGES.DEFAULT(),
1029 )
1031 raise HTTPException(
1032 status_code=status.HTTP_400_BAD_REQUEST,
1033 detail=ERROR_MESSAGES.USER_NOT_FOUND,
1034 )
1037############################
1038# DeleteUserById
1039############################
1042@router.delete('/{user_id}', response_model=bool)
1043async def delete_user_by_id(
1044 request: Request, user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
1045):
1046 # Prevent deletion of the primary admin user
1047 try:
1048 first_user = await Users.get_first_user(db=db)
1049 if first_user and user_id == first_user.id: 1049 ↛ 1050line 1049 didn't jump to line 1050 because the condition on line 1049 was never true
1050 raise HTTPException(
1051 status_code=status.HTTP_403_FORBIDDEN,
1052 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
1053 )
1054 except HTTPException:
1055 raise
1056 except Exception as e:
1057 log.error(f'Error checking primary admin status: {e}')
1058 raise HTTPException(
1059 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1060 detail='Could not verify primary admin status.',
1061 )
1063 if user.id != user_id: 1063 ↛ 1081line 1063 didn't jump to line 1081 because the condition on line 1063 was always true
1064 result = await Auths.delete_auth_by_id(user_id, db=db)
1066 if result: 1066 ↛ 1075line 1066 didn't jump to line 1075 because the condition on line 1066 was always true
1067 await publish_event(
1068 request,
1069 EVENTS.USER_DELETED,
1070 actor=user,
1071 subject_id=user_id,
1072 )
1073 return True
1075 raise HTTPException(
1076 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1077 detail=ERROR_MESSAGES.DELETE_USER_ERROR,
1078 )
1080 # Prevent self-deletion
1081 raise HTTPException(
1082 status_code=status.HTTP_403_FORBIDDEN,
1083 detail=ERROR_MESSAGES.ACTION_PROHIBITED,
1084 )
1087############################
1088# GetUserGroupsById
1089############################
1092@router.get('/{user_id}/groups')
1093async def get_user_groups_by_id(
1094 user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
1095):
1096 return await Groups.get_groups_by_member_id(user_id, db=db)
1099############################
1100# GetUserPreview
1101############################
1104@router.get('/{user_id}/preview')
1105async def get_user_preview(
1106 user_id: str,
1107 user=Depends(get_admin_user),
1108 db: AsyncSession = Depends(get_async_session),
1109):
1110 """Show what resources a specific user can access across all their groups."""
1111 target_user = await Users.get_user_by_id(user_id, db=db)
1112 if not target_user:
1113 raise HTTPException(
1114 status_code=status.HTTP_404_NOT_FOUND,
1115 detail=ERROR_MESSAGES.USER_NOT_FOUND,
1116 )
1118 # Get all group IDs this user belongs to
1119 user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
1120 user_group_ids = {g.id for g in user_groups}
1122 all_models = await Models.get_all_models(db=db)
1123 active_models = [m for m in all_models if m.is_active]
1124 owned_model_ids = {m.id for m in active_models if m.user_id == user_id}
1125 granted_model_ids = await AccessGrants.get_accessible_resource_ids(
1126 user_id=user_id,
1127 resource_type='model',
1128 resource_ids=[m.id for m in active_models if m.user_id != user_id],
1129 permission='read',
1130 user_group_ids=user_group_ids,
1131 db=db,
1132 )
1133 accessible_model_ids = owned_model_ids | granted_model_ids
1135 all_knowledge = await Knowledges.get_knowledge_bases(db=db)
1136 owned_knowledge_ids = {k.id for k in all_knowledge if k.user_id == user_id}
1137 granted_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
1138 user_id=user_id,
1139 resource_type='knowledge',
1140 resource_ids=[k.id for k in all_knowledge if k.user_id != user_id],
1141 permission='read',
1142 user_group_ids=user_group_ids,
1143 db=db,
1144 )
1145 accessible_knowledge_ids = owned_knowledge_ids | granted_knowledge_ids
1147 all_tools = await Tools.get_tools(defer_content=True, db=db)
1148 owned_tool_ids = {t.id for t in all_tools if t.user_id == user_id}
1149 granted_tool_ids = await AccessGrants.get_accessible_resource_ids(
1150 user_id=user_id,
1151 resource_type='tool',
1152 resource_ids=[t.id for t in all_tools if t.user_id != user_id],
1153 permission='read',
1154 user_group_ids=user_group_ids,
1155 db=db,
1156 )
1157 accessible_tool_ids = owned_tool_ids | granted_tool_ids
1159 return {
1160 'user': {'id': target_user.id, 'name': target_user.name},
1161 'groups': [{'id': g.id, 'name': g.name} for g in user_groups],
1162 'models': {
1163 'items': [{'id': m.id, 'name': m.name} for m in active_models if m.id in accessible_model_ids],
1164 'total': len(active_models),
1165 },
1166 'knowledge': {
1167 'items': [{'id': k.id, 'name': k.name} for k in all_knowledge if k.id in accessible_knowledge_ids],
1168 'total': len(all_knowledge),
1169 },
1170 'tools': {
1171 'items': [{'id': t.id, 'name': t.name} for t in all_tools if t.id in accessible_tool_ids],
1172 'total': len(all_tools),
1173 },
1174 }