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

1from __future__ import annotations 

2 

3import base64 

4import io 

5import logging 

6import time 

7from collections import Counter 

8from datetime import datetime, timedelta 

9from typing import Optional 

10 

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 

51 

52log = logging.getLogger(__name__) 

53 

54router = APIRouter() 

55 

56 

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 

62 

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 

70 

71 

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############################ 

77 

78 

79PAGE_ITEM_COUNT = 30 

80 

81 

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 

92 

93 page = max(1, page) 

94 skip = (page - 1) * limit 

95 

96 filter = {} 

97 if query: 

98 filter['query'] = query 

99 

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 ) 

107 

108 users = result['users'] 

109 total = result['total'] 

110 

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) 

114 

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 } 

127 

128 

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) 

135 

136 

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 

147 

148 page = max(1, page) 

149 skip = (page - 1) * limit 

150 

151 filter = {} 

152 if query: 

153 filter['query'] = query 

154 

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 ) 

162 

163 

164############################ 

165# User Groups 

166############################ 

167 

168 

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) 

172 

173 

174############################ 

175# User Permissions 

176############################ 

177 

178 

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) 

186 

187 return user_permissions 

188 

189 

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 

207 

208 

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 

226 

227 

228class AccessGrantsPermissions(BaseModel): 

229 allow_users: bool = True 

230 allow_groups: bool = True 

231 

232 

233class ChatPermissions(BaseModel): 

234 model_config = ConfigDict(populate_by_name=True) 

235 

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 

257 

258 

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 

265 

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 

273 

274 

275class SettingsPermissions(BaseModel): 

276 interface: bool = True 

277 

278 

279class UserPermissions(BaseModel): 

280 workspace: WorkspacePermissions 

281 sharing: SharingPermissions 

282 access_grants: AccessGrantsPermissions 

283 chat: ChatPermissions 

284 features: FeaturesPermissions 

285 settings: SettingsPermissions 

286 

287 

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 

302 

303 

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) 

310 

311 

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 

318 

319 

320class UserUsageToolEntry(BaseModel): 

321 name: str 

322 count: int 

323 

324 

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 

331 

332 

333class UserUsagePeriod(BaseModel): 

334 start_date: int 

335 end_date: int 

336 days: int 

337 

338 

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 

348 

349 

350def _week_start(date: datetime) -> datetime: 

351 return date - timedelta(days=date.weekday()) 

352 

353 

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', {})) 

363 

364 return [ 

365 { 

366 **weeks[key], 

367 'models': dict(weeks[key]['models']), 

368 } 

369 for key in sorted(weeks) 

370 ] 

371 

372 

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 

390 

391 

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 

401 

402 current = 0 

403 for day in reversed(heatmap): 

404 if day.get('messages', 0) <= 0: 

405 break 

406 current += 1 

407 

408 return {'current': current, 'longest': longest} 

409 

410 

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 } 

422 

423 

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 

436 

437 

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 

441 

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 } 

450 

451 

452############################ 

453# GetUserSettingsBySessionUser 

454############################ 

455 

456 

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 

465 

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 

469 

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) 

473 

474 return UserSettings.model_validate(user_settings) 

475 

476 

477############################ 

478# UpdateUserSettingsBySessionUser 

479############################ 

480 

481 

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') 

491 

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) 

499 

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) 

509 

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) 

519 

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) 

531 

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) 

548 

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 ) 

563 

564 

565############################ 

566# GetUserStatusBySessionUser 

567############################ 

568 

569 

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 

582 

583 

584############################ 

585# UpdateUserStatusBySessionUser 

586############################ 

587 

588 

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 ) 

615 

616 

617############################ 

618# GetUserInfoBySessionUser 

619############################ 

620 

621 

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 

626 

627 

628class UserVariablesForm(BaseModel): 

629 variables: dict = Field(default_factory=dict) 

630 

631 

632class UserVariablesResponse(BaseModel): 

633 variables: dict[str, str] = Field(default_factory=dict) 

634 

635 

636############################ 

637# GetUserVariablesBySessionUser 

638############################ 

639 

640 

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)) 

644 

645 

646############################ 

647# UpdateUserVariablesBySessionUser 

648############################ 

649 

650 

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 ) 

664 

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) 

672 

673 

674############################ 

675# UpdateUserInfoBySessionUser 

676############################ 

677 

678 

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. 

686 

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 

699 

700 

701############################ 

702# GetUserUsageBySessionUser 

703############################ 

704 

705 

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) 

721 

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 ) 

727 

728 period_days = max(1, int((period_end - period_start) / 86400) + 1) 

729 

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) 

738 

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) 

745 

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 ) 

778 

779 

780############################ 

781# GetUserById 

782############################ 

783 

784 

785class UserActiveResponse(UserStatus): 

786 name: str 

787 profile_image_url: str | None = None 

788 groups: list | None = [] 

789 

790 is_active: bool 

791 model_config = ConfigDict(extra='allow') 

792 

793 

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)): 

796 

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 ) 

812 

813 

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 ) 

833 

834 

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 ) 

847 

848 

849############################ 

850# GetUserProfileImageById 

851############################ 

852 

853 

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() 

874 

875 if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES: 

876 return FileResponse(f'{STATIC_DIR}/user.png') 

877 

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 ) 

894 

895 

896############################ 

897# GetUserActiveStatusById 

898############################ 

899 

900 

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 } 

908 

909 

910############################ 

911# UpdateUserById 

912############################ 

913 

914 

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 ) 

934 

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 ) 

941 

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 ) 

950 

951 user = await Users.get_user_by_id(user_id, db=db) 

952 

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 ) 

961 

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)) 

967 

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) 

971 

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 

983 

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 

992 

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 

996 

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 ) 

1005 

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 ) 

1014 

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 

1025 

1026 raise HTTPException( 

1027 status_code=status.HTTP_400_BAD_REQUEST, 

1028 detail=ERROR_MESSAGES.DEFAULT(), 

1029 ) 

1030 

1031 raise HTTPException( 

1032 status_code=status.HTTP_400_BAD_REQUEST, 

1033 detail=ERROR_MESSAGES.USER_NOT_FOUND, 

1034 ) 

1035 

1036 

1037############################ 

1038# DeleteUserById 

1039############################ 

1040 

1041 

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 ) 

1062 

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) 

1065 

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 

1074 

1075 raise HTTPException( 

1076 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 

1077 detail=ERROR_MESSAGES.DELETE_USER_ERROR, 

1078 ) 

1079 

1080 # Prevent self-deletion 

1081 raise HTTPException( 

1082 status_code=status.HTTP_403_FORBIDDEN, 

1083 detail=ERROR_MESSAGES.ACTION_PROHIBITED, 

1084 ) 

1085 

1086 

1087############################ 

1088# GetUserGroupsById 

1089############################ 

1090 

1091 

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) 

1097 

1098 

1099############################ 

1100# GetUserPreview 

1101############################ 

1102 

1103 

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 ) 

1117 

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} 

1121 

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 

1134 

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 

1146 

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 

1158 

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 }