[OND211-2329]: Updated add users API and tests to add users with a role(invite/normal/admin).
This commit is contained in:
parent
abeb37a9b5
commit
55e0ace936
4 changed files with 146 additions and 110 deletions
|
|
@ -840,8 +840,8 @@ async def update_request(tenant_id: str) -> Response:
|
|||
@validate_request("users")
|
||||
async def add_users(tenant_id: str) -> Response:
|
||||
"""
|
||||
Send invitations to one or more users to join a team. Only OWNER or ADMIN can send invitations.
|
||||
Users must accept the invitation before they are added to the team.
|
||||
Add one or more users directly to a team. Only OWNER or ADMIN can add users.
|
||||
Users are added immediately without requiring invitation acceptance.
|
||||
Supports both single user and bulk operations.
|
||||
|
||||
---
|
||||
|
|
@ -877,8 +877,8 @@ async def add_users(tenant_id: str) -> Response:
|
|||
description: User email
|
||||
role:
|
||||
type: string
|
||||
description: Role to assign (normal, admin). Defaults to normal.
|
||||
enum: [normal, admin]
|
||||
description: Role to assign (normal, admin, invite). Defaults to normal.
|
||||
enum: [normal, admin, invite]
|
||||
responses:
|
||||
200:
|
||||
description: Users added successfully
|
||||
|
|
@ -934,7 +934,28 @@ async def add_users(tenant_id: str) -> Response:
|
|||
role = UserTenantRole.NORMAL.value
|
||||
elif isinstance(user_input, dict):
|
||||
email = user_input.get("email")
|
||||
role = user_input.get("role", UserTenantRole.NORMAL.value)
|
||||
role_input = user_input.get("role")
|
||||
# Normalize role to lowercase and validate
|
||||
if role_input is not None:
|
||||
if isinstance(role_input, str):
|
||||
role_input = role_input.lower().strip()
|
||||
if role_input in [UserTenantRole.NORMAL.value, UserTenantRole.ADMIN.value, UserTenantRole.INVITE.value]:
|
||||
role = role_input
|
||||
else:
|
||||
failed_users.append({
|
||||
"email": email or str(user_input),
|
||||
"error": f"Invalid role '{role_input}'. Allowed roles: {UserTenantRole.NORMAL.value}, {UserTenantRole.ADMIN.value}, {UserTenantRole.INVITE.value}"
|
||||
})
|
||||
continue
|
||||
else:
|
||||
failed_users.append({
|
||||
"email": email or str(user_input),
|
||||
"error": f"Role must be a string. Allowed values: {UserTenantRole.NORMAL.value}, {UserTenantRole.ADMIN.value}, {UserTenantRole.INVITE.value}"
|
||||
})
|
||||
continue
|
||||
# If role not provided, default to NORMAL
|
||||
else:
|
||||
role = UserTenantRole.NORMAL.value
|
||||
else:
|
||||
failed_users.append({
|
||||
"email": str(user_input),
|
||||
|
|
@ -949,14 +970,6 @@ async def add_users(tenant_id: str) -> Response:
|
|||
})
|
||||
continue
|
||||
|
||||
# Validate role
|
||||
if role not in [UserTenantRole.NORMAL.value, UserTenantRole.ADMIN.value]:
|
||||
failed_users.append({
|
||||
"email": email,
|
||||
"error": f"Invalid role '{role}'. Allowed roles: {UserTenantRole.NORMAL.value}, {UserTenantRole.ADMIN.value}"
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
# Find user by email
|
||||
invite_users: List[Any] = UserService.query(email=email)
|
||||
|
|
@ -985,20 +998,22 @@ async def add_users(tenant_id: str) -> Response:
|
|||
"error": "User is the owner of the team and cannot be added again."
|
||||
})
|
||||
continue
|
||||
# If user has INVITE role, resend invitation with new role (update the invitation)
|
||||
# If user has INVITE role, convert it to the actual role (forcefully add them)
|
||||
if existing_role == UserTenantRole.INVITE:
|
||||
# Update invitation - keep INVITE role, user needs to accept again
|
||||
# Note: The intended role will be applied when user accepts via /agree endpoint
|
||||
# For now, we'll store it by updating the invitation (user will need to accept)
|
||||
# Set default permissions: read-only access to datasets and canvases
|
||||
default_permissions: Dict[str, Any] = {
|
||||
"dataset": {"create": False, "read": True, "update": False, "delete": False},
|
||||
"canvas": {"create": False, "read": True, "update": False, "delete": False}
|
||||
}
|
||||
# Update from INVITE to the specified role
|
||||
UserTenantService.filter_update(
|
||||
[UserTenant.tenant_id == tenant_id, UserTenant.user_id == user_id_to_add],
|
||||
{"role": role, "status": StatusEnum.VALID.value, "permissions": default_permissions}
|
||||
)
|
||||
usr: Dict[str, Any] = invite_users[0].to_dict()
|
||||
usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]}
|
||||
usr["role"] = "invite" # Still pending acceptance
|
||||
usr["intended_role"] = role # Store intended role for reference
|
||||
added_users.append({
|
||||
"email": email,
|
||||
"status": "invitation_resent",
|
||||
"intended_role": role
|
||||
})
|
||||
usr["role"] = role
|
||||
added_users.append(usr)
|
||||
continue
|
||||
|
||||
# Set default permissions: read-only access to datasets and canvases
|
||||
|
|
@ -1007,33 +1022,20 @@ async def add_users(tenant_id: str) -> Response:
|
|||
"canvas": {"create": False, "read": True, "update": False, "delete": False}
|
||||
}
|
||||
|
||||
# Send invitation - create user with INVITE role (user must accept to join)
|
||||
# Add user directly with the specified role (NORMAL, ADMIN, or INVITE)
|
||||
UserTenantService.save(
|
||||
id=get_uuid(),
|
||||
user_id=user_id_to_add,
|
||||
tenant_id=tenant_id,
|
||||
invited_by=current_user.id,
|
||||
role=UserTenantRole.INVITE, # Start with INVITE role
|
||||
role=role, # Directly assign the role (NORMAL, ADMIN, or INVITE)
|
||||
status=StatusEnum.VALID.value,
|
||||
permissions=default_permissions
|
||||
)
|
||||
|
||||
# Send invitation email if configured
|
||||
if smtp_mail_server and settings.SMTP_CONF:
|
||||
user_name: str = ""
|
||||
_, user = UserService.get_by_id(current_user.id)
|
||||
if user:
|
||||
user_name = user.nickname
|
||||
Thread(
|
||||
target=send_invite_email,
|
||||
args=(email, settings.MAIL_FRONTEND_URL, tenant_id, user_name or current_user.email),
|
||||
daemon=True
|
||||
).start()
|
||||
|
||||
usr: Dict[str, Any] = invite_users[0].to_dict()
|
||||
usr = {k: v for k, v in usr.items() if k in ["id", "avatar", "email", "nickname"]}
|
||||
usr["role"] = "invite" # User is invited, not yet added
|
||||
usr["intended_role"] = role # Role they will get after acceptance
|
||||
usr["role"] = role
|
||||
added_users.append(usr)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1057,12 +1059,12 @@ async def add_users(tenant_id: str) -> Response:
|
|||
elif failed_users:
|
||||
return get_json_result(
|
||||
data=result,
|
||||
message=f"Sent {len(added_users)} invitation(s). {len(failed_users)} user(s) failed."
|
||||
message=f"Added {len(added_users)} user(s). {len(failed_users)} user(s) failed."
|
||||
)
|
||||
else:
|
||||
return get_json_result(
|
||||
data=result,
|
||||
message=f"Successfully sent {len(added_users)} invitation(s). Users must accept to join the team."
|
||||
message=f"Successfully added {len(added_users)} user(s) to the team."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import pytest
|
|||
import requests
|
||||
|
||||
from common import (
|
||||
accept_team_invitation,
|
||||
add_users_to_team,
|
||||
create_dataset,
|
||||
create_team,
|
||||
|
|
@ -82,7 +81,7 @@ class TestDatasetPermissions:
|
|||
web_api_auth: RAGFlowWebApiAuth,
|
||||
test_team: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a team with a user who has accepted the invitation."""
|
||||
"""Create a team with a user who has been added to the team."""
|
||||
tenant_id: str = test_team["id"]
|
||||
|
||||
# Create user
|
||||
|
|
@ -98,19 +97,11 @@ class TestDatasetPermissions:
|
|||
assert user_res["code"] == 0
|
||||
user_id: str = user_res["data"]["id"]
|
||||
|
||||
# Add user to team
|
||||
# Add user to team (users are now added directly)
|
||||
add_payload: dict[str, list[str]] = {"users": [email]}
|
||||
add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
assert add_res["code"] == 0
|
||||
|
||||
# Small delay
|
||||
time.sleep(0.5)
|
||||
|
||||
# Accept invitation as the user
|
||||
user_auth: RAGFlowWebApiAuth = login_as_user(email, password)
|
||||
accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id)
|
||||
assert accept_res["code"] == 0
|
||||
|
||||
return {
|
||||
"team": test_team,
|
||||
"user": {"id": user_id, "email": email, "password": password},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#
|
||||
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
|
@ -140,7 +139,7 @@ class TestAddUsers:
|
|||
assert "added" in res["data"]
|
||||
assert len(res["data"]["added"]) == 1
|
||||
assert res["data"]["added"][0]["email"] == user_email
|
||||
assert res["data"]["added"][0]["role"] == "invite" # Users are added with invite role initially
|
||||
assert res["data"]["added"][0]["role"] == "normal" # Users are added directly with normal role
|
||||
assert "failed" in res["data"]
|
||||
assert len(res["data"]["failed"]) == 0
|
||||
|
||||
|
|
@ -163,8 +162,7 @@ class TestAddUsers:
|
|||
assert res["code"] == 0, res
|
||||
assert len(res["data"]["added"]) == 1
|
||||
assert res["data"]["added"][0]["email"] == user_email
|
||||
assert res["data"]["added"][0]["role"] == "invite" # Users are added with invite role initially
|
||||
assert res["data"]["added"][0]["intended_role"] == "admin" # Intended role after acceptance
|
||||
assert res["data"]["added"][0]["role"] == "admin" # Users are added directly with the specified role
|
||||
|
||||
@pytest.mark.p1
|
||||
def test_add_multiple_users(
|
||||
|
|
@ -207,12 +205,9 @@ class TestAddUsers:
|
|||
|
||||
assert res["code"] == 0, res
|
||||
assert len(res["data"]["added"]) == 3
|
||||
assert res["data"]["added"][0]["role"] == "invite" # String format defaults to invite role initially
|
||||
assert res["data"]["added"][0].get("intended_role") == "normal" # Intended role after acceptance
|
||||
assert res["data"]["added"][1]["role"] == "invite" # Object format - still invite initially
|
||||
assert res["data"]["added"][1]["intended_role"] == "admin" # Intended role after acceptance
|
||||
assert res["data"]["added"][2]["role"] == "invite" # String format defaults to invite role initially
|
||||
assert res["data"]["added"][2].get("intended_role") == "normal" # Intended role after acceptance
|
||||
assert res["data"]["added"][0]["role"] == "normal" # String format defaults to normal role
|
||||
assert res["data"]["added"][1]["role"] == "admin" # Object format with admin role
|
||||
assert res["data"]["added"][2]["role"] == "normal" # String format defaults to normal role
|
||||
|
||||
@pytest.mark.p1
|
||||
def test_add_user_unregistered_email(
|
||||
|
|
@ -248,11 +243,12 @@ class TestAddUsers:
|
|||
|
||||
# Try to add same user again
|
||||
res2: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
assert res2["code"] == 0 # Returns success - invitation is resent
|
||||
# API resends invitation instead of failing
|
||||
assert len(res2["data"]["added"]) == 1
|
||||
assert res2["data"]["added"][0]["email"] == user_email
|
||||
assert res2["data"]["added"][0].get("status") == "invitation_resent" or "intended_role" in res2["data"]["added"][0]
|
||||
# User is already a member, so it should be in failed list
|
||||
# When all users fail, API returns DATA_ERROR (102)
|
||||
assert res2["code"] == 102 # DATA_ERROR when all users fail
|
||||
assert len(res2["data"]["failed"]) == 1
|
||||
assert len(res2["data"]["added"]) == 0
|
||||
assert "already" in res2["data"]["failed"][0]["error"].lower() or "member" in res2["data"]["failed"][0]["error"].lower()
|
||||
|
||||
@pytest.mark.p1
|
||||
def test_add_users_partial_success(
|
||||
|
|
@ -318,7 +314,31 @@ class TestAddUsers:
|
|||
assert res["code"] == 102 # DATA_ERROR
|
||||
assert len(res["data"]["added"]) == 0
|
||||
assert len(res["data"]["failed"]) == 1
|
||||
assert "invalid role" in res["data"]["failed"][0]["error"].lower() or "invalid" in res["data"]["failed"][0]["error"].lower()
|
||||
error_msg = res["data"]["failed"][0]["error"].lower()
|
||||
assert "invalid role" in error_msg or "invalid" in error_msg
|
||||
# Verify error message mentions allowed roles (should include normal, admin, invite)
|
||||
assert "normal" in error_msg or "admin" in error_msg or "invite" in error_msg
|
||||
|
||||
@pytest.mark.p1
|
||||
def test_add_user_with_invite_role(
|
||||
self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any], test_users: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Test adding a user with invite role."""
|
||||
if not test_users:
|
||||
pytest.skip("No test users created")
|
||||
|
||||
tenant_id: str = test_team["id"]
|
||||
user_email: str = test_users[0]["email"]
|
||||
|
||||
add_payload: dict[str, list[dict[str, str]]] = {
|
||||
"users": [{"email": user_email, "role": "invite"}]
|
||||
}
|
||||
res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
|
||||
assert res["code"] == 0, res
|
||||
assert len(res["data"]["added"]) == 1
|
||||
assert res["data"]["added"][0]["email"] == user_email
|
||||
assert res["data"]["added"][0]["role"] == "invite" # Users can be added with invite role
|
||||
|
||||
@pytest.mark.p1
|
||||
def test_add_users_not_owner_or_admin(
|
||||
|
|
@ -340,9 +360,7 @@ class TestAddUsers:
|
|||
add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
assert add_res["code"] == 0
|
||||
|
||||
# Small delay to ensure user is fully added
|
||||
time.sleep(0.5)
|
||||
|
||||
# Users are now added directly, no need to wait for invitation acceptance
|
||||
# Login as the normal user
|
||||
normal_user_auth: RAGFlowWebApiAuth = login_as_user(user_email, test_users[0]["password"])
|
||||
|
||||
|
|
@ -382,3 +400,59 @@ class TestAddUsers:
|
|||
assert res["code"] != 0
|
||||
assert len(res["data"]["added"]) == 0
|
||||
|
||||
@pytest.mark.p1
|
||||
def test_add_users_with_different_roles(
|
||||
self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any], test_users: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Test adding users with different roles (normal, admin, and invite)."""
|
||||
if len(test_users) < 3:
|
||||
pytest.skip("Need at least 3 test users")
|
||||
|
||||
tenant_id: str = test_team["id"]
|
||||
|
||||
add_payload: dict[str, list[Any]] = {
|
||||
"users": [
|
||||
{"email": test_users[0]["email"], "role": "normal"},
|
||||
{"email": test_users[1]["email"], "role": "admin"},
|
||||
{"email": test_users[2]["email"], "role": "invite"},
|
||||
]
|
||||
}
|
||||
res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
|
||||
assert res["code"] == 0, res
|
||||
assert len(res["data"]["added"]) == 3
|
||||
assert len(res["data"]["failed"]) == 0
|
||||
# Verify roles are set correctly
|
||||
added_emails = {user["email"]: user["role"] for user in res["data"]["added"]}
|
||||
assert added_emails[test_users[0]["email"]] == "normal"
|
||||
assert added_emails[test_users[1]["email"]] == "admin"
|
||||
assert added_emails[test_users[2]["email"]] == "invite"
|
||||
|
||||
@pytest.mark.p1
|
||||
def test_add_users_role_case_insensitive(
|
||||
self, web_api_auth: RAGFlowWebApiAuth, test_team: dict[str, Any], test_users: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Test that role parameter is case-insensitive for all roles."""
|
||||
if len(test_users) < 3:
|
||||
pytest.skip("Need at least 3 test users")
|
||||
|
||||
tenant_id: str = test_team["id"]
|
||||
|
||||
# Test with uppercase roles - all should be normalized to lowercase
|
||||
add_payload: dict[str, list[dict[str, str]]] = {
|
||||
"users": [
|
||||
{"email": test_users[0]["email"], "role": "ADMIN"},
|
||||
{"email": test_users[1]["email"], "role": "NORMAL"},
|
||||
{"email": test_users[2]["email"], "role": "INVITE"},
|
||||
]
|
||||
}
|
||||
res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
|
||||
assert res["code"] == 0, res
|
||||
assert len(res["data"]["added"]) == 3
|
||||
# Verify all roles are normalized to lowercase
|
||||
added_emails = {user["email"]: user["role"] for user in res["data"]["added"]}
|
||||
assert added_emails[test_users[0]["email"]] == "admin"
|
||||
assert added_emails[test_users[1]["email"]] == "normal"
|
||||
assert added_emails[test_users[2]["email"]] == "invite"
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ from typing import Any
|
|||
import pytest
|
||||
|
||||
from common import (
|
||||
accept_team_invitation,
|
||||
add_users_to_team,
|
||||
create_team,
|
||||
create_user,
|
||||
|
|
@ -93,14 +92,7 @@ class TestAuthorization:
|
|||
if add_res.get("code", -1) != 0:
|
||||
pytest.skip(f"Failed to add user to team in setup: {add_res}")
|
||||
|
||||
# Small delay
|
||||
time.sleep(0.5)
|
||||
|
||||
# Accept invitation as the user
|
||||
user_auth: RAGFlowWebApiAuth = login_as_user(email, password)
|
||||
accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id)
|
||||
if accept_res.get("code", -1) != 0:
|
||||
pytest.skip(f"Failed to accept team invitation in setup: {accept_res}")
|
||||
# Users are now added directly, no invitation acceptance needed
|
||||
|
||||
# Try to get permissions with invalid auth
|
||||
res: dict[str, Any] = get_user_permissions(invalid_auth, tenant_id, user_id)
|
||||
|
|
@ -157,14 +149,7 @@ class TestAuthorization:
|
|||
if add_res.get("code", -1) != 0:
|
||||
pytest.skip(f"Failed to add user to team in setup: {add_res}")
|
||||
|
||||
# Small delay
|
||||
time.sleep(0.5)
|
||||
|
||||
# Accept invitation as the user
|
||||
user_auth: RAGFlowWebApiAuth = login_as_user(email, password)
|
||||
accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id)
|
||||
if accept_res.get("code", -1) != 0:
|
||||
pytest.skip(f"Failed to accept team invitation in setup: {accept_res}")
|
||||
# Users are now added directly, no invitation acceptance needed
|
||||
|
||||
# Try to update permissions with invalid auth
|
||||
update_payload: dict[str, Any] = {
|
||||
|
|
@ -207,7 +192,7 @@ class TestGetUserPermissions:
|
|||
test_team: dict[str, Any],
|
||||
clear_team_users: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a team with a user who has accepted the invitation."""
|
||||
"""Create a team with a user who has been added to the team."""
|
||||
tenant_id: str = test_team["id"]
|
||||
|
||||
# Create user
|
||||
|
|
@ -225,19 +210,11 @@ class TestGetUserPermissions:
|
|||
clear_team_users.append(email)
|
||||
user_id: str = user_res["data"]["id"]
|
||||
|
||||
# Add user to team
|
||||
# Add user to team (users are now added directly)
|
||||
add_payload: dict[str, list[str]] = {"users": [email]}
|
||||
add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
if add_res["code"] != 0:
|
||||
pytest.skip(f"Failed to add user to team in setup: {add_res}")
|
||||
|
||||
# Small delay
|
||||
time.sleep(0.5)
|
||||
|
||||
# Accept invitation as the user
|
||||
user_auth: RAGFlowWebApiAuth = login_as_user(email, password)
|
||||
accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id)
|
||||
if accept_res["code"] != 0:
|
||||
pytest.skip(f"Failed to accept team invitation in setup: {accept_res}")
|
||||
|
||||
return {
|
||||
|
|
@ -364,7 +341,7 @@ class TestUpdateUserPermissions:
|
|||
test_team: dict[str, Any],
|
||||
clear_team_users: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Create a team with a user who has accepted the invitation."""
|
||||
"""Create a team with a user who has been added to the team."""
|
||||
tenant_id: str = test_team["id"]
|
||||
|
||||
# Create user
|
||||
|
|
@ -382,19 +359,11 @@ class TestUpdateUserPermissions:
|
|||
clear_team_users.append(email)
|
||||
user_id: str = user_res["data"]["id"]
|
||||
|
||||
# Add user to team
|
||||
# Add user to team (users are now added directly)
|
||||
add_payload: dict[str, list[str]] = {"users": [email]}
|
||||
add_res: dict[str, Any] = add_users_to_team(web_api_auth, tenant_id, add_payload)
|
||||
if add_res["code"] != 0:
|
||||
pytest.skip(f"Failed to add user to team in setup: {add_res}")
|
||||
|
||||
# Small delay
|
||||
time.sleep(0.5)
|
||||
|
||||
# Accept invitation as the user
|
||||
user_auth: RAGFlowWebApiAuth = login_as_user(email, password)
|
||||
accept_res: dict[str, Any] = accept_team_invitation(user_auth, tenant_id)
|
||||
if accept_res["code"] != 0:
|
||||
pytest.skip(f"Failed to accept team invitation in setup: {accept_res}")
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue