mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-13 21:06:58 +00:00
feat(ui): search teams by team ID alongside name (#27684)
* feat(ui): search teams by team ID alongside name The Teams page search box only matched team_alias, so pasting a team UUID returned zero results. Detect when the input is a full UUID and route it to the team_id filter instead; otherwise keep the existing alias substring search. Placeholder now reads "Search teams by name or ID...". Resolves LIT-2648 * refactor: search teams via backend OR clause, drop client-side UUID detection Adds a `search` query param to /v2/team/list that ORs across team_id (exact) and team_alias (case-insensitive contains), so the search box sends one param regardless of input format. Removes the isLikelyTeamId helper and the client-side branching it fed.
This commit is contained in:
committed by
GitHub
parent
b39cac9382
commit
9c4faeabc9
@@ -3805,6 +3805,7 @@ async def _build_team_list_where_conditions(
|
||||
organization_id: Optional[str],
|
||||
user_id: Optional[str],
|
||||
use_deleted_table: bool,
|
||||
search: Optional[str] = None,
|
||||
org_admin_org_ids: Optional[List[str]] = None,
|
||||
user_api_key_cache: Optional[Any] = None,
|
||||
proxy_logging_obj: Optional[Any] = None,
|
||||
@@ -3826,6 +3827,12 @@ async def _build_team_list_where_conditions(
|
||||
"mode": "insensitive", # Case-insensitive search
|
||||
}
|
||||
|
||||
if search:
|
||||
where_conditions["OR"] = [
|
||||
{"team_id": search},
|
||||
{"team_alias": {"contains": search, "mode": "insensitive"}},
|
||||
]
|
||||
|
||||
if organization_id:
|
||||
where_conditions["organization_id"] = organization_id
|
||||
elif org_admin_org_ids is not None:
|
||||
@@ -4019,6 +4026,10 @@ async def list_team_v2(
|
||||
default=None,
|
||||
description="Only return teams which this 'team_alias' belongs to. Supports partial matching.",
|
||||
),
|
||||
search: Optional[str] = fastapi.Query(
|
||||
default=None,
|
||||
description="Combined search: matches teams whose 'team_id' equals the value OR whose 'team_alias' contains it (case-insensitive).",
|
||||
),
|
||||
page: int = fastapi.Query(
|
||||
default=1, description="Page number for pagination", ge=1
|
||||
),
|
||||
@@ -4104,6 +4115,7 @@ async def list_team_v2(
|
||||
organization_id=organization_id,
|
||||
user_id=user_id,
|
||||
use_deleted_table=use_deleted_table,
|
||||
search=search,
|
||||
org_admin_org_ids=org_admin_org_ids,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
|
||||
@@ -3140,6 +3140,121 @@ async def test_list_team_v2_with_invalid_status():
|
||||
assert "deleted" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_team_v2_search_builds_or_clause():
|
||||
"""
|
||||
`search` should be passed as a Prisma OR across team_id (exact) and
|
||||
team_alias (case-insensitive contains), so the UI can hit a single
|
||||
backend filter with either a UUID or a name fragment.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
|
||||
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_admin = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
|
||||
)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client:
|
||||
mock_db = Mock()
|
||||
mock_prisma_client.db = mock_db
|
||||
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[])
|
||||
mock_db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
|
||||
await list_team_v2(
|
||||
http_request=mock_request,
|
||||
user_id=None,
|
||||
organization_id=None,
|
||||
team_id=None,
|
||||
team_alias=None,
|
||||
search="platform",
|
||||
user_api_key_dict=mock_admin,
|
||||
page=1,
|
||||
page_size=10,
|
||||
status=None,
|
||||
)
|
||||
|
||||
find_many_kwargs = mock_db.litellm_teamtable.find_many.call_args.kwargs
|
||||
assert find_many_kwargs["where"] == {
|
||||
"OR": [
|
||||
{"team_id": "platform"},
|
||||
{"team_alias": {"contains": "platform", "mode": "insensitive"}},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_team_v2_search_composes_with_user_id_filter():
|
||||
"""
|
||||
For non-admin users, `search` must compose with the membership filter:
|
||||
the resulting where clause should AND `team_id IN <user's teams>` with
|
||||
the search OR clause, so users still only see their own teams.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.team_endpoints import list_team_v2
|
||||
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_user_api_key_dict = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, user_id="member_user"
|
||||
)
|
||||
|
||||
mock_user = LiteLLM_UserTable(
|
||||
user_id="member_user",
|
||||
teams=["team_a", "team_b"],
|
||||
organization_memberships=[],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
|
||||
new=AsyncMock(return_value=mock_user),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.team_endpoints._get_org_admin_org_ids",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
mock_db.litellm_teamtable.find_many = AsyncMock(return_value=[])
|
||||
mock_db.litellm_teamtable.count = AsyncMock(return_value=0)
|
||||
|
||||
await list_team_v2(
|
||||
http_request=mock_request,
|
||||
user_id="member_user",
|
||||
organization_id=None,
|
||||
team_id=None,
|
||||
team_alias=None,
|
||||
search="team_a",
|
||||
user_api_key_dict=mock_user_api_key_dict,
|
||||
page=1,
|
||||
page_size=10,
|
||||
status=None,
|
||||
)
|
||||
|
||||
find_many_kwargs = mock_db.litellm_teamtable.find_many.call_args.kwargs
|
||||
where = find_many_kwargs["where"]
|
||||
assert where["OR"] == [
|
||||
{"team_id": "team_a"},
|
||||
{"team_alias": {"contains": "team_a", "mode": "insensitive"}},
|
||||
]
|
||||
assert where["team_id"] == {"in": ["team_a", "team_b"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_member_delete_cleans_membership(mock_db_client, mock_admin_auth):
|
||||
"""
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface TeamListCallOptions {
|
||||
organizationID?: string | null;
|
||||
teamID?: string | null;
|
||||
team_alias?: string | null;
|
||||
search?: string | null;
|
||||
userID?: string | null;
|
||||
sortBy?: string | null;
|
||||
sortOrder?: string | null;
|
||||
@@ -52,6 +53,7 @@ export const teamListCall = async (
|
||||
team_id: options.teamID,
|
||||
organization_id: options.organizationID,
|
||||
team_alias: options.team_alias,
|
||||
search: options.search,
|
||||
user_id: options.userID,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
@@ -178,6 +180,7 @@ const deletedTeamListCall = async (
|
||||
team_id: options.teamID,
|
||||
organization_id: options.organizationID,
|
||||
team_alias: options.team_alias,
|
||||
search: options.search,
|
||||
user_id: options.userID,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
|
||||
@@ -80,8 +80,7 @@ interface TeamProps {
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
team_id: string;
|
||||
team_alias: string;
|
||||
search: string;
|
||||
organization_id: string;
|
||||
sort_by: string;
|
||||
sort_order: "asc" | "desc";
|
||||
@@ -200,8 +199,7 @@ const Teams: React.FC<TeamProps> = ({
|
||||
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
|
||||
const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState<Organization | null>(null);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
team_id: "",
|
||||
team_alias: "",
|
||||
search: "",
|
||||
organization_id: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
@@ -215,7 +213,7 @@ const Teams: React.FC<TeamProps> = ({
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
organizationID?: string;
|
||||
teamAlias?: string;
|
||||
search?: string;
|
||||
} = {}) => {
|
||||
if (!accessToken) return;
|
||||
const page = opts.page ?? currentPage;
|
||||
@@ -223,7 +221,7 @@ const Teams: React.FC<TeamProps> = ({
|
||||
const sortBy = opts.sortBy ?? filters.sort_by;
|
||||
const sortOrder = opts.sortOrder ?? filters.sort_order;
|
||||
const organizationID = opts.organizationID ?? filters.organization_id;
|
||||
const teamAlias = opts.teamAlias ?? filters.team_alias;
|
||||
const search = opts.search ?? filters.search;
|
||||
|
||||
setIsLoading(true);
|
||||
setFetchError(null);
|
||||
@@ -234,7 +232,7 @@ const Teams: React.FC<TeamProps> = ({
|
||||
size,
|
||||
{
|
||||
organizationID: organizationID || null,
|
||||
team_alias: teamAlias || null,
|
||||
search: search || null,
|
||||
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
|
||||
sortBy: sortBy || null,
|
||||
sortOrder: sortOrder || null,
|
||||
@@ -632,9 +630,9 @@ const Teams: React.FC<TeamProps> = ({
|
||||
setIsSearching(true);
|
||||
searchDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
setFilters((prev) => ({ ...prev, team_alias: value }));
|
||||
setFilters((prev) => ({ ...prev, search: value }));
|
||||
setCurrentPage(1);
|
||||
await fetchTeamsV2({ page: 1, teamAlias: value });
|
||||
await fetchTeamsV2({ page: 1, search: value });
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
@@ -653,7 +651,7 @@ const Teams: React.FC<TeamProps> = ({
|
||||
pageSize,
|
||||
{
|
||||
organizationID: newFilters.organization_id || null,
|
||||
team_alias: newFilters.team_alias || null,
|
||||
search: newFilters.search || null,
|
||||
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
|
||||
sortBy: newFilters.sort_by || null,
|
||||
sortOrder: newFilters.sort_order || null,
|
||||
@@ -670,15 +668,14 @@ const Teams: React.FC<TeamProps> = ({
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||
setIsSearching(false);
|
||||
const resetFilters: FilterState = {
|
||||
team_id: "",
|
||||
team_alias: "",
|
||||
search: "",
|
||||
organization_id: "",
|
||||
sort_by: "created_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
setFilters(resetFilters);
|
||||
setCurrentPage(1);
|
||||
fetchTeamsV2({ page: 1, organizationID: "", teamAlias: "", sortBy: "created_at", sortOrder: "desc" });
|
||||
fetchTeamsV2({ page: 1, organizationID: "", search: "", sortBy: "created_at", sortOrder: "desc" });
|
||||
};
|
||||
|
||||
const { token } = theme.useToken();
|
||||
@@ -945,7 +942,7 @@ const Teams: React.FC<TeamProps> = ({
|
||||
<Input
|
||||
prefix={<SearchIcon size={16} />}
|
||||
suffix={isSearching ? <AntDLoadingSpinner size="small" /> : null}
|
||||
placeholder="Search teams by name..."
|
||||
placeholder="Search teams by name or ID..."
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ maxWidth: 400 }}
|
||||
|
||||
Reference in New Issue
Block a user