mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
feat/refac: group members db table (#19239)
* refac: group members table db migration * refac: group members backend * refac: group members frontend * refac: group members frontend integration * refac: styling
This commit is contained in:
commit
34684e7e58
13 changed files with 600 additions and 207 deletions
|
|
@ -0,0 +1,146 @@
|
|||
"""add_group_member_table
|
||||
|
||||
Revision ID: 37f288994c47
|
||||
Revises: a5c220713937
|
||||
Create Date: 2025-11-17 03:45:25.123939
|
||||
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import time
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "37f288994c47"
|
||||
down_revision: Union[str, None] = "a5c220713937"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Create new table
|
||||
op.create_table(
|
||||
"group_member",
|
||||
sa.Column("id", sa.Text(), primary_key=True, unique=True, nullable=False),
|
||||
sa.Column(
|
||||
"group_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("group.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.Text(),
|
||||
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.BigInteger(), nullable=True),
|
||||
sa.UniqueConstraint("group_id", "user_id", name="uq_group_member_group_user"),
|
||||
)
|
||||
|
||||
connection = op.get_bind()
|
||||
|
||||
# 2. Read existing group with user_ids JSON column
|
||||
group_table = sa.Table(
|
||||
"group",
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("user_ids", sa.JSON()), # JSON stored as text in SQLite + PG
|
||||
)
|
||||
|
||||
results = connection.execute(
|
||||
sa.select(group_table.c.id, group_table.c.user_ids)
|
||||
).fetchall()
|
||||
|
||||
print(results)
|
||||
|
||||
# 3. Insert members into group_member table
|
||||
gm_table = sa.Table(
|
||||
"group_member",
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("group_id", sa.Text()),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("created_at", sa.BigInteger()),
|
||||
sa.Column("updated_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
for group_id, user_ids in results:
|
||||
if not user_ids:
|
||||
continue
|
||||
|
||||
if isinstance(user_ids, str):
|
||||
try:
|
||||
user_ids = json.loads(user_ids)
|
||||
except Exception:
|
||||
continue # skip invalid JSON
|
||||
|
||||
if not isinstance(user_ids, list):
|
||||
continue
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"group_id": group_id,
|
||||
"user_id": uid,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
for uid in user_ids
|
||||
]
|
||||
|
||||
if rows:
|
||||
connection.execute(gm_table.insert(), rows)
|
||||
|
||||
# 4. Optionally drop the old column
|
||||
with op.batch_alter_table("group") as batch:
|
||||
batch.drop_column("user_ids")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Reverse: restore user_ids column
|
||||
with op.batch_alter_table("group") as batch:
|
||||
batch.add_column(sa.Column("user_ids", sa.JSON()))
|
||||
|
||||
connection = op.get_bind()
|
||||
gm_table = sa.Table(
|
||||
"group_member",
|
||||
sa.MetaData(),
|
||||
sa.Column("group_id", sa.Text()),
|
||||
sa.Column("user_id", sa.Text()),
|
||||
sa.Column("created_at", sa.BigInteger()),
|
||||
sa.Column("updated_at", sa.BigInteger()),
|
||||
)
|
||||
|
||||
group_table = sa.Table(
|
||||
"group",
|
||||
sa.MetaData(),
|
||||
sa.Column("id", sa.Text()),
|
||||
sa.Column("user_ids", sa.JSON()),
|
||||
)
|
||||
|
||||
# Build JSON arrays again
|
||||
results = connection.execute(sa.select(group_table.c.id)).fetchall()
|
||||
|
||||
for (group_id,) in results:
|
||||
members = connection.execute(
|
||||
sa.select(gm_table.c.user_id).where(gm_table.c.group_id == group_id)
|
||||
).fetchall()
|
||||
|
||||
member_ids = [m[0] for m in members]
|
||||
|
||||
connection.execute(
|
||||
group_table.update()
|
||||
.where(group_table.c.id == group_id)
|
||||
.values(user_ids=member_ids)
|
||||
)
|
||||
|
||||
# Drop the new table
|
||||
op.drop_table("group_member")
|
||||
|
|
@ -11,7 +11,7 @@ from open_webui.models.files import FileMetadataResponse
|
|||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON, func
|
||||
from sqlalchemy import BigInteger, Column, String, Text, JSON, func, ForeignKey
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -35,7 +35,6 @@ class Group(Base):
|
|||
meta = Column(JSON, nullable=True)
|
||||
|
||||
permissions = Column(JSON, nullable=True)
|
||||
user_ids = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
|
@ -53,12 +52,33 @@ class GroupModel(BaseModel):
|
|||
meta: Optional[dict] = None
|
||||
|
||||
permissions: Optional[dict] = None
|
||||
user_ids: list[str] = []
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
|
||||
class GroupMember(Base):
|
||||
__tablename__ = "group_member"
|
||||
|
||||
id = Column(Text, unique=True, primary_key=True)
|
||||
group_id = Column(
|
||||
Text,
|
||||
ForeignKey("group.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id = Column(Text, nullable=False)
|
||||
created_at = Column(BigInteger, nullable=True)
|
||||
updated_at = Column(BigInteger, nullable=True)
|
||||
|
||||
|
||||
class GroupMemberModel(BaseModel):
|
||||
id: str
|
||||
group_id: str
|
||||
user_id: str
|
||||
created_at: Optional[int] = None # timestamp in epoch
|
||||
updated_at: Optional[int] = None # timestamp in epoch
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
|
@ -72,7 +92,7 @@ class GroupResponse(BaseModel):
|
|||
permissions: Optional[dict] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
user_ids: list[str] = []
|
||||
member_count: Optional[int] = None
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
|
|
@ -87,7 +107,7 @@ class UserIdsForm(BaseModel):
|
|||
user_ids: Optional[list[str]] = None
|
||||
|
||||
|
||||
class GroupUpdateForm(GroupForm, UserIdsForm):
|
||||
class GroupUpdateForm(GroupForm):
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -131,12 +151,8 @@ class GroupTable:
|
|||
return [
|
||||
GroupModel.model_validate(group)
|
||||
for group in db.query(Group)
|
||||
.filter(
|
||||
func.json_array_length(Group.user_ids) > 0
|
||||
) # Ensure array exists
|
||||
.filter(
|
||||
Group.user_ids.cast(String).like(f'%"{user_id}"%')
|
||||
) # String-based check
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.filter(GroupMember.user_id == user_id)
|
||||
.order_by(Group.updated_at.desc())
|
||||
.all()
|
||||
]
|
||||
|
|
@ -149,13 +165,47 @@ class GroupTable:
|
|||
except Exception:
|
||||
return None
|
||||
|
||||
def get_group_user_ids_by_id(self, id: str) -> Optional[str]:
|
||||
group = self.get_group_by_id(id)
|
||||
if group:
|
||||
return group.user_ids
|
||||
else:
|
||||
def get_group_user_ids_by_id(self, id: str) -> Optional[list[str]]:
|
||||
with get_db() as db:
|
||||
members = (
|
||||
db.query(GroupMember.user_id).filter(GroupMember.group_id == id).all()
|
||||
)
|
||||
|
||||
if not members:
|
||||
return None
|
||||
|
||||
return [m[0] for m in members]
|
||||
|
||||
def set_group_user_ids_by_id(self, group_id: str, user_ids: list[str]) -> None:
|
||||
with get_db() as db:
|
||||
# Delete existing members
|
||||
db.query(GroupMember).filter(GroupMember.group_id == group_id).delete()
|
||||
|
||||
# Insert new members
|
||||
now = int(time.time())
|
||||
new_members = [
|
||||
GroupMember(
|
||||
id=str(uuid.uuid4()),
|
||||
group_id=group_id,
|
||||
user_id=user_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
for user_id in user_ids
|
||||
]
|
||||
|
||||
db.add_all(new_members)
|
||||
db.commit()
|
||||
|
||||
def get_group_member_count_by_id(self, id: str) -> int:
|
||||
with get_db() as db:
|
||||
count = (
|
||||
db.query(func.count(GroupMember.user_id))
|
||||
.filter(GroupMember.group_id == id)
|
||||
.scalar()
|
||||
)
|
||||
return count if count else 0
|
||||
|
||||
def update_group_by_id(
|
||||
self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
|
||||
) -> Optional[GroupModel]:
|
||||
|
|
@ -195,20 +245,29 @@ class GroupTable:
|
|||
def remove_user_from_all_groups(self, user_id: str) -> bool:
|
||||
with get_db() as db:
|
||||
try:
|
||||
groups = self.get_groups_by_member_id(user_id)
|
||||
|
||||
for group in groups:
|
||||
group.user_ids.remove(user_id)
|
||||
db.query(Group).filter_by(id=group.id).update(
|
||||
{
|
||||
"user_ids": group.user_ids,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
# Find all groups the user belongs to
|
||||
groups = (
|
||||
db.query(Group)
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.filter(GroupMember.user_id == user_id)
|
||||
.all()
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Remove the user from each group
|
||||
for group in groups:
|
||||
db.query(GroupMember).filter(
|
||||
GroupMember.group_id == group.id, GroupMember.user_id == user_id
|
||||
).delete()
|
||||
|
||||
db.query(Group).filter_by(id=group.id).update(
|
||||
{"updated_at": int(time.time())}
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
def create_groups_by_group_names(
|
||||
|
|
@ -246,37 +305,61 @@ class GroupTable:
|
|||
def sync_groups_by_group_names(self, user_id: str, group_names: list[str]) -> bool:
|
||||
with get_db() as db:
|
||||
try:
|
||||
groups = db.query(Group).filter(Group.name.in_(group_names)).all()
|
||||
group_ids = [group.id for group in groups]
|
||||
now = int(time.time())
|
||||
|
||||
# Remove user from groups not in the new list
|
||||
existing_groups = self.get_groups_by_member_id(user_id)
|
||||
# 1. Groups that SHOULD contain the user
|
||||
target_groups = (
|
||||
db.query(Group).filter(Group.name.in_(group_names)).all()
|
||||
)
|
||||
target_group_ids = {g.id for g in target_groups}
|
||||
|
||||
for group in existing_groups:
|
||||
if group.id not in group_ids:
|
||||
group.user_ids.remove(user_id)
|
||||
db.query(Group).filter_by(id=group.id).update(
|
||||
{
|
||||
"user_ids": group.user_ids,
|
||||
"updated_at": int(time.time()),
|
||||
# 2. Groups the user is CURRENTLY in
|
||||
existing_group_ids = {
|
||||
g.id
|
||||
for g in db.query(Group)
|
||||
.join(GroupMember, GroupMember.group_id == Group.id)
|
||||
.filter(GroupMember.user_id == user_id)
|
||||
.all()
|
||||
}
|
||||
|
||||
# 3. Determine adds + removals
|
||||
groups_to_add = target_group_ids - existing_group_ids
|
||||
groups_to_remove = existing_group_ids - target_group_ids
|
||||
|
||||
# 4. Remove in one bulk delete
|
||||
if groups_to_remove:
|
||||
db.query(GroupMember).filter(
|
||||
GroupMember.user_id == user_id,
|
||||
GroupMember.group_id.in_(groups_to_remove),
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
db.query(Group).filter(Group.id.in_(groups_to_remove)).update(
|
||||
{"updated_at": now}, synchronize_session=False
|
||||
)
|
||||
|
||||
# Add user to new groups
|
||||
for group in groups:
|
||||
if user_id not in group.user_ids:
|
||||
group.user_ids.append(user_id)
|
||||
db.query(Group).filter_by(id=group.id).update(
|
||||
{
|
||||
"user_ids": group.user_ids,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
# 5. Bulk insert missing memberships
|
||||
for group_id in groups_to_add:
|
||||
db.add(
|
||||
GroupMember(
|
||||
id=str(uuid.uuid4()),
|
||||
group_id=group_id,
|
||||
user_id=user_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
if groups_to_add:
|
||||
db.query(Group).filter(Group.id.in_(groups_to_add)).update(
|
||||
{"updated_at": now}, synchronize_session=False
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
db.rollback()
|
||||
return False
|
||||
|
||||
def add_users_to_group(
|
||||
|
|
@ -288,21 +371,31 @@ class GroupTable:
|
|||
if not group:
|
||||
return None
|
||||
|
||||
group_user_ids = group.user_ids
|
||||
if not group_user_ids or not isinstance(group_user_ids, list):
|
||||
group_user_ids = []
|
||||
now = int(time.time())
|
||||
|
||||
group_user_ids = list(set(group_user_ids)) # Deduplicate
|
||||
for user_id in user_ids or []:
|
||||
try:
|
||||
db.add(
|
||||
GroupMember(
|
||||
id=str(uuid.uuid4()),
|
||||
group_id=id,
|
||||
user_id=user_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
db.flush() # Detect unique constraint violation early
|
||||
except Exception:
|
||||
db.rollback() # Clear failed INSERT
|
||||
db.begin() # Start a new transaction
|
||||
continue # Duplicate → ignore
|
||||
|
||||
for user_id in user_ids:
|
||||
if user_id not in group_user_ids:
|
||||
group_user_ids.append(user_id)
|
||||
|
||||
group.user_ids = group_user_ids
|
||||
group.updated_at = int(time.time())
|
||||
group.updated_at = now
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
|
|
@ -316,23 +409,22 @@ class GroupTable:
|
|||
if not group:
|
||||
return None
|
||||
|
||||
group_user_ids = group.user_ids
|
||||
|
||||
if not group_user_ids or not isinstance(group_user_ids, list):
|
||||
if not user_ids:
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
group_user_ids = list(set(group_user_ids)) # Deduplicate
|
||||
|
||||
# Remove each user from group_member
|
||||
for user_id in user_ids:
|
||||
if user_id in group_user_ids:
|
||||
group_user_ids.remove(user_id)
|
||||
db.query(GroupMember).filter(
|
||||
GroupMember.group_id == id, GroupMember.user_id == user_id
|
||||
).delete()
|
||||
|
||||
group.user_ids = group_user_ids
|
||||
# Update group timestamp
|
||||
group.updated_at = int(time.time())
|
||||
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return GroupModel.model_validate(group)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from open_webui.internal.db import Base, JSONField, get_db
|
|||
|
||||
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.groups import Groups, GroupMember
|
||||
from open_webui.utils.misc import throttle
|
||||
|
||||
|
||||
|
|
@ -95,8 +95,12 @@ class UpdateProfileForm(BaseModel):
|
|||
date_of_birth: Optional[datetime.date] = None
|
||||
|
||||
|
||||
class UserGroupIdsModel(UserModel):
|
||||
group_ids: list[str] = []
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserModel]
|
||||
users: list[UserGroupIdsModel]
|
||||
total: int
|
||||
|
||||
|
||||
|
|
@ -222,7 +226,10 @@ class UsersTable:
|
|||
limit: Optional[int] = None,
|
||||
) -> dict:
|
||||
with get_db() as db:
|
||||
query = db.query(User)
|
||||
# Join GroupMember so we can order by group_id when requested
|
||||
query = db.query(User).outerjoin(
|
||||
GroupMember, GroupMember.user_id == User.id
|
||||
)
|
||||
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
|
|
@ -237,7 +244,16 @@ class UsersTable:
|
|||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by == "name":
|
||||
if order_by and order_by.startswith("group_id:"):
|
||||
group_id = order_by.split(":", 1)[1]
|
||||
|
||||
if direction == "asc":
|
||||
query = query.order_by((GroupMember.group_id == group_id).asc())
|
||||
else:
|
||||
query = query.order_by(
|
||||
(GroupMember.group_id == group_id).desc()
|
||||
)
|
||||
elif order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(User.name.asc())
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -33,9 +33,18 @@ router = APIRouter()
|
|||
@router.get("/", response_model=list[GroupResponse])
|
||||
async def get_groups(user=Depends(get_verified_user)):
|
||||
if user.role == "admin":
|
||||
return Groups.get_groups()
|
||||
groups = Groups.get_groups()
|
||||
else:
|
||||
return Groups.get_groups_by_member_id(user.id)
|
||||
groups = Groups.get_groups_by_member_id(user.id)
|
||||
|
||||
return [
|
||||
GroupResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id),
|
||||
)
|
||||
for group in groups
|
||||
if group
|
||||
]
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -48,7 +57,10 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
|
|||
try:
|
||||
group = Groups.insert_new_group(user.id, form_data)
|
||||
if group:
|
||||
return group
|
||||
return GroupResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -71,7 +83,10 @@ async def create_new_group(form_data: GroupForm, user=Depends(get_admin_user)):
|
|||
async def get_group_by_id(id: str, user=Depends(get_admin_user)):
|
||||
group = Groups.get_group_by_id(id)
|
||||
if group:
|
||||
return group
|
||||
return GroupResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -89,12 +104,12 @@ async def update_group_by_id(
|
|||
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
|
||||
):
|
||||
try:
|
||||
if form_data.user_ids:
|
||||
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
|
||||
|
||||
group = Groups.update_group_by_id(id, form_data)
|
||||
if group:
|
||||
return group
|
||||
return GroupResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -123,7 +138,10 @@ async def add_user_to_group(
|
|||
|
||||
group = Groups.add_users_to_group(id, form_data.user_ids)
|
||||
if group:
|
||||
return group
|
||||
return GroupResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -144,7 +162,10 @@ async def remove_users_from_group(
|
|||
try:
|
||||
group = Groups.remove_users_from_group(id, form_data.user_ids)
|
||||
if group:
|
||||
return group
|
||||
return GroupResponse(
|
||||
**group.model_dump(),
|
||||
member_count=Groups.get_group_member_count_by_id(group.id),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
|
|||
|
|
@ -349,8 +349,10 @@ def user_to_scim(user: UserModel, request: Request) -> SCIMUser:
|
|||
|
||||
def group_to_scim(group: GroupModel, request: Request) -> SCIMGroup:
|
||||
"""Convert internal Group model to SCIM Group"""
|
||||
member_ids = Groups.get_group_user_ids_by_id(group.id)
|
||||
members = []
|
||||
for user_id in group.user_ids:
|
||||
|
||||
for user_id in member_ids:
|
||||
user = Users.get_user_by_id(user_id)
|
||||
if user:
|
||||
members.append(
|
||||
|
|
@ -796,9 +798,11 @@ async def create_group(
|
|||
update_form = GroupUpdateForm(
|
||||
name=new_group.name,
|
||||
description=new_group.description,
|
||||
user_ids=member_ids,
|
||||
)
|
||||
|
||||
Groups.update_group_by_id(new_group.id, update_form)
|
||||
Groups.set_group_user_ids_by_id(new_group.id, member_ids)
|
||||
|
||||
new_group = Groups.get_group_by_id(new_group.id)
|
||||
|
||||
return group_to_scim(new_group, request)
|
||||
|
|
@ -830,7 +834,7 @@ async def update_group(
|
|||
# Handle members if provided
|
||||
if group_data.members is not None:
|
||||
member_ids = [member.value for member in group_data.members]
|
||||
update_form.user_ids = member_ids
|
||||
Groups.set_group_user_ids_by_id(group_id, member_ids)
|
||||
|
||||
# Update group
|
||||
updated_group = Groups.update_group_by_id(group_id, update_form)
|
||||
|
|
@ -863,7 +867,6 @@ async def patch_group(
|
|||
update_form = GroupUpdateForm(
|
||||
name=group.name,
|
||||
description=group.description,
|
||||
user_ids=group.user_ids.copy() if group.user_ids else [],
|
||||
)
|
||||
|
||||
for operation in patch_data.Operations:
|
||||
|
|
@ -876,21 +879,22 @@ async def patch_group(
|
|||
update_form.name = value
|
||||
elif path == "members":
|
||||
# Replace all members
|
||||
update_form.user_ids = [member["value"] for member in value]
|
||||
Groups.set_group_user_ids_by_id(
|
||||
group_id, [member["value"] for member in value]
|
||||
)
|
||||
|
||||
elif op == "add":
|
||||
if path == "members":
|
||||
# Add members
|
||||
if isinstance(value, list):
|
||||
for member in value:
|
||||
if isinstance(member, dict) and "value" in member:
|
||||
if member["value"] not in update_form.user_ids:
|
||||
update_form.user_ids.append(member["value"])
|
||||
Groups.add_users_to_group(group_id, [member["value"]])
|
||||
elif op == "remove":
|
||||
if path and path.startswith("members[value eq"):
|
||||
# Remove specific member
|
||||
member_id = path.split('"')[1]
|
||||
if member_id in update_form.user_ids:
|
||||
update_form.user_ids.remove(member_id)
|
||||
Groups.remove_users_from_group(group_id, [member_id])
|
||||
|
||||
# Update group
|
||||
updated_group = Groups.update_group_by_id(group_id, update_form)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from open_webui.models.groups import Groups
|
|||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.users import (
|
||||
UserModel,
|
||||
UserGroupIdsModel,
|
||||
UserListResponse,
|
||||
UserInfoListResponse,
|
||||
UserIdNameListResponse,
|
||||
|
|
@ -91,7 +92,25 @@ async def get_users(
|
|||
if direction:
|
||||
filter["direction"] = direction
|
||||
|
||||
return Users.get_users(filter=filter, skip=skip, limit=limit)
|
||||
result = Users.get_users(filter=filter, skip=skip, limit=limit)
|
||||
|
||||
users = result["users"]
|
||||
total = result["total"]
|
||||
|
||||
return {
|
||||
"users": [
|
||||
UserGroupIdsModel(
|
||||
**{
|
||||
**user.model_dump(),
|
||||
"group_ids": [
|
||||
group.id for group in Groups.get_groups_by_member_id(user.id)
|
||||
],
|
||||
}
|
||||
)
|
||||
for user in users
|
||||
],
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/all", response_model=UserInfoListResponse)
|
||||
|
|
|
|||
|
|
@ -1137,22 +1137,21 @@ class OAuthManager:
|
|||
f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
|
||||
)
|
||||
|
||||
user_ids = group_model.user_ids
|
||||
user_ids = [i for i in user_ids if i != user.id]
|
||||
Groups.remove_users_from_group(group_model.id, [user.id])
|
||||
|
||||
# In case a group is created, but perms are never assigned to the group by hitting "save"
|
||||
group_permissions = group_model.permissions
|
||||
if not group_permissions:
|
||||
group_permissions = default_permissions
|
||||
|
||||
update_form = GroupUpdateForm(
|
||||
Groups.update_group_by_id(
|
||||
id=group_model.id,
|
||||
form_data=GroupUpdateForm(
|
||||
name=group_model.name,
|
||||
description=group_model.description,
|
||||
permissions=group_permissions,
|
||||
user_ids=user_ids,
|
||||
)
|
||||
Groups.update_group_by_id(
|
||||
id=group_model.id, form_data=update_form, overwrite=False
|
||||
),
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
# Add user to new groups
|
||||
|
|
@ -1168,22 +1167,21 @@ class OAuthManager:
|
|||
f"Adding user to group {group_model.name} as it was found in their oauth groups"
|
||||
)
|
||||
|
||||
user_ids = group_model.user_ids
|
||||
user_ids.append(user.id)
|
||||
Groups.add_users_to_group(group_model.id, [user.id])
|
||||
|
||||
# In case a group is created, but perms are never assigned to the group by hitting "save"
|
||||
group_permissions = group_model.permissions
|
||||
if not group_permissions:
|
||||
group_permissions = default_permissions
|
||||
|
||||
update_form = GroupUpdateForm(
|
||||
Groups.update_group_by_id(
|
||||
id=group_model.id,
|
||||
form_data=GroupUpdateForm(
|
||||
name=group_model.name,
|
||||
description=group_model.description,
|
||||
permissions=group_permissions,
|
||||
user_ids=user_ids,
|
||||
)
|
||||
Groups.update_group_by_id(
|
||||
id=group_model.id, form_data=update_form, overwrite=False
|
||||
),
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
async def _process_picture_url(
|
||||
|
|
|
|||
|
|
@ -160,3 +160,73 @@ export const deleteGroupById = async (token: string, id: string) => {
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const addUserToGroup = async (token: string, id: string, userIds: string[]) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/users/add`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_ids: userIds
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const removeUserFromGroup = async (token: string, id: string, userIds: string[]) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/groups/id/${id}/users/remove`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_ids: userIds
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,9 +33,6 @@
|
|||
|
||||
let loaded = false;
|
||||
|
||||
let users = [];
|
||||
let total = 0;
|
||||
|
||||
let groups = [];
|
||||
let filteredGroups;
|
||||
|
||||
|
|
@ -93,16 +90,6 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const res = await getAllUsers(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
users = res.users;
|
||||
total = res.total;
|
||||
}
|
||||
|
||||
defaultPermissions = await getUserDefaultPermissions(localStorage.token);
|
||||
await setGroups();
|
||||
loaded = true;
|
||||
|
|
@ -189,7 +176,7 @@
|
|||
|
||||
{#each filteredGroups as group}
|
||||
<div class="my-2">
|
||||
<GroupItem {group} {users} {setGroups} {defaultPermissions} />
|
||||
<GroupItem {group} {setGroups} {defaultPermissions} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
export let name = '';
|
||||
export let color = '';
|
||||
export let description = '';
|
||||
|
||||
export let edit = false;
|
||||
export let onDelete: Function = () => {};
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2">
|
||||
|
|
@ -59,3 +62,18 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if edit}
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Actions')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<button
|
||||
class="text-xs bg-transparent hover:underline cursor-pointer"
|
||||
on:click={() => onDelete()}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
export let show = false;
|
||||
export let edit = false;
|
||||
|
||||
export let users = [];
|
||||
export let group = null;
|
||||
export let defaultPermissions = {};
|
||||
|
||||
|
|
@ -31,6 +30,8 @@
|
|||
let loading = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
let userCount = 0;
|
||||
|
||||
export let name = '';
|
||||
export let description = '';
|
||||
|
||||
|
|
@ -83,7 +84,6 @@
|
|||
notes: true
|
||||
}
|
||||
};
|
||||
export let userIds = [];
|
||||
|
||||
const submitHandler = async () => {
|
||||
loading = true;
|
||||
|
|
@ -91,8 +91,7 @@
|
|||
const group = {
|
||||
name,
|
||||
description,
|
||||
permissions,
|
||||
user_ids: userIds
|
||||
permissions
|
||||
};
|
||||
|
||||
await onSubmit(group);
|
||||
|
|
@ -107,7 +106,7 @@
|
|||
description = group.description;
|
||||
permissions = group?.permissions ?? {};
|
||||
|
||||
userIds = group?.user_ids ?? [];
|
||||
userCount = group?.member_count ?? 0;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -129,7 +128,7 @@
|
|||
}}
|
||||
/>
|
||||
|
||||
<Modal size="md" bind:show>
|
||||
<Modal size="lg" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 mb-1.5">
|
||||
<div class=" text-lg font-medium self-center font-primary">
|
||||
|
|
@ -228,20 +227,27 @@
|
|||
<div class=" self-center mr-2">
|
||||
<UserPlusSolid />
|
||||
</div>
|
||||
<div class=" self-center">{$i18n.t('Users')} ({userIds.length})</div>
|
||||
<div class=" self-center">{$i18n.t('Users')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 mt-1 lg:mt-1 lg:h-[22rem] lg:max-h-[22rem] overflow-y-auto scrollbar-hidden"
|
||||
class="flex-1 mt-1 lg:mt-1 lg:h-[30rem] lg:max-h-[30rem] overflow-y-auto scrollbar-hidden"
|
||||
>
|
||||
{#if selectedTab == 'general'}
|
||||
<Display bind:name bind:description />
|
||||
<Display
|
||||
bind:name
|
||||
bind:description
|
||||
{edit}
|
||||
onDelete={() => {
|
||||
showDeleteConfirmDialog = true;
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab == 'permissions'}
|
||||
<Permissions bind:permissions {defaultPermissions} />
|
||||
{:else if selectedTab == 'users'}
|
||||
<Users bind:userIds {users} />
|
||||
<Users bind:userCount groupId={group?.id} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -295,21 +301,8 @@
|
|||
{/if}
|
||||
</div> -->
|
||||
|
||||
<div class="flex justify-between pt-3 text-sm font-medium gap-1.5">
|
||||
{#if edit}
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showDeleteConfirmDialog = true;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
{#if ['general', 'permissions'].includes(selectedTab)}
|
||||
<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
|
||||
? ' cursor-not-allowed'
|
||||
|
|
@ -326,6 +319,7 @@
|
|||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
|
||||
import GroupModal from './EditGroupModal.svelte';
|
||||
|
||||
export let users = [];
|
||||
export let group = {
|
||||
name: 'Admins',
|
||||
user_ids: [1, 2, 3]
|
||||
|
|
@ -58,7 +57,6 @@
|
|||
<GroupModal
|
||||
bind:show={showEdit}
|
||||
edit
|
||||
{users}
|
||||
{group}
|
||||
{defaultPermissions}
|
||||
onSubmit={updateHandler}
|
||||
|
|
@ -81,7 +79,7 @@
|
|||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 w-fit font-medium text-right justify-end">
|
||||
{group.user_ids.length}
|
||||
{group?.member_count}
|
||||
|
||||
<div>
|
||||
<User className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -2,50 +2,80 @@
|
|||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { getUsers } from '$lib/apis/users';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
import Checkbox from '$lib/components/common/Checkbox.svelte';
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
import Search from '$lib/components/icons/Search.svelte';
|
||||
import Pagination from '$lib/components/common/Pagination.svelte';
|
||||
import { addUserToGroup, removeUserFromGroup } from '$lib/apis/groups';
|
||||
|
||||
export let users = [];
|
||||
export let userIds = [];
|
||||
export let groupId: string;
|
||||
export let userCount = 0;
|
||||
|
||||
let filteredUsers = [];
|
||||
|
||||
$: filteredUsers = users
|
||||
.filter((user) => {
|
||||
if (query === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
user.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aUserIndex = userIds.indexOf(a.id);
|
||||
const bUserIndex = userIds.indexOf(b.id);
|
||||
|
||||
// Compare based on userIds or fall back to alphabetical order
|
||||
if (aUserIndex !== -1 && bUserIndex === -1) return -1; // 'a' has valid userId -> prioritize
|
||||
if (bUserIndex !== -1 && aUserIndex === -1) return 1; // 'b' has valid userId -> prioritize
|
||||
|
||||
// Both a and b are either in the userIds array or not, so we'll sort them by their indices
|
||||
if (aUserIndex !== -1 && bUserIndex !== -1) return aUserIndex - bUserIndex;
|
||||
|
||||
// If both are not in the userIds, fallback to alphabetical sorting by name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
let users = [];
|
||||
let total = 0;
|
||||
|
||||
let query = '';
|
||||
let page = 1;
|
||||
|
||||
const getUserList = async () => {
|
||||
try {
|
||||
const res = await getUsers(
|
||||
localStorage.token,
|
||||
query,
|
||||
`group_id:${groupId}`,
|
||||
null,
|
||||
page
|
||||
).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
users = res.users;
|
||||
total = res.total;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMember = async (userId, state) => {
|
||||
if (state === 'checked') {
|
||||
await addUserToGroup(localStorage.token, groupId, [userId]).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
await removeUserFromGroup(localStorage.token, groupId, [userId]).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
page = 1;
|
||||
getUserList();
|
||||
};
|
||||
|
||||
$: if (page) {
|
||||
getUserList();
|
||||
}
|
||||
|
||||
$: if (query !== null) {
|
||||
getUserList();
|
||||
}
|
||||
|
||||
$: if (query) {
|
||||
page = 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex w-full">
|
||||
<div class="flex flex-1">
|
||||
<div class=" max-h-full h-full w-full flex flex-col overflow-y-hidden">
|
||||
<div class="w-full h-fit mb-1.5">
|
||||
<div class="flex flex-1 h-fit">
|
||||
<div class=" self-center mr-3">
|
||||
<Search />
|
||||
</div>
|
||||
|
|
@ -57,20 +87,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 scrollbar-hidden">
|
||||
<div class="flex-1 overflow-y-auto scrollbar-hidden">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
{#if filteredUsers.length > 0}
|
||||
{#each filteredUsers as user, userIdx (user.id)}
|
||||
{#if users.length > 0}
|
||||
{#each users as user, userIdx (user.id)}
|
||||
<div class="flex flex-row items-center gap-3 w-full text-sm">
|
||||
<div class="flex items-center">
|
||||
<Checkbox
|
||||
state={userIds.includes(user.id) ? 'checked' : 'unchecked'}
|
||||
state={(user?.group_ids ?? []).includes(groupId) ? 'checked' : 'unchecked'}
|
||||
on:change={(e) => {
|
||||
if (e.detail === 'checked') {
|
||||
userIds = [...userIds, user.id];
|
||||
} else {
|
||||
userIds = userIds.filter((id) => id !== user.id);
|
||||
}
|
||||
toggleMember(user.id, e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -82,7 +108,7 @@
|
|||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{#if userIds.includes(user.id)}
|
||||
{#if (user?.group_ids ?? []).includes(groupId)}
|
||||
<Badge type="success" content="member" />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -95,4 +121,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if total > 30}
|
||||
<Pagination bind:page count={total} perPage={30} />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue