mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-13 01:02:25 -06:00
80d2f4154a
The SharingPermissions model defaulted public_tools and public_notes to True while the config defaults (USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING and USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING) are both False. On an instance whose stored user.permissions config predates these keys, GET /api/v1/users/default/permissions fills the gap from the model and reports both as enabled, while has_permission fills it from DEFAULT_USER_PERMISSIONS and denies. Saving any unrelated permission then persists the model's True, granting public tool and note sharing the admin never enabled.
1173 lines
38 KiB
Python
1173 lines
38 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.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()
|
|
|
|
|
|
def merge_user_ui_settings(defaults: dict, settings: dict) -> dict:
|
|
merged = dict(defaults)
|
|
for key, value in settings.items():
|
|
if value is None:
|
|
continue
|
|
|
|
default_value = merged.get(key)
|
|
merged[key] = (
|
|
merge_user_ui_settings(default_value, value)
|
|
if isinstance(default_value, dict) and isinstance(value, dict)
|
|
else value
|
|
)
|
|
return merged
|
|
|
|
|
|
def strip_default_interface_settings(defaults: dict, settings: dict) -> dict:
|
|
stripped = {}
|
|
for key, value in settings.items():
|
|
if value is None:
|
|
continue
|
|
|
|
default_value = defaults.get(key)
|
|
if isinstance(default_value, dict) and isinstance(value, dict):
|
|
nested = strip_default_interface_settings(default_value, value)
|
|
if nested:
|
|
stripped[key] = nested
|
|
elif value != default_value:
|
|
stripped[key] = value
|
|
|
|
return stripped
|
|
|
|
|
|
############################
|
|
# 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 = False
|
|
skills: bool = False
|
|
public_skills: bool = False
|
|
notes: bool = False
|
|
public_notes: bool = False
|
|
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(
|
|
raw: bool = False,
|
|
user=Depends(get_verified_user),
|
|
db: AsyncSession = Depends(get_async_session),
|
|
):
|
|
# user already fetched by get_verified_user — no need to refetch
|
|
if raw:
|
|
return user.settings
|
|
|
|
default_interface_settings = await Config.get('ui.default_interface_settings')
|
|
if not isinstance(default_interface_settings, dict) or not default_interface_settings:
|
|
return user.settings
|
|
|
|
user_settings = user.settings.model_dump() if isinstance(user.settings, UserSettings) else dict(user.settings or {})
|
|
ui_settings = user_settings.get('ui') if isinstance(user_settings.get('ui'), dict) else {}
|
|
user_settings['ui'] = merge_user_ui_settings(default_interface_settings, ui_settings)
|
|
|
|
return UserSettings.model_validate(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)
|
|
|
|
default_interface_settings = await Config.get('ui.default_interface_settings')
|
|
ui_settings = updated_user_settings.get('ui')
|
|
if isinstance(default_interface_settings, dict) and isinstance(ui_settings, dict):
|
|
updated_user_settings['ui'] = strip_default_interface_settings(default_interface_settings, ui_settings)
|
|
|
|
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
|
|
else:
|
|
days = days or 730
|
|
period_start = period_end - ((days - 1) * 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:
|
|
updated_fields = [field for field in update_data.keys() if field != 'role']
|
|
role_changed = updated_user.role != user.role
|
|
|
|
if updated_fields:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_UPDATED,
|
|
actor=session_user,
|
|
subject_id=user_id,
|
|
data={'updated_fields': updated_fields},
|
|
)
|
|
|
|
if role_changed:
|
|
await publish_event(
|
|
request,
|
|
EVENTS.USER_ROLE_UPDATED,
|
|
actor=session_user,
|
|
subject_id=user_id,
|
|
data={'role': updated_user.role},
|
|
)
|
|
|
|
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 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),
|
|
},
|
|
}
|