[OND211-2329]: Updated add users API and tests to add users with a role(invite/normal/admin).

This commit is contained in:
Hetavi Shah 2025-11-27 15:30:43 +05:30
parent abeb37a9b5
commit 55e0ace936
4 changed files with 146 additions and 110 deletions

View file

@ -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."
)

View file

@ -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},

View file

@ -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"

View file

@ -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 {