Coverage for open_webui/models/users.py: 57%
561 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1"""User models, Pydantic schemas, and database access layer."""
3from __future__ import annotations
5import datetime
6import time
7from typing import Literal, Optional
8from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
9from open_webui.internal.db import Base, JSONField, get_async_db_context
10from open_webui.utils.misc import throttle
11from open_webui.utils.validate import validate_image_url
12from pydantic import (
13 BaseModel,
14 ConfigDict,
15 Field,
16 field_validator,
17 model_validator,
18)
19from sqlalchemy import (
20 JSON,
21 BigInteger,
22 Boolean,
23 Column,
24 Date,
25 String,
26 Text,
27 case,
28 cast,
29 delete,
30 exists,
31 func,
32 or_,
33 select,
34 update,
35)
36from sqlalchemy.ext.asyncio import AsyncSession
38####################
39# User DB Schema
40# Hallowed be the columns defined here, for they hold the
41# daily bread of every session. Let none go hungry.
42####################
45class InterfaceTitleSettings(BaseModel):
46 model_config = ConfigDict(extra='forbid')
48 auto: bool | None = None
51class InterfaceImageCompressionSize(BaseModel):
52 model_config = ConfigDict(extra='forbid')
54 width: int | float | Literal[''] | None = None
55 height: int | float | Literal[''] | None = None
58class InterfaceFloatingActionButton(BaseModel):
59 model_config = ConfigDict(extra='forbid')
61 id: str
62 label: str
63 input: bool
64 prompt: str
67class InterfaceSettings(BaseModel):
68 """Fields owned by the Interface settings panel; not the entire user UI dict."""
70 model_config = ConfigDict(extra='forbid')
72 autoTags: bool | None = None
73 autoFollowUps: bool | None = None
74 highContrastMode: bool | None = None
75 detectArtifacts: bool | None = None
76 responseAutoCopy: bool | None = None
77 showUsername: bool | None = None
78 showUpdateToast: bool | None = None
79 showChangelog: bool | None = None
80 showEmojiInCall: bool | None = None
81 voiceInterruption: bool | None = None
82 displayMultiModelResponsesInTabs: bool | None = None
83 chatFadeStreamingText: bool | None = None
84 richTextInput: bool | None = None
85 showFormattingToolbar: bool | None = None
86 insertPromptAsRichText: bool | None = None
87 promptAutocomplete: bool | None = None
88 insertSuggestionPrompt: bool | None = None
89 keepFollowUpPrompts: bool | None = None
90 insertFollowUpPrompt: bool | None = None
91 regenerateMenu: bool | None = None
92 enableMessageQueue: bool | None = None
93 largeTextAsFile: bool | None = None
94 copyFormatted: bool | None = None
95 collapseCodeBlocks: bool | None = None
96 renderMarkdownInUserMessages: bool | None = None
97 renderMarkdownInAssistantMessages: bool | None = None
98 expandDetails: bool | None = None
99 chatHoverPreview: bool | None = None
100 renderMarkdownInPreviews: bool | None = None
101 chatBubble: bool | None = None
102 widescreenMode: bool | None = None
103 splitLargeChunks: bool | None = None
104 scrollOnBranchChange: bool | None = None
105 scrollOnResponseGeneration: bool | None = None
106 showFilesOnTerminalSelect: bool | None = None
107 temporaryChatByDefault: bool | None = None
108 userLocation: bool | None = None
109 showChatTitleInTab: bool | None = None
110 iframeSandboxAllowScripts: bool | None = None
111 iframeSandboxAllowSameOrigin: bool | None = None
112 iframeSandboxAllowForms: bool | None = None
113 iframeSandboxAllowDownloads: bool | None = None
114 terminalPreviewAllowSameOrigin: bool | None = None
115 stylizedPdfExport: bool | None = None
116 hapticFeedback: bool | None = None
117 ctrlEnterToSend: bool | None = None
118 showFloatingActionButtons: bool | None = None
119 imageCompression: bool | None = None
120 imageCompressionInChannels: bool | None = None
122 landingPageMode: Literal['', 'chat'] | None = None
123 chatDirection: Literal['LTR', 'RTL', 'auto'] | None = None
124 terminalFileDisplay: Literal['sidebar', 'inline'] | None = None
125 defaultUploadContext: Literal['full', 'focused'] | None = None
126 webSearch: Literal['always'] | None = None
127 models: list[str] | None = None
128 backgroundImageUrl: str | None = None
129 fontFamily: str | None = None
130 textScale: float | None = None
131 title: InterfaceTitleSettings | None = None
132 imageCompressionSize: InterfaceImageCompressionSize | None = None
133 floatingActionButtons: list[InterfaceFloatingActionButton] | None = None
136class UserSettings(BaseModel):
137 ui: dict | None = {}
138 model_config = ConfigDict(extra='allow')
139 pass
142class User(Base): # identity & profile
143 """One row per registered account — profile, role, and settings."""
145 __tablename__: str = 'user' # Identity & Credentials
146 id = Column(String, primary_key=True, unique=True) # unique user id
147 email = Column(String, unique=True) # user email address
148 username = Column(String(50), nullable=True) # custom handle
149 role = Column(String, default='pending') # permissions role
150 name = Column(String, nullable=False) # display name
152 # Profile
153 profile_image_url = Column(Text) # data-uri, path, or external URL
154 profile_banner_image_url = Column(Text, nullable=True)
155 bio = Column(Text, nullable=True)
156 gender = Column(Text, nullable=True)
157 date_of_birth = Column(Date, nullable=True)
158 timezone = Column(String, nullable=True)
160 # Online status
161 presence_state = Column(String, nullable=True)
162 status_emoji = Column(String, nullable=True)
163 status_message = Column(Text, nullable=True)
164 status_expires_at = Column(BigInteger, nullable=True)
166 # Metadata
167 info = Column(JSON, nullable=True)
168 variables = Column(JSON, nullable=True)
169 settings = Column(JSON, nullable=True)
170 oauth = Column(JSON, nullable=True)
171 scim = Column(JSON, nullable=True)
173 # Timestamps (epoch seconds)
174 last_active_at = Column(BigInteger)
175 updated_at = Column(BigInteger)
176 created_at = Column(BigInteger)
179_DEFAULT_PROFILE_IMAGE_URL = '/api/v1/users/{user_id}/profile/image'
182class UserModel(BaseModel):
183 id: str
185 email: str
186 username: str | None = None
187 role: str = 'pending'
189 name: str
191 profile_image_url: str | None = None
192 profile_banner_image_url: str | None = None
194 bio: str | None = None
195 gender: str | None = None
196 date_of_birth: datetime.date | None = None
197 timezone: str | None = None
199 presence_state: str | None = None
200 status_emoji: str | None = None
201 status_message: str | None = None
202 status_expires_at: int | None = None
204 info: dict | None = None
205 variables: dict = Field(default_factory=dict, exclude=True)
206 settings: UserSettings | None = None
208 oauth: dict | None = None
209 scim: dict | None = None
211 last_active_at: int # timestamp in epoch
212 updated_at: int # timestamp in epoch
213 created_at: int # timestamp in epoch
215 model_config = ConfigDict(
216 from_attributes=True,
217 )
219 # validation schema logic
220 # --- model validators ---
221 @model_validator(mode='after')
222 def _ensure_profile_image(self) -> 'UserModel':
223 """Assign a generated avatar when no profile image is provided."""
224 self.profile_image_url = self.profile_image_url or _DEFAULT_PROFILE_IMAGE_URL.format(user_id=self.id)
225 return self
227 @field_validator('variables', mode='before')
228 @classmethod
229 def normalize_variables(cls, value):
230 return value if isinstance(value, dict) else {}
233class UserStatusModel(UserModel):
234 is_active: bool = False
236 model_config = ConfigDict(from_attributes=True)
239class ApiKey(Base):
240 __tablename__ = 'api_key'
242 id = Column(Text, primary_key=True, unique=True)
243 user_id = Column(Text, nullable=False)
244 key = Column(Text, unique=True, nullable=False)
245 data = Column(JSON, nullable=True)
246 expires_at = Column(BigInteger, nullable=True)
247 last_used_at = Column(BigInteger, nullable=True)
248 created_at = Column(BigInteger, nullable=False)
249 updated_at = Column(BigInteger, nullable=False)
252class ApiKeyModel(BaseModel):
253 id: str
254 user_id: str
255 key: str
256 data: dict | None = None
257 expires_at: int | None = None
258 last_used_at: int | None = None
259 created_at: int # timestamp in epoch
260 updated_at: int # timestamp in epoch
262 model_config = ConfigDict(from_attributes=True)
265####################
266# Forms
267####################
270class UpdateProfileForm(BaseModel):
271 profile_image_url: str
272 name: str
273 bio: str | None = None
274 gender: str | None = None
275 date_of_birth: datetime.date | None = None
277 @field_validator('profile_image_url')
278 @classmethod
279 def check_profile_image_url(cls, v: str) -> str:
280 return validate_image_url(v)
283class UserGroupIdsModel(UserModel):
284 group_ids: list[str] = []
287class UserModelResponse(UserModel):
288 model_config = ConfigDict(extra='allow')
291class UserListResponse(BaseModel):
292 users: list[UserModelResponse]
293 total: int
296class UserGroupIdsListResponse(BaseModel):
297 users: list[UserGroupIdsModel]
298 total: int
301class UserStatus(BaseModel):
302 status_emoji: str | None = None
303 status_message: str | None = None
304 status_expires_at: int | None = None
307class UserInfoResponse(UserStatus):
308 id: str
309 name: str
310 email: str
311 role: str
312 bio: str | None = None
313 groups: list | None = []
314 is_active: bool = False
317class UserIdNameResponse(BaseModel):
318 id: str
319 name: str
322class UserIdNameStatusResponse(UserStatus):
323 id: str
324 name: str
325 is_active: bool | None = None
328class UserInfoListResponse(BaseModel):
329 users: list[UserInfoResponse]
330 total: int
333class UserIdNameListResponse(BaseModel):
334 users: list[UserIdNameResponse]
335 total: int
338class UserNameResponse(BaseModel):
339 id: str
340 name: str
341 role: str
344class UserResponse(UserNameResponse):
345 email: str
348class UserProfileImageResponse(UserNameResponse):
349 email: str
350 profile_image_url: str
353class UserRoleUpdateForm(BaseModel):
354 id: str
355 role: str
358class UserUpdateForm(BaseModel):
359 role: str | None = None
360 name: str | None = None
361 email: str | None = None
362 profile_image_url: str | None = None
363 password: str | None = None
365 @field_validator('profile_image_url', mode='before')
366 @classmethod
367 def check_profile_image_url(cls, v: str | None) -> str | None:
368 if v is None:
369 return v
370 return validate_image_url(v)
373class UsersTable:
374 async def insert_new_user(
375 self,
376 id: str,
377 name: str,
378 email: str,
379 profile_image_url: str = '/user.png',
380 role: str = 'pending',
381 username: str | None = None,
382 oauth: dict | None = None,
383 db: AsyncSession | None = None,
384 ) -> UserModel | None:
385 try:
386 profile_image_url = validate_image_url(profile_image_url)
387 except ValueError:
388 profile_image_url = '/user.png'
390 async with get_async_db_context(db) as session:
391 user = UserModel(
392 **{
393 'id': id,
394 'email': email,
395 'name': name,
396 'role': role,
397 'profile_image_url': profile_image_url,
398 'last_active_at': int(time.time()),
399 'created_at': int(time.time()),
400 'updated_at': int(time.time()),
401 'username': username,
402 'oauth': oauth,
403 }
404 )
405 result = User(**user.model_dump())
406 session.add(result)
407 await session.commit()
408 return user if result else None
410 # database read methods
411 # --- read / lookup operations ---
412 async def get_user_by_id(
413 self,
414 id: str,
415 db: AsyncSession | None = None,
416 ) -> UserModel | None:
417 """Fetch a single user by primary key."""
418 async with get_async_db_context(db) as session:
419 user = await session.get(User, id)
420 return UserModel.model_validate(user) if user else None
422 # api key auth helper
423 async def get_user_by_api_key(
424 self,
425 api_key: str,
426 db: AsyncSession | None = None,
427 ) -> UserModel | None:
428 """Resolve a user from their API key via a JOIN on the api_key table."""
429 async with get_async_db_context(db) as session:
430 result = await session.execute(
431 select(User).join(ApiKey, User.id == ApiKey.user_id).where(ApiKey.key == api_key),
432 )
433 user = result.scalars().first()
434 return UserModel.model_validate(user) if user else None
436 async def get_user_by_email(
437 self,
438 email: str,
439 db: AsyncSession | None = None,
440 ) -> UserModel | None:
441 """Case-insensitive email lookup using SQL lower()."""
442 async with get_async_db_context(db) as session:
443 email_filter = func.lower(User.email) == email.lower()
444 query = select(User).where(email_filter)
445 match = (await session.execute(query)).scalars().first()
446 if match is None:
447 return
448 return UserModel.model_validate(match)
449 # --- context manager above always returns ---
450 return
452 # --- oauth & integrations ---
453 async def get_user_by_oauth_sub(
454 self,
455 provider: str,
456 sub: str,
457 db: AsyncSession | None = None,
458 ) -> UserModel | None:
459 """Look up a user by OAuth provider + subject claim."""
460 sub = str(sub)
461 async with get_async_db_context(db) as session:
462 # Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
463 sub_expr = User.oauth[provider]['sub'].as_string()
464 query = select(User).where(sub_expr == sub)
465 # SQLite preserves JSON numeric type here; Postgres ->> already compares numeric JSON as text.
466 if session.get_bind().dialect.name == 'sqlite' and sub.isdecimal():
467 sub_int = int(sub)
468 if str(sub_int) == sub and sub_int <= 2**63 - 1:
469 query = select(User).where(or_(sub_expr == sub, sub_expr == sub_int))
470 row = (await session.execute(query)).scalars().first()
471 return UserModel.model_validate(row) if row else None
473 async def get_user_by_scim_external_id(
474 self,
475 provider: str,
476 external_id: str,
477 db: AsyncSession | None = None,
478 ) -> UserModel | None:
479 """Look up a user by SCIM provider + external ID."""
480 async with get_async_db_context(db) as session:
481 # Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
482 query = select(User).where(User.scim[provider]['external_id'].as_string() == external_id)
483 row = (await session.execute(query)).scalars().first()
484 return UserModel.model_validate(row) if row else None
486 async def get_scim_users(
487 self,
488 filter: dict | None = None,
489 sort: dict | None = None,
490 skip: int | None = None,
491 limit: int | None = None,
492 db: AsyncSession | None = None,
493 ) -> dict:
494 async with get_async_db_context(db) as session:
495 stmt = select(User).where(or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null'))
497 if filter:
498 user_id = filter.get('id')
499 if user_id:
500 stmt = stmt.where(User.id == user_id)
502 email = filter.get('email')
503 if email:
504 stmt = stmt.where(func.lower(User.email) == email.lower())
506 order_by = sort.get('order_by') if sort else None
507 direction = sort.get('direction') if sort else None
509 if order_by == 'created_at':
510 stmt = stmt.order_by(User.created_at.asc() if direction == 'asc' else User.created_at.desc())
512 count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
513 total = count_result.scalar()
515 if skip is not None:
516 stmt = stmt.offset(skip)
517 if limit is not None:
518 stmt = stmt.limit(limit)
520 result = await session.execute(stmt)
521 users = result.scalars().all()
522 return {
523 'users': [UserModel.model_validate(user) for user in users],
524 'total': total,
525 }
527 async def get_scim_user_by_id(
528 self,
529 id: str,
530 db: AsyncSession | None = None,
531 ) -> UserModel | None:
532 async with get_async_db_context(db) as session:
533 stmt = select(User).where(
534 User.id == id,
535 or_(User.oauth.cast(String) != 'null', User.scim.cast(String) != 'null'),
536 )
537 user = (await session.execute(stmt)).scalars().first()
538 return UserModel.model_validate(user) if user else None
540 async def get_users(
541 self,
542 filter: dict | None = None,
543 sort: dict | None = None,
544 skip: int | None = None,
545 limit: int | None = None,
546 db: AsyncSession | None = None,
547 ) -> dict:
548 """Paginated user listing with optional filters and sort."""
549 async with get_async_db_context(db) as session:
550 # Deferred imports to avoid circular dependencies
551 from open_webui.models.channels import ChannelMember
552 from open_webui.models.groups import GroupMember
554 # Join GroupMember so we can order by group_id when requested
555 stmt = select(User)
557 if filter:
558 query_key = filter.get('query')
559 if query_key: 559 ↛ 567line 559 didn't jump to line 567 because the condition on line 559 was always true
560 stmt = stmt.filter(
561 or_(
562 User.name.ilike(f'%{query_key}%'),
563 User.email.ilike(f'%{query_key}%'),
564 )
565 )
567 channel_id = filter.get('channel_id')
568 if channel_id: 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true
569 stmt = stmt.filter(
570 exists(
571 select(ChannelMember.id).where(
572 ChannelMember.user_id == User.id,
573 ChannelMember.channel_id == channel_id,
574 )
575 )
576 )
578 user_ids = filter.get('user_ids')
579 group_ids = filter.get('group_ids')
581 if isinstance(user_ids, list) and isinstance(group_ids, list): 581 ↛ 583line 581 didn't jump to line 583 because the condition on line 581 was never true
582 # If both are empty lists, return no users
583 if not user_ids and not group_ids:
584 return {'users': [], 'total': 0}
586 if user_ids: 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true
587 stmt = stmt.filter(User.id.in_(user_ids))
589 if group_ids: 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true
590 stmt = stmt.filter(
591 exists(
592 select(GroupMember.id).where(
593 GroupMember.user_id == User.id,
594 GroupMember.group_id.in_(group_ids),
595 )
596 )
597 )
599 roles = filter.get('roles')
600 if roles: 600 ↛ 601line 600 didn't jump to line 601 because the condition on line 600 was never true
601 include_roles = [role for role in roles if not role.startswith('!')]
602 exclude_roles = [role[1:] for role in roles if role.startswith('!')]
604 if include_roles:
605 stmt = stmt.filter(User.role.in_(include_roles))
606 if exclude_roles:
607 stmt = stmt.filter(~User.role.in_(exclude_roles))
609 order_by = sort.get('order_by') if sort else None
610 direction = sort.get('direction') if sort else None
612 if order_by and order_by.startswith('group_id:'): 612 ↛ 613line 612 didn't jump to line 613 because the condition on line 612 was never true
613 group_id = order_by.split(':', 1)[1]
615 # Subquery that checks if the user belongs to the group
616 membership_exists = exists(
617 select(GroupMember.id).where(
618 GroupMember.user_id == User.id,
619 GroupMember.group_id == group_id,
620 )
621 )
623 # CASE: user in group → 1, user not in group → 0
624 group_sort = case((membership_exists, 1), else_=0)
626 if direction == 'asc':
627 stmt = stmt.order_by(group_sort.asc(), User.name.asc())
628 else:
629 stmt = stmt.order_by(group_sort.desc(), User.name.asc())
631 elif order_by == 'name': 631 ↛ 632line 631 didn't jump to line 632 because the condition on line 631 was never true
632 if direction == 'asc':
633 stmt = stmt.order_by(User.name.asc())
634 else:
635 stmt = stmt.order_by(User.name.desc())
637 elif order_by == 'email': 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true
638 if direction == 'asc':
639 stmt = stmt.order_by(User.email.asc())
640 else:
641 stmt = stmt.order_by(User.email.desc())
643 elif order_by == 'created_at': 643 ↛ 644line 643 didn't jump to line 644 because the condition on line 643 was never true
644 if direction == 'asc':
645 stmt = stmt.order_by(User.created_at.asc())
646 else:
647 stmt = stmt.order_by(User.created_at.desc())
649 elif order_by == 'last_active_at': 649 ↛ 650line 649 didn't jump to line 650 because the condition on line 649 was never true
650 if direction == 'asc':
651 stmt = stmt.order_by(User.last_active_at.asc())
652 else:
653 stmt = stmt.order_by(User.last_active_at.desc())
655 elif order_by == 'updated_at': 655 ↛ 656line 655 didn't jump to line 656 because the condition on line 655 was never true
656 if direction == 'asc':
657 stmt = stmt.order_by(User.updated_at.asc())
658 else:
659 stmt = stmt.order_by(User.updated_at.desc())
660 elif order_by == 'role': 660 ↛ 661line 660 didn't jump to line 661 because the condition on line 660 was never true
661 if direction == 'asc':
662 stmt = stmt.order_by(User.role.asc())
663 else:
664 stmt = stmt.order_by(User.role.desc())
665 elif not filter:
666 stmt = stmt.order_by(User.created_at.desc())
668 # Count BEFORE pagination
669 count_result = await session.execute(select(func.count()).select_from(stmt.subquery()))
670 total = count_result.scalar()
672 # correct pagination logic
673 if skip is not None:
674 stmt = stmt.offset(skip)
675 if limit is not None:
676 stmt = stmt.limit(limit)
678 result = await session.execute(stmt)
679 users = result.scalars().all()
680 return {
681 'users': [UserModel.model_validate(user) for user in users],
682 'total': total,
683 }
685 async def get_users_by_group_id(self, group_id: str, db: AsyncSession | None = None) -> list[UserModel]:
686 async with get_async_db_context(db) as session:
687 from open_webui.models.groups import GroupMember
689 result = await session.execute(
690 select(User).join(GroupMember, User.id == GroupMember.user_id).filter(GroupMember.group_id == group_id)
691 )
692 users = result.scalars().all()
693 return [UserModel.model_validate(user) for user in users]
695 async def get_users_by_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[UserStatusModel]:
696 async with get_async_db_context(db) as session:
697 result = await session.execute(select(User).filter(User.id.in_(user_ids)))
698 users = result.scalars().all()
699 return [UserModel.model_validate(user) for user in users]
701 # count registered accounts
702 async def get_num_users(self, db: AsyncSession | None = None) -> int | None:
703 async with get_async_db_context(db) as session:
704 result = await session.execute(select(func.count()).select_from(User))
705 return result.scalar()
707 # check user existence
708 async def has_users(self, db: AsyncSession | None = None) -> bool:
709 async with get_async_db_context(db) as session:
710 result = await session.execute(select(exists(select(User))))
711 return result.scalar()
713 async def get_first_user(self, db: AsyncSession | None = None) -> UserModel | None:
714 """Return the earliest-created user (bootstrap admin detection)."""
715 async with get_async_db_context(db) as session:
716 stmt = select(User).order_by(User.created_at).limit(1)
717 row = (await session.execute(stmt)).scalars().first()
718 return UserModel.model_validate(row) if row else None
720 async def get_num_users_active_today(self, db: AsyncSession | None = None) -> int | None:
721 async with get_async_db_context(db) as session:
722 current_timestamp = int(time.time())
723 today_midnight_timestamp = current_timestamp - (current_timestamp % 86400)
724 result = await session.execute(
725 select(func.count()).select_from(User).where(User.last_active_at > today_midnight_timestamp)
726 )
727 return result.scalar()
729 async def update_user_role_by_id(self, id: str, role: str, db: AsyncSession | None = None) -> UserModel | None:
730 async with get_async_db_context(db) as session:
731 user = await session.get(User, id)
732 if not user:
733 return None
734 user.role = role
735 await session.commit()
736 return UserModel.model_validate(user)
738 async def update_user_status_by_id(
739 self, id: str, form_data: UserStatus, db: AsyncSession | None = None
740 ) -> UserModel | None:
741 async with get_async_db_context(db) as session:
742 user = await session.get(User, id)
743 if not user:
744 return None
745 for key, value in form_data.model_dump(exclude_none=True).items():
746 setattr(user, key, value)
747 await session.commit()
748 return UserModel.model_validate(user)
750 async def update_user_profile_image_url_by_id(
751 self,
752 id: str,
753 profile_image_url: str,
754 db: AsyncSession | None = None,
755 ) -> UserModel | None:
756 try:
757 profile_image_url = validate_image_url(profile_image_url)
758 except ValueError:
759 profile_image_url = '/user.png'
761 async with get_async_db_context(db) as session:
762 user = await session.get(User, id)
763 if user is None:
764 return None
765 user.profile_image_url = profile_image_url
766 await session.commit()
767 return UserModel.model_validate(user)
769 @throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
770 async def update_last_active_by_id(self, id: str, db: AsyncSession | None = None) -> None:
771 async with get_async_db_context(db) as session:
772 await session.execute(update(User).where(User.id == id).values(last_active_at=int(time.time())))
773 await session.commit()
775 async def update_user_oauth_by_id(
776 self, id: str, provider: str, sub: str, db: AsyncSession | None = None
777 ) -> UserModel | None:
778 """Update or insert an OAuth provider/sub pair into the user's oauth JSON field."""
779 async with get_async_db_context(db) as session:
780 user = await session.get(User, id)
781 if not user:
782 return None
783 oauth = dict(user.oauth or {})
784 provider_oauth = oauth.get(provider)
785 provider_oauth = dict(provider_oauth) if isinstance(provider_oauth, dict) else {}
786 provider_oauth['sub'] = str(sub)
787 oauth[provider] = provider_oauth
788 user.oauth = oauth
789 await session.commit()
790 return UserModel.model_validate(user)
792 async def update_user_scim_by_id(
793 self,
794 id: str,
795 provider: str,
796 external_id: str | None,
797 db: AsyncSession | None = None,
798 ) -> UserModel | None:
799 """Update or insert a SCIM provider/external_id pair into the user's scim JSON field."""
800 async with get_async_db_context(db) as session:
801 user = await session.get(User, id)
802 if not user:
803 return None
804 scim = dict(user.scim or {})
805 scim[provider] = {'external_id': external_id}
806 if scim != user.scim:
807 user.scim = scim
808 user.updated_at = int(time.time())
809 await session.commit()
810 return UserModel.model_validate(user)
812 async def update_user_by_id(self, id: str, updated: dict, db: AsyncSession | None = None) -> UserModel | None:
813 async with get_async_db_context(db) as session:
814 user = await session.get(User, id)
815 if not user:
816 return None
817 for key, value in updated.items():
818 setattr(user, key, value)
819 await session.commit()
820 return UserModel.model_validate(user)
822 # settings update helper
823 async def update_user_settings_by_id(
824 self, id: str, updated: dict, db: AsyncSession | None = None
825 ) -> UserModel | None:
826 async with get_async_db_context(db) as session:
827 user = await session.get(User, id)
828 if not user:
829 return None
830 user_settings = dict(user.settings or {})
831 updated = dict(updated)
832 ui_settings = updated.pop('ui', None)
833 user_settings.update(updated)
834 if ui_settings is not None:
835 # UI updates are field-level patches: omission keeps a value; null resets it.
836 current_ui_settings = dict(user_settings.get('ui') or {})
837 for key, value in ui_settings.items():
838 if value is None:
839 current_ui_settings.pop(key, None)
840 else:
841 current_ui_settings[key] = value
842 user_settings['ui'] = current_ui_settings
843 user.settings = user_settings
844 await session.commit()
845 return UserModel.model_validate(user)
847 async def delete_user_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
848 from open_webui.models.chats import Chats
849 from open_webui.models.groups import Groups
851 # Remove User from Groups
852 await Groups.remove_user_from_all_groups(id)
854 # Delete User Chats
855 async with get_async_db_context(db) as session:
856 deleted_chats = await Chats.delete_chats_by_user_id(id, db=session)
857 if not deleted_chats: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true
858 return False # chats deletion failed
859 await session.execute(delete(User).where(User.id == id))
860 await session.commit()
861 return True
863 async def get_user_api_key_by_id(self, id: str, db: AsyncSession | None = None) -> str | None:
864 async with get_async_db_context(db) as session:
865 api_key = (await session.execute(select(ApiKey).where(ApiKey.user_id == id))).scalars().first()
866 return api_key.key if api_key else None
868 async def update_user_api_key_by_id(self, id: str, api_key: str, db: AsyncSession | None = None) -> bool:
869 async with get_async_db_context(db) as session:
870 await session.execute(delete(ApiKey).where(ApiKey.user_id == id))
871 now_ts = int(time.time())
872 new_key = ApiKey(
873 id=f'key_{id}',
874 user_id=id,
875 key=api_key,
876 created_at=now_ts,
877 updated_at=now_ts,
878 )
879 session.add(new_key)
880 await session.commit()
881 return True
883 async def delete_user_api_key_by_id(self, id: str, db: AsyncSession | None = None) -> bool:
884 async with get_async_db_context(db) as session:
885 await session.execute(delete(ApiKey).where(ApiKey.user_id == id))
886 await session.commit()
887 return True
889 async def get_valid_user_ids(self, user_ids: list[str], db: AsyncSession | None = None) -> list[str]:
890 async with get_async_db_context(db) as session:
891 result = await session.execute(select(User.id).where(User.id.in_(user_ids)))
892 return list(result.scalars().all())
894 async def get_super_admin_user(self, db: AsyncSession | None = None) -> UserModel | None:
895 async with get_async_db_context(db) as session:
896 row = (await session.execute(select(User).where(User.role == 'admin').limit(1))).scalars().first()
897 return UserModel.model_validate(row) if row else None
899 async def get_active_user_count(self, db: AsyncSession | None = None) -> int:
900 async with get_async_db_context(db) as session:
901 # Consider user active if last_active_at within the last 3 minutes
902 three_minutes_ago = int(time.time()) - 180
903 result = await session.execute(
904 select(func.count()).select_from(User).where(User.last_active_at >= three_minutes_ago)
905 )
906 return result.scalar()
908 @staticmethod
909 def is_active(user: UserModel) -> bool:
910 """Compute active status from an already-loaded UserModel (no DB hit)."""
911 if user.last_active_at:
912 three_minutes_ago = int(time.time()) - 180
913 return user.last_active_at >= three_minutes_ago
914 return False
916 async def is_user_active(self, user_id: str, db: AsyncSession | None = None) -> bool:
917 async with get_async_db_context(db) as session:
918 last_active_at = await session.scalar(select(User.last_active_at).where(User.id == user_id))
919 if last_active_at:
920 # Consider user active if last_active_at within the last 3 minutes
921 three_minutes_ago = int(time.time()) - 180
922 return last_active_at >= three_minutes_ago
923 return False
926Users = UsersTable() # singleton user repository