mirror of
https://github.com/open-webui/open-webui.git
synced 2026-07-31 21:20:52 -05:00
1124 lines
36 KiB
Python
1124 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import logging
|
|
import time
|
|
from collections import Counter
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
|
from open_webui.constants import ERROR_MESSAGES
|
|
from open_webui.events import EVENTS, publish_event
|
|
from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_ALLOWED_MIME_TYPES, STATIC_DIR
|
|
from open_webui.internal.db import get_async_session
|
|
from open_webui.models.auths import Auths
|
|
from open_webui.models.config import Config
|
|
from open_webui.models.chat_messages import ChatMessages
|
|
from open_webui.models.chats import Chats
|
|
from open_webui.models.groups import Groups
|
|
from open_webui.models.oauth_sessions import OAuthSessions
|
|
from open_webui.models.users import (
|
|
UserGroupIdsListResponse,
|
|
UserGroupIdsModel,
|
|
UserInfoListResponse,
|
|
UserInfoResponse,
|
|
UserModel,
|
|
UserRoleUpdateForm,
|
|
Users,
|
|
UserSettings,
|
|
UserStatus,
|
|
UserUpdateForm,
|
|
)
|
|
from open_webui.models.access_grants import AccessGrants
|
|
from open_webui.models.knowledge import Knowledges
|
|
from open_webui.models.models import Models
|
|
from open_webui.models.tools import Tools
|
|
from open_webui.socket.main import disconnect_user_sessions
|
|
from open_webui.utils.access_control import get_permissions, has_permission
|
|
from open_webui.utils.auth import (
|
|
get_admin_user,
|
|
get_password_hash,
|
|
get_verified_user,
|
|
validate_password,
|
|
)
|
|
from open_webui.utils.chat_variables import ChatVariablesError, normalize_user_variables, validate_user_variables
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
############################
|
|
# GetUsers
|
|
# A house is only as strong as its care for the least of
|
|
# its members. Let none here be counted without being served.
|
|
############################
|
|
|
|
|
|
PAGE_ITEM_COUNT = 30
|
|
|
|
|
|
@router.get('/', response_model=UserGroupIdsListResponse)
|
|
async def get_users(
|
|
query: str | None = None,
|
|
order_by: str | None = None,
|
|
direction: str | None = None,
|
|
page: int | None = 1,
|
|
user=Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
limit = PAGE_ITEM_COUNT
|
|
|
|
page = max(1, page)
|
|
skip = (page - 1) * limit
|
|
|
|
filter = {}
|
|
if query:
|
|
filter['query'] = query
|
|
if order_by:
|
|
filter['order_by'] = order_by
|
|
if direction:
|
|
filter['direction'] = direction
|
|
|
|
filter['direction'] = direction
|
|
|
|
result = await Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
|
|
|
|
users = result['users']
|
|
total = result['total']
|
|
|
|
# Fetch groups for all users in a single query to avoid N+1
|
|
user_ids = [user.id for user in users]
|
|
user_groups = await Groups.get_groups_by_member_ids(user_ids, db=db)
|
|
|
|
return {
|
|
'users': [
|
|
UserGroupIdsModel(
|
|
**{
|
|
**user.model_dump(),
|
|
'group_ids': [group.id for group in user_groups.get(user.id, [])],
|
|
}
|
|
)
|
|
for user in users
|
|
],
|
|
'total': total,
|
|
}
|
|
|
|
|
|
@router.get('/all', response_model=UserInfoListResponse)
|
|
async def get_all_users(
|
|
user=Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
return await Users.get_users(db=db)
|
|
|
|
|
|
@router.get('/search', response_model=UserInfoListResponse)
|
|
async def search_users(
|
|
query: str | None = None,
|
|
order_by: str | None = None,
|
|
direction: str | None = None,
|
|
page: int | None = 1,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
limit = PAGE_ITEM_COUNT
|
|
|
|
page = max(1, page)
|
|
skip = (page - 1) * limit
|
|
|
|
filter = {}
|
|
if query:
|
|
filter['query'] = query
|
|
if order_by:
|
|
filter['order_by'] = order_by
|
|
if direction:
|
|
filter['direction'] = direction
|
|
|
|
return await Users.get_users(filter=filter, skip=skip, limit=limit, db=db)
|
|
|
|
|
|
############################
|
|
# User Groups
|
|
############################
|
|
|
|
|
|
@router.get('/groups')
|
|
async def get_user_groups(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
|
return await Groups.get_groups_by_member_id(user.id, db=db)
|
|
|
|
|
|
############################
|
|
# User Permissions
|
|
############################
|
|
|
|
|
|
@router.get('/permissions')
|
|
async def get_user_permissisions(
|
|
request: Request,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
user_permissions = await get_permissions(user.id, await Config.get('user.permissions'), db=db)
|
|
|
|
return user_permissions
|
|
|
|
|
|
############################
|
|
# User Default Permissions
|
|
############################
|
|
class WorkspacePermissions(BaseModel):
|
|
models: bool = False
|
|
knowledge: bool = False
|
|
prompts: bool = False
|
|
tools: bool = False
|
|
skills: bool = False
|
|
models_import: bool = False
|
|
models_export: bool = False
|
|
prompts_import: bool = False
|
|
prompts_export: bool = False
|
|
tools_import: bool = False
|
|
tools_export: bool = False
|
|
skills_import: bool = False
|
|
skills_export: bool = False
|
|
|
|
|
|
class SharingPermissions(BaseModel):
|
|
models: bool = False
|
|
public_models: bool = False
|
|
knowledge: bool = False
|
|
public_knowledge: bool = False
|
|
prompts: bool = False
|
|
public_prompts: bool = False
|
|
tools: bool = False
|
|
public_tools: bool = True
|
|
skills: bool = False
|
|
public_skills: bool = False
|
|
notes: bool = False
|
|
public_notes: bool = True
|
|
folders: bool = False
|
|
public_chats: bool = False
|
|
public_calendars: bool = False
|
|
|
|
|
|
class AccessGrantsPermissions(BaseModel):
|
|
allow_users: bool = True
|
|
allow_groups: bool = True
|
|
|
|
|
|
class ChatPermissions(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
controls: bool = True
|
|
valves: bool = True
|
|
system_prompt: bool = True
|
|
params: bool = True
|
|
file_upload: bool = True
|
|
web_upload: bool = True
|
|
delete: bool = True
|
|
delete_message: bool = True
|
|
continue_response: bool = True
|
|
regenerate_response: bool = True
|
|
rate_response: bool = True
|
|
edit: bool = True
|
|
share: bool = True
|
|
export: bool = True
|
|
import_: bool = Field(default=True, alias='import')
|
|
stt: bool = True
|
|
tts: bool = True
|
|
call: bool = True
|
|
multiple_models: bool = True
|
|
temporary: bool = True
|
|
temporary_enforced: bool = False
|
|
|
|
|
|
class FeaturesPermissions(BaseModel):
|
|
api_keys: bool = False
|
|
notes: bool = True
|
|
channels: bool = True
|
|
folders: bool = True
|
|
direct_tool_servers: bool = False
|
|
|
|
web_search: bool = True
|
|
image_generation: bool = True
|
|
code_interpreter: bool = True
|
|
memories: bool = True
|
|
automations: bool = False
|
|
calendar: bool = True
|
|
webhooks: bool = False
|
|
|
|
|
|
class SettingsPermissions(BaseModel):
|
|
interface: bool = True
|
|
|
|
|
|
class UserPermissions(BaseModel):
|
|
workspace: WorkspacePermissions
|
|
sharing: SharingPermissions
|
|
access_grants: AccessGrantsPermissions
|
|
chat: ChatPermissions
|
|
features: FeaturesPermissions
|
|
settings: SettingsPermissions
|
|
|
|
|
|
class UserUsageTotals(BaseModel):
|
|
lifetime_tokens: int = 0
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
peak_daily_tokens: int = 0
|
|
longest_chat_seconds: int = 0
|
|
current_streak: int = 0
|
|
longest_streak: int = 0
|
|
total_chats: int = 0
|
|
active_days: int = 0
|
|
models_used: int = 0
|
|
messages: int = 0
|
|
user_messages: int = 0
|
|
assistant_messages: int = 0
|
|
|
|
|
|
class UserUsageHeatmapEntry(BaseModel):
|
|
date: str
|
|
messages: int = 0
|
|
chats: int = 0
|
|
tokens: int = 0
|
|
models: dict[str, int] = Field(default_factory=dict)
|
|
|
|
|
|
class UserUsageModelEntry(BaseModel):
|
|
model_id: str
|
|
messages: int = 0
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
total_tokens: int = 0
|
|
|
|
|
|
class UserUsageToolEntry(BaseModel):
|
|
name: str
|
|
count: int
|
|
|
|
|
|
class UserUsageInsights(BaseModel):
|
|
most_used_model: Optional[str] = None
|
|
average_tokens_per_chat: float = 0
|
|
average_messages_per_active_day: float = 0
|
|
user_message_share: float = 0
|
|
assistant_message_share: float = 0
|
|
|
|
|
|
class UserUsagePeriod(BaseModel):
|
|
start_date: int
|
|
end_date: int
|
|
days: int
|
|
|
|
|
|
class UserUsageResponse(BaseModel):
|
|
totals: UserUsageTotals
|
|
heatmap: list[UserUsageHeatmapEntry]
|
|
weekly_heatmap: list[UserUsageHeatmapEntry]
|
|
cumulative_heatmap: list[UserUsageHeatmapEntry]
|
|
insights: UserUsageInsights
|
|
top_models: list[UserUsageModelEntry]
|
|
top_tools: list[UserUsageToolEntry] = []
|
|
period: UserUsagePeriod
|
|
|
|
|
|
def _week_start(date: datetime) -> datetime:
|
|
return date - timedelta(days=date.weekday())
|
|
|
|
|
|
def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]:
|
|
weeks: dict[str, dict] = {}
|
|
for day in heatmap:
|
|
week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d')
|
|
entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()})
|
|
entry['messages'] += day.get('messages', 0)
|
|
entry['chats'] += day.get('chats', 0)
|
|
entry['tokens'] += day.get('tokens', 0)
|
|
entry['models'].update(day.get('models', {}))
|
|
|
|
return [
|
|
{
|
|
**weeks[key],
|
|
'models': dict(weeks[key]['models']),
|
|
}
|
|
for key in sorted(weeks)
|
|
]
|
|
|
|
|
|
def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]:
|
|
totals = {'messages': 0, 'chats': 0, 'tokens': 0}
|
|
models: Counter[str] = Counter()
|
|
cumulative = []
|
|
for day in heatmap:
|
|
totals['messages'] += day.get('messages', 0)
|
|
totals['chats'] += day.get('chats', 0)
|
|
totals['tokens'] += day.get('tokens', 0)
|
|
models.update(day.get('models', {}))
|
|
cumulative.append(
|
|
{
|
|
'date': day['date'],
|
|
**totals,
|
|
'models': dict(models),
|
|
}
|
|
)
|
|
return cumulative
|
|
|
|
|
|
def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
|
|
longest = 0
|
|
current_run = 0
|
|
for day in heatmap:
|
|
if day.get('messages', 0) > 0:
|
|
current_run += 1
|
|
longest = max(longest, current_run)
|
|
else:
|
|
current_run = 0
|
|
|
|
current = 0
|
|
for day in reversed(heatmap):
|
|
if day.get('messages', 0) <= 0:
|
|
break
|
|
current += 1
|
|
|
|
return {'current': current, 'longest': longest}
|
|
|
|
|
|
@router.get('/default/permissions', response_model=UserPermissions)
|
|
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
|
|
user_permissions = await Config.get('user.permissions')
|
|
return {
|
|
'workspace': WorkspacePermissions(**user_permissions.get('workspace', {})),
|
|
'sharing': SharingPermissions(**user_permissions.get('sharing', {})),
|
|
'access_grants': AccessGrantsPermissions(**user_permissions.get('access_grants', {})),
|
|
'chat': ChatPermissions(**user_permissions.get('chat', {})),
|
|
'features': FeaturesPermissions(**user_permissions.get('features', {})),
|
|
'settings': SettingsPermissions(**user_permissions.get('settings', {})),
|
|
}
|
|
|
|
|
|
@router.post('/default/permissions')
|
|
async def update_default_user_permissions(request: Request, form_data: UserPermissions, user=Depends(get_admin_user)):
|
|
user_permissions = form_data.model_dump(by_alias=True)
|
|
await Config.upsert({'user.permissions': user_permissions})
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_PERMISSIONS_UPDATED,
|
|
actor=user,
|
|
subject_id='user.permissions',
|
|
subject_type='config',
|
|
)
|
|
return user_permissions
|
|
|
|
|
|
@router.get('/default/permissions/defaults', response_model=UserPermissions)
|
|
async def get_default_user_permissions_defaults(user=Depends(get_admin_user)):
|
|
from open_webui.config import DEFAULT_USER_PERMISSIONS
|
|
|
|
return {
|
|
'workspace': WorkspacePermissions(**DEFAULT_USER_PERMISSIONS.get('workspace', {})),
|
|
'sharing': SharingPermissions(**DEFAULT_USER_PERMISSIONS.get('sharing', {})),
|
|
'access_grants': AccessGrantsPermissions(**DEFAULT_USER_PERMISSIONS.get('access_grants', {})),
|
|
'chat': ChatPermissions(**DEFAULT_USER_PERMISSIONS.get('chat', {})),
|
|
'features': FeaturesPermissions(**DEFAULT_USER_PERMISSIONS.get('features', {})),
|
|
'settings': SettingsPermissions(**DEFAULT_USER_PERMISSIONS.get('settings', {})),
|
|
}
|
|
|
|
|
|
############################
|
|
# GetUserSettingsBySessionUser
|
|
############################
|
|
|
|
|
|
@router.get('/user/settings', response_model=UserSettings | None)
|
|
async def get_user_settings_by_session_user(
|
|
user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
# user already fetched by get_verified_user — no need to refetch
|
|
return user.settings
|
|
|
|
|
|
############################
|
|
# UpdateUserSettingsBySessionUser
|
|
############################
|
|
|
|
|
|
@router.post('/user/settings/update', response_model=UserSettings)
|
|
async def update_user_settings_by_session_user(
|
|
request: Request,
|
|
form_data: UserSettings,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if user.role != 'admin' and not await has_permission(
|
|
user.id, 'settings.interface', await Config.get('user.permissions')
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
|
)
|
|
|
|
updated_user_settings = form_data.model_dump()
|
|
ui_settings = updated_user_settings.get('ui')
|
|
if (
|
|
user.role != 'admin'
|
|
and ui_settings is not None
|
|
and 'toolServers' in ui_settings.keys()
|
|
and not await has_permission(
|
|
user.id,
|
|
'features.direct_tool_servers',
|
|
await Config.get('user.permissions'),
|
|
)
|
|
):
|
|
# If the user is not an admin and does not have permission to use tool servers, remove the key
|
|
updated_user_settings['ui'].pop('toolServers', None)
|
|
|
|
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
|
|
if (
|
|
user.role != 'admin'
|
|
and (
|
|
'notifications' in updated_user_settings
|
|
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
|
|
)
|
|
and not await has_permission(
|
|
user.id,
|
|
'features.webhooks',
|
|
await Config.get('user.permissions'),
|
|
)
|
|
):
|
|
updated_user_settings.pop('notifications', None)
|
|
if isinstance(ui_notifications, dict):
|
|
ui_notifications.pop('webhook_url', None)
|
|
|
|
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
|
|
if user:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_SETTINGS_UPDATED,
|
|
actor=user,
|
|
subject_id=user.id,
|
|
)
|
|
return user.settings
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
|
|
############################
|
|
# GetUserStatusBySessionUser
|
|
############################
|
|
|
|
|
|
@router.get('/user/status')
|
|
async def get_user_status_by_session_user(
|
|
request: Request,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if not await Config.get('users.enable_status'):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
# user already fetched by get_verified_user — no need to refetch
|
|
return user
|
|
|
|
|
|
############################
|
|
# UpdateUserStatusBySessionUser
|
|
############################
|
|
|
|
|
|
@router.post('/user/status/update')
|
|
async def update_user_status_by_session_user(
|
|
request: Request,
|
|
form_data: UserStatus,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
if not await Config.get('users.enable_status'):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
# user already fetched by get_verified_user — no need to refetch
|
|
updated = await Users.update_user_status_by_id(user.id, form_data, db=db)
|
|
if updated:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_STATUS_UPDATED,
|
|
actor=user,
|
|
subject_id=user.id,
|
|
)
|
|
return updated
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
|
|
############################
|
|
# GetUserInfoBySessionUser
|
|
############################
|
|
|
|
|
|
@router.get('/user/info', response_model=dict | None)
|
|
async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
|
# user already fetched by get_verified_user — no need to refetch
|
|
return user.info
|
|
|
|
|
|
class UserVariablesForm(BaseModel):
|
|
variables: dict = Field(default_factory=dict)
|
|
|
|
|
|
class UserVariablesResponse(BaseModel):
|
|
variables: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
############################
|
|
# GetUserVariablesBySessionUser
|
|
############################
|
|
|
|
|
|
@router.get('/user/variables', response_model=UserVariablesResponse)
|
|
async def get_user_variables_by_session_user(user=Depends(get_verified_user)):
|
|
return UserVariablesResponse(variables=normalize_user_variables(user.variables))
|
|
|
|
|
|
############################
|
|
# UpdateUserVariablesBySessionUser
|
|
############################
|
|
|
|
|
|
@router.post('/user/variables/update', response_model=UserVariablesResponse)
|
|
async def update_user_variables_by_session_user(
|
|
form_data: UserVariablesForm,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
try:
|
|
variables = validate_user_variables(form_data.variables)
|
|
except ChatVariablesError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(exc),
|
|
)
|
|
|
|
updated = await Users.update_user_by_id(user.id, {'variables': variables}, db=db)
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
return UserVariablesResponse(variables=variables)
|
|
|
|
|
|
############################
|
|
# UpdateUserInfoBySessionUser
|
|
############################
|
|
|
|
|
|
@router.post('/user/info/update', response_model=dict | None)
|
|
async def update_user_info_by_session_user( # PATCH-style merge
|
|
form_data: dict,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Merge caller-supplied fields into the current user's info dict.
|
|
|
|
Uses the auth-time snapshot of ``user.info`` as the merge base. This does
|
|
NOT eliminate lost-update races on concurrent same-user writes; real safety
|
|
would need row locking or an optimistic-concurrency version column.
|
|
"""
|
|
merged_info = {**(user.info or {}), **form_data}
|
|
updated = await Users.update_user_by_id(user.id, {'info': merged_info}, db=db)
|
|
if not updated:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
return updated.info
|
|
|
|
|
|
############################
|
|
# GetUserUsageBySessionUser
|
|
############################
|
|
|
|
|
|
@router.get('/usage', response_model=UserUsageResponse)
|
|
async def get_user_usage_by_session_user(
|
|
days: Optional[int] = Query(None, ge=7, le=732),
|
|
start_date: Optional[int] = Query(None),
|
|
end_date: Optional[int] = Query(None),
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
now = int(time.time())
|
|
period_end = end_date or now
|
|
if start_date is not None:
|
|
period_start = start_date
|
|
elif days is not None:
|
|
period_start = period_end - ((days - 1) * 86400)
|
|
else:
|
|
period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400))
|
|
|
|
if period_start > period_end:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='start_date must be before end_date',
|
|
)
|
|
|
|
period_days = max(1, int((period_end - period_start) / 86400) + 1)
|
|
|
|
lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db)
|
|
period_summary = await ChatMessages.get_user_usage_summary(
|
|
user.id, period_start, period_end, timezone=user.timezone, db=db
|
|
)
|
|
chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db)
|
|
heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db)
|
|
top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db)
|
|
top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db)
|
|
|
|
streaks = _calculate_streaks(heatmap)
|
|
total_messages = period_summary.get('messages', 0)
|
|
total_chats = chat_stats.get('total_chats', 0)
|
|
active_days = period_summary.get('active_days', 0)
|
|
assistant_messages = period_summary.get('assistant_messages', 0)
|
|
user_messages = period_summary.get('user_messages', 0)
|
|
|
|
return UserUsageResponse(
|
|
totals=UserUsageTotals(
|
|
lifetime_tokens=lifetime_summary.get('total_tokens', 0),
|
|
input_tokens=lifetime_summary.get('input_tokens', 0),
|
|
output_tokens=lifetime_summary.get('output_tokens', 0),
|
|
peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0),
|
|
longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0),
|
|
current_streak=streaks['current'],
|
|
longest_streak=streaks['longest'],
|
|
total_chats=total_chats,
|
|
active_days=active_days,
|
|
models_used=lifetime_summary.get('models_used', 0),
|
|
messages=total_messages,
|
|
user_messages=user_messages,
|
|
assistant_messages=assistant_messages,
|
|
),
|
|
heatmap=heatmap,
|
|
weekly_heatmap=_build_weekly_heatmap(heatmap),
|
|
cumulative_heatmap=_build_cumulative_heatmap(heatmap),
|
|
insights=UserUsageInsights(
|
|
most_used_model=top_models[0]['model_id'] if top_models else None,
|
|
average_tokens_per_chat=(
|
|
round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0
|
|
),
|
|
average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0,
|
|
user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0,
|
|
assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0,
|
|
),
|
|
top_models=top_models,
|
|
top_tools=top_tools,
|
|
period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days),
|
|
)
|
|
|
|
|
|
############################
|
|
# GetUserById
|
|
############################
|
|
|
|
|
|
class UserActiveResponse(UserStatus):
|
|
name: str
|
|
profile_image_url: str | None = None
|
|
groups: list | None = []
|
|
|
|
is_active: bool
|
|
model_config = ConfigDict(extra='allow')
|
|
|
|
|
|
@router.get('/{user_id}', response_model=UserActiveResponse)
|
|
async def get_user_by_id(user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
|
|
|
user = await Users.get_user_by_id(user_id, db=db)
|
|
if user:
|
|
groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
|
return UserActiveResponse(
|
|
**{
|
|
**user.model_dump(),
|
|
'groups': [{'id': group.id, 'name': group.name} for group in groups],
|
|
'is_active': await Users.is_user_active(user_id, db=db),
|
|
}
|
|
)
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
|
|
@router.get('/{user_id}/info', response_model=UserInfoResponse)
|
|
async def get_user_info_by_id(
|
|
user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
user = await Users.get_user_by_id(user_id, db=db)
|
|
if user:
|
|
groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
|
return UserInfoResponse(
|
|
**{
|
|
**user.model_dump(),
|
|
'groups': [{'id': group.id, 'name': group.name} for group in groups],
|
|
'is_active': await Users.is_user_active(user_id, db=db),
|
|
}
|
|
)
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
|
|
@router.get('/{user_id}/oauth/sessions')
|
|
async def get_user_oauth_sessions_by_id(
|
|
user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
sessions = await OAuthSessions.get_sessions_by_user_id(user_id, db=db)
|
|
if sessions and len(sessions) > 0:
|
|
return sessions
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
|
|
############################
|
|
# GetUserProfileImageById
|
|
############################
|
|
|
|
|
|
@router.get('/{user_id}/profile/image')
|
|
async def get_user_profile_image_by_id(user_id: str, user=Depends(get_verified_user)):
|
|
user = await Users.get_user_by_id(user_id)
|
|
if user:
|
|
if user.profile_image_url:
|
|
if user.profile_image_url.startswith('http'):
|
|
if ENABLE_PROFILE_IMAGE_URL_FORWARDING:
|
|
return Response(
|
|
status_code=status.HTTP_302_FOUND,
|
|
headers={'Location': user.profile_image_url},
|
|
)
|
|
# When forwarding is disabled, fall through to the
|
|
# default image to prevent client-side IP/UA/Referer
|
|
# leaks via 302 redirect to external origins.
|
|
elif user.profile_image_url.startswith('data:image'):
|
|
try:
|
|
header, base64_data = user.profile_image_url.split(',', 1)
|
|
image_data = base64.b64decode(base64_data)
|
|
image_buffer = io.BytesIO(image_data)
|
|
media_type = header.split(';')[0].lstrip('data:').lower()
|
|
|
|
if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES:
|
|
return FileResponse(f'{STATIC_DIR}/user.png')
|
|
|
|
return StreamingResponse(
|
|
image_buffer,
|
|
media_type=media_type,
|
|
headers={
|
|
'Content-Disposition': 'inline',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
},
|
|
)
|
|
except Exception as e:
|
|
pass
|
|
return FileResponse(f'{STATIC_DIR}/user.png')
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
|
|
############################
|
|
# GetUserActiveStatusById
|
|
############################
|
|
|
|
|
|
@router.get('/{user_id}/active', response_model=dict)
|
|
async def get_user_active_status_by_id(
|
|
user_id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
return {
|
|
'active': await Users.is_user_active(user_id, db=db),
|
|
}
|
|
|
|
|
|
############################
|
|
# UpdateUserById
|
|
############################
|
|
|
|
|
|
@router.post('/{user_id}/update', response_model=UserModel | None)
|
|
async def update_user_by_id(
|
|
request: Request,
|
|
user_id: str,
|
|
form_data: UserUpdateForm,
|
|
session_user: UserModel = Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
# Prevent modification of the primary admin user by other admins
|
|
try:
|
|
first_user = await Users.get_first_user(db=db)
|
|
if first_user:
|
|
if user_id == first_user.id:
|
|
if session_user.id != user_id:
|
|
# If the user trying to update is the primary admin, and they are not the primary admin themselves
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
|
|
if form_data.role is not None and form_data.role != 'admin':
|
|
# If the primary admin is trying to change their own role, prevent it
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
log.error(f'Error checking primary admin status: {e}')
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Could not verify primary admin status.',
|
|
)
|
|
|
|
user = await Users.get_user_by_id(user_id, db=db)
|
|
|
|
if user:
|
|
if form_data.email is not None and form_data.email.lower() != user.email:
|
|
email_user = await Users.get_user_by_email(form_data.email.lower(), db=db)
|
|
if email_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.EMAIL_TAKEN,
|
|
)
|
|
|
|
if form_data.password:
|
|
try:
|
|
validate_password(form_data.password)
|
|
except Exception as e:
|
|
raise HTTPException(400, detail=str(e))
|
|
|
|
hashed = await get_password_hash(form_data.password)
|
|
await Auths.update_user_password_by_id(user_id, hashed, db=db)
|
|
|
|
# Build update dict from only the provided fields
|
|
update_data = {}
|
|
if form_data.role is not None:
|
|
update_data['role'] = form_data.role
|
|
if form_data.name is not None:
|
|
update_data['name'] = form_data.name
|
|
if form_data.email is not None:
|
|
update_data['email'] = form_data.email.lower()
|
|
await Auths.update_email_by_id(user_id, form_data.email.lower(), db=db)
|
|
if form_data.profile_image_url is not None:
|
|
update_data['profile_image_url'] = form_data.profile_image_url
|
|
|
|
if update_data:
|
|
updated_user = await Users.update_user_by_id(
|
|
user_id,
|
|
update_data,
|
|
db=db,
|
|
)
|
|
else:
|
|
updated_user = user
|
|
|
|
if updated_user:
|
|
# If the role changed, disconnect all socket sessions so stale
|
|
# privileges cached in SESSION_POOL are invalidated.
|
|
if updated_user.role != user.role:
|
|
await disconnect_user_sessions(user_id)
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_ROLE_UPDATED,
|
|
actor=session_user,
|
|
subject_id=user_id,
|
|
data={'role': updated_user.role},
|
|
)
|
|
else:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_UPDATED,
|
|
actor=session_user,
|
|
subject_id=user_id,
|
|
data={'updated_fields': list(update_data.keys())},
|
|
)
|
|
if form_data.password:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.AUTH_PASSWORD_CHANGED,
|
|
actor=session_user,
|
|
subject_id=user_id,
|
|
subject_type='user',
|
|
source='admin',
|
|
)
|
|
return updated_user
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.DEFAULT(),
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
|
|
############################
|
|
# DeleteUserById
|
|
############################
|
|
|
|
|
|
@router.delete('/{user_id}', response_model=bool)
|
|
async def delete_user_by_id(
|
|
request: Request, user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
# Prevent deletion of the primary admin user
|
|
try:
|
|
first_user = await Users.get_first_user(db=db)
|
|
if first_user and user_id == first_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
log.error(f'Error checking primary admin status: {e}')
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Could not verify primary admin status.',
|
|
)
|
|
|
|
if user.id != user_id:
|
|
result = await Auths.delete_auth_by_id(user_id, db=db)
|
|
|
|
if result:
|
|
await disconnect_user_sessions(user_id)
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_DELETED,
|
|
actor=user,
|
|
subject_id=user_id,
|
|
)
|
|
return True
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=ERROR_MESSAGES.DELETE_USER_ERROR,
|
|
)
|
|
|
|
# Prevent self-deletion
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=ERROR_MESSAGES.ACTION_PROHIBITED,
|
|
)
|
|
|
|
|
|
############################
|
|
# GetUserGroupsById
|
|
############################
|
|
|
|
|
|
@router.get('/{user_id}/groups')
|
|
async def get_user_groups_by_id(
|
|
user_id: str, user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)
|
|
):
|
|
return await Groups.get_groups_by_member_id(user_id, db=db)
|
|
|
|
|
|
############################
|
|
# GetUserPreview
|
|
############################
|
|
|
|
|
|
@router.get('/{user_id}/preview')
|
|
async def get_user_preview(
|
|
user_id: str,
|
|
user=Depends(get_admin_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
"""Show what resources a specific user can access across all their groups."""
|
|
target_user = await Users.get_user_by_id(user_id, db=db)
|
|
if not target_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=ERROR_MESSAGES.USER_NOT_FOUND,
|
|
)
|
|
|
|
# Get all group IDs this user belongs to
|
|
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
|
user_group_ids = {g.id for g in user_groups}
|
|
|
|
all_models = await Models.get_all_models(db=db)
|
|
active_models = [m for m in all_models if m.is_active]
|
|
owned_model_ids = {m.id for m in active_models if m.user_id == user_id}
|
|
granted_model_ids = await AccessGrants.get_accessible_resource_ids(
|
|
user_id=user_id,
|
|
resource_type='model',
|
|
resource_ids=[m.id for m in active_models if m.user_id != user_id],
|
|
permission='read',
|
|
user_group_ids=user_group_ids,
|
|
db=db,
|
|
)
|
|
accessible_model_ids = owned_model_ids | granted_model_ids
|
|
|
|
all_knowledge = await Knowledges.get_knowledge_bases(db=db)
|
|
owned_knowledge_ids = {k.id for k in all_knowledge if k.user_id == user_id}
|
|
granted_knowledge_ids = await AccessGrants.get_accessible_resource_ids(
|
|
user_id=user_id,
|
|
resource_type='knowledge',
|
|
resource_ids=[k.id for k in all_knowledge if k.user_id != user_id],
|
|
permission='read',
|
|
user_group_ids=user_group_ids,
|
|
db=db,
|
|
)
|
|
accessible_knowledge_ids = owned_knowledge_ids | granted_knowledge_ids
|
|
|
|
all_tools = await Tools.get_tools(defer_content=True, db=db)
|
|
owned_tool_ids = {t.id for t in all_tools if t.user_id == user_id}
|
|
granted_tool_ids = await AccessGrants.get_accessible_resource_ids(
|
|
user_id=user_id,
|
|
resource_type='tool',
|
|
resource_ids=[t.id for t in all_tools if t.user_id != user_id],
|
|
permission='read',
|
|
user_group_ids=user_group_ids,
|
|
db=db,
|
|
)
|
|
accessible_tool_ids = owned_tool_ids | granted_tool_ids
|
|
|
|
return {
|
|
'user': {'id': target_user.id, 'name': target_user.name},
|
|
'groups': [{'id': g.id, 'name': g.name} for g in user_groups],
|
|
'models': {
|
|
'items': [{'id': m.id, 'name': m.name} for m in active_models if m.id in accessible_model_ids],
|
|
'total': len(active_models),
|
|
},
|
|
'knowledge': {
|
|
'items': [{'id': k.id, 'name': k.name} for k in all_knowledge if k.id in accessible_knowledge_ids],
|
|
'total': len(all_knowledge),
|
|
},
|
|
'tools': {
|
|
'items': [{'id': t.id, 'name': t.name} for t in all_tools if t.id in accessible_tool_ids],
|
|
'total': len(all_tools),
|
|
},
|
|
}
|