mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
feat(ui): sort every teams table data column (#48109)
* feat(ui): sort every teams table data column * fix(ui): order unlimited team limits as infinity and verify org scope * fix(ui): validate team sort filters without recursion
This commit is contained in:
parent
91287b49f4
commit
683442eb61
6 changed files with 556 additions and 32 deletions
|
|
@ -6109,13 +6109,52 @@ async def list_team_v2(
|
|||
"total_pages": 0,
|
||||
}
|
||||
|
||||
# Build order_by conditions
|
||||
valid_sort_columns: Final = ["team_id", "team_alias", "created_at"]
|
||||
order_by = None
|
||||
if sort_by and sort_by in valid_sort_columns:
|
||||
if sort_order.lower() not in ["asc", "desc"]:
|
||||
sort_order = "asc"
|
||||
order_by = {sort_by: sort_order.lower()}
|
||||
from litellm.proxy.management_helpers.team_list_sorting import (
|
||||
COMPUTED_TEAM_SORTS,
|
||||
UnsupportedTeamFilter,
|
||||
sorted_team_ids,
|
||||
)
|
||||
|
||||
valid_sort_columns: Final = (
|
||||
"team_id",
|
||||
"team_alias",
|
||||
"created_at",
|
||||
"spend",
|
||||
"max_budget",
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
"updated_at",
|
||||
)
|
||||
sort_direction: Final = "desc" if str(sort_order).lower() == "desc" else "asc"
|
||||
nullable_sort: Final = sort_by in ("max_budget", "tpm_limit", "rpm_limit")
|
||||
order_by: Final = (
|
||||
(
|
||||
{sort_by: {"sort": sort_direction, "nulls": "first" if sort_direction == "desc" else "last"}},
|
||||
{"team_id": "asc"},
|
||||
)
|
||||
if nullable_sort
|
||||
else ({sort_by: sort_direction}, {"team_id": "asc"})
|
||||
if sort_by in ("spend", "updated_at")
|
||||
else {sort_by: sort_direction}
|
||||
if sort_by in valid_sort_columns
|
||||
else None
|
||||
)
|
||||
computed_sort: Final = not use_deleted_table and sort_by in COMPUTED_TEAM_SORTS
|
||||
sorted_ids: Final = (
|
||||
await sorted_team_ids(
|
||||
prisma_client,
|
||||
where=where_conditions,
|
||||
sort_by=sort_by or "",
|
||||
descending=sort_direction == "desc",
|
||||
skip=skip,
|
||||
take=page_size,
|
||||
)
|
||||
if computed_sort
|
||||
else ()
|
||||
)
|
||||
|
||||
if isinstance(sorted_ids, UnsupportedTeamFilter):
|
||||
raise HTTPException(status_code=500, detail="Unsupported team sorting filter")
|
||||
|
||||
# Get teams with pagination
|
||||
if use_deleted_table:
|
||||
|
|
@ -6130,26 +6169,32 @@ async def list_team_v2(
|
|||
total_count = await _deleted_team_db(prisma_client).count(where=where_conditions)
|
||||
else:
|
||||
teams = await _team_db(prisma_client).find_many(
|
||||
where=where_conditions,
|
||||
skip=skip,
|
||||
# bounded-ok: sorted_ids comes from LIMIT page_size, capped at 100 by the route
|
||||
where={"AND": (where_conditions, {"team_id": {"in": sorted_ids}})} if computed_sort else where_conditions,
|
||||
skip=0 if computed_sort else skip,
|
||||
take=page_size,
|
||||
order=order_by if order_by else {"created_at": "desc"}, # Default sort
|
||||
order=order_by if order_by else {"created_at": "desc"},
|
||||
include=_INCLUDE_MODEL_TABLE,
|
||||
)
|
||||
# Get total count for pagination
|
||||
total_count = await _team_db(prisma_client).count(where=where_conditions)
|
||||
|
||||
positions: Final = {team_id: index for index, team_id in enumerate(sorted_ids)}
|
||||
sorted_teams: Final = sorted(teams, key=lambda team: positions[team.team_id]) if computed_sort else teams
|
||||
|
||||
# Calculate total pages
|
||||
total_pages: Final = -(-total_count // page_size) # Ceiling division
|
||||
|
||||
# Aggregate virtual-key counts per team for the current page. The deleted
|
||||
# table does not carry keys_count, so it is skipped.
|
||||
keys_count_by_team: dict[str, int] = {}
|
||||
if not use_deleted_table:
|
||||
keys_count_by_team = await _get_keys_count_by_team(prisma_client, teams)
|
||||
keys_count_by_team: Final = (
|
||||
await _get_keys_count_by_team(prisma_client, sorted_teams) if not use_deleted_table else {}
|
||||
)
|
||||
|
||||
# Convert Prisma models to response models with members_count and keys_count
|
||||
team_list = _convert_teams_to_response_models(teams, use_deleted_table, keys_count_by_team=keys_count_by_team)
|
||||
team_list: Final = _convert_teams_to_response_models(
|
||||
sorted_teams, use_deleted_table, keys_count_by_team=keys_count_by_team
|
||||
)
|
||||
|
||||
# Resolve resources inherited from access groups (single batch query)
|
||||
if not use_deleted_table:
|
||||
|
|
|
|||
109
litellm/proxy/management_helpers/team_list_sorting.py
Normal file
109
litellm/proxy/management_helpers/team_list_sorting.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
|
||||
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
COMPUTED_TEAM_SORTS: Final = MappingProxyType(
|
||||
{
|
||||
"organization_alias": "COALESCE((SELECT NULLIF(o.organization_alias, '') "
|
||||
'FROM "LiteLLM_OrganizationTable" o WHERE o.organization_id = t.organization_id), '
|
||||
"NULLIF(t.organization_id, ''))",
|
||||
"members": "CASE WHEN jsonb_typeof(t.members_with_roles) = 'array' "
|
||||
"THEN jsonb_array_length(t.members_with_roles) ELSE 0 END",
|
||||
"models": "cardinality(t.models)",
|
||||
"keys_count": '(SELECT COUNT(*) FROM "LiteLLM_VerificationToken" k WHERE k.team_id = t.team_id)',
|
||||
}
|
||||
)
|
||||
_WHERE: Final = TypeAdapter(dict[str, JsonValue])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnsupportedTeamFilter:
|
||||
field: str
|
||||
|
||||
|
||||
def _field_sql(field: str, value: JsonValue, index: int) -> tuple[str, tuple[object, ...]] | UnsupportedTeamFilter:
|
||||
if field not in ("team_id", "team_alias", "organization_id"):
|
||||
return UnsupportedTeamFilter(field)
|
||||
column: Final = f't."{field}"'
|
||||
match value:
|
||||
case str(text):
|
||||
return f"{column} = ${index}", (text,)
|
||||
case {"in": list(values)}:
|
||||
return f"{column} = ANY(${index}::text[])", (values,)
|
||||
case {"contains": str(text), "mode": "insensitive"}:
|
||||
return f"{column} ILIKE ${index}", (f"%{text}%",)
|
||||
case {"startsWith": str(text)}:
|
||||
return f"{column} LIKE ${index}", (f"{text}%",)
|
||||
case _:
|
||||
return UnsupportedTeamFilter(field)
|
||||
|
||||
|
||||
def _group_tokens(
|
||||
field: str, value: Sequence[JsonValue] | Mapping[str, JsonValue]
|
||||
) -> tuple[str | tuple[str, JsonValue], ...]:
|
||||
children: Final = tuple(value.items()) if isinstance(value, Mapping) else tuple(("AND", item) for item in value)
|
||||
if not children:
|
||||
return ("FALSE" if field == "OR" else "TRUE",)
|
||||
return (
|
||||
"(",
|
||||
*chain.from_iterable(((f" {field} " if index else ""), child) for index, child in enumerate(children)),
|
||||
")",
|
||||
)
|
||||
|
||||
|
||||
def _where_sql(where: Mapping[str, JsonValue]) -> tuple[str, tuple[object, ...]] | UnsupportedTeamFilter:
|
||||
pending: Final[list[str | tuple[str, JsonValue]]] = [("AND", dict(where))] # mutable-ok: iterative tree traversal
|
||||
fragments: Final[list[str]] = [] # mutable-ok: append-only SQL output avoids quadratic tuple copying
|
||||
params: Final[list[object]] = [] # mutable-ok: append-only bindings follow traversal order
|
||||
while pending:
|
||||
match pending.pop():
|
||||
case str(fragment):
|
||||
fragments.append(fragment)
|
||||
case (field, value) if field in ("AND", "OR") and isinstance(value, (list, dict)):
|
||||
pending.extend(reversed(_group_tokens(field, value)))
|
||||
case (field, value):
|
||||
match _field_sql(field, value, len(params) + 1):
|
||||
case UnsupportedTeamFilter() as failure:
|
||||
return failure
|
||||
case (fragment, values):
|
||||
fragments.append(fragment)
|
||||
params.extend(values)
|
||||
return "".join(fragments), tuple(params)
|
||||
|
||||
|
||||
class _TeamId(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
team_id: str
|
||||
|
||||
|
||||
_TEAM_IDS: Final = TypeAdapter(tuple[_TeamId, ...])
|
||||
|
||||
|
||||
async def sorted_team_ids(
|
||||
prisma_client: PrismaClient,
|
||||
*,
|
||||
where: Mapping[str, object],
|
||||
sort_by: str,
|
||||
descending: bool,
|
||||
skip: int,
|
||||
take: int,
|
||||
) -> Sequence[str] | UnsupportedTeamFilter:
|
||||
result: Final = _where_sql(_WHERE.validate_python(where))
|
||||
if isinstance(result, UnsupportedTeamFilter):
|
||||
return result
|
||||
predicate, params = result
|
||||
expression: Final = COMPUTED_TEAM_SORTS[sort_by]
|
||||
direction: Final = "DESC" if descending else "ASC"
|
||||
sql: Final = (
|
||||
f'SELECT t.team_id FROM "LiteLLM_TeamTable" t WHERE {predicate} '
|
||||
f"ORDER BY {expression} {direction} NULLS LAST, t.team_id ASC "
|
||||
f"LIMIT ${len(params) + 1} OFFSET ${len(params) + 2}"
|
||||
)
|
||||
rows: Final = _TEAM_IDS.validate_python(await prisma_client.db.query_raw(sql, *params, take, skip))
|
||||
return tuple(row.team_id for row in rows)
|
||||
244
tests/integration/management/test_team_list_sorting.py
Normal file
244
tests/integration/management/test_team_list_sorting.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import json
|
||||
from itertools import chain
|
||||
from typing import Final
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.integration._support.client import Gateway, Scenario
|
||||
from tests.integration._support.database import write_rows
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ("spend", "max_budget", "tpm_limit", "rpm_limit", "updated_at", "members", "models", "keys_count")
|
||||
)
|
||||
@pytest.mark.parametrize("direction", ("asc", "desc"))
|
||||
def test_team_list_sorts_across_pages_and_respects_membership(
|
||||
field: str,
|
||||
direction: str,
|
||||
gateway: Gateway,
|
||||
scenario: Scenario,
|
||||
) -> None:
|
||||
prefix: Final = f"sort-{uuid4().hex}"
|
||||
owner: Final = scenario.user(user_role="internal_user")
|
||||
caller: Final = scenario.key(user_id=owner)
|
||||
values: Final = (20, 3, 3, 100, 0)
|
||||
teams: Final = tuple(
|
||||
scenario.team(
|
||||
team_id=f"{prefix}-{index}",
|
||||
team_alias=f"{prefix}-{index}",
|
||||
members_with_roles=[{"user_id": owner, "role": "admin"}],
|
||||
)
|
||||
for index in range(len(values))
|
||||
)
|
||||
outsider: Final = scenario.team(team_alias=f"{prefix}-outside")
|
||||
for team_id, value in zip(teams, values, strict=True):
|
||||
write_rows(
|
||||
'UPDATE "LiteLLM_TeamTable" SET spend = %s::float, max_budget = %s::float, '
|
||||
"tpm_limit = %s::bigint, rpm_limit = %s::bigint, "
|
||||
"updated_at = timestamp '2030-01-01' + %s::int * interval '1 day', "
|
||||
"members_with_roles = %s::jsonb, models = ARRAY(SELECT generate_series(1, %s::int)::text) "
|
||||
"WHERE team_id = %s",
|
||||
(
|
||||
str(value),
|
||||
str(value),
|
||||
str(value),
|
||||
str(value),
|
||||
str(value),
|
||||
json.dumps(
|
||||
[{"user_id": owner if i == 0 else f"{prefix}-member-{i}", "role": "admin"} for i in range(value)]
|
||||
),
|
||||
str(value),
|
||||
team_id,
|
||||
),
|
||||
)
|
||||
write_rows(
|
||||
'INSERT INTO "LiteLLM_VerificationToken" (token, team_id) '
|
||||
"SELECT %s || '-key-' || n::text, %s FROM generate_series(1, %s::int) n",
|
||||
(team_id, team_id, str(value)),
|
||||
)
|
||||
write_rows(
|
||||
'UPDATE "LiteLLM_TeamTable" SET max_budget = NULL, tpm_limit = NULL, rpm_limit = NULL WHERE team_id = %s',
|
||||
(teams[-1],),
|
||||
)
|
||||
try:
|
||||
expected: Final = tuple(
|
||||
team_id
|
||||
for value, team_id in sorted(
|
||||
zip(values, teams, strict=True),
|
||||
key=lambda item: (
|
||||
(item[0] != 0 if direction == "desc" else item[0] == 0)
|
||||
if field in ("max_budget", "tpm_limit", "rpm_limit")
|
||||
else False,
|
||||
item[0] * (1 if direction == "asc" else -1),
|
||||
item[1],
|
||||
),
|
||||
)
|
||||
)
|
||||
pages: Final = tuple(
|
||||
gateway.request(
|
||||
"GET",
|
||||
"/v2/team/list",
|
||||
key=caller,
|
||||
params={
|
||||
"search": prefix,
|
||||
"user_id": owner,
|
||||
"sort_by": field,
|
||||
"sort_order": direction,
|
||||
"page_size": "2",
|
||||
"page": str(page),
|
||||
},
|
||||
)
|
||||
for page in (1, 2, 3)
|
||||
)
|
||||
assert all(page.status_code == 200 for page in pages), [page.text for page in pages]
|
||||
returned: Final = tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages))
|
||||
assert returned == expected
|
||||
assert outsider not in returned
|
||||
assert all(page.json()["total"] == len(teams) for page in pages)
|
||||
finally:
|
||||
for team_id in teams:
|
||||
write_rows('DELETE FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team_id,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("direction", ("asc", "desc"))
|
||||
def test_team_list_sorts_organization_names_and_missing_values(
|
||||
direction: str, gateway: Gateway, scenario: Scenario
|
||||
) -> None:
|
||||
prefix: Final = f"sort-org-{uuid4().hex}"
|
||||
budget: Final = scenario.budget(max_budget=10)
|
||||
organizations: Final = ((f"{prefix}-z", "alpha"), (f"{prefix}-a", "zulu"), (f"{prefix}-m", ""))
|
||||
teams: Final = tuple(scenario.team(team_id=f"{prefix}-{i}", team_alias=f"{prefix}-{i}") for i in range(4))
|
||||
try:
|
||||
for (organization_id, alias), team_id in zip(organizations, teams):
|
||||
write_rows(
|
||||
'INSERT INTO "LiteLLM_OrganizationTable" '
|
||||
"(organization_id, organization_alias, budget_id, created_by, updated_by, updated_at) "
|
||||
"VALUES (%s, %s, %s, 'sorting-test', 'sorting-test', NOW())",
|
||||
(organization_id, alias, budget),
|
||||
)
|
||||
write_rows(
|
||||
'UPDATE "LiteLLM_TeamTable" SET organization_id = %s WHERE team_id = %s',
|
||||
(organization_id, team_id),
|
||||
)
|
||||
ascending: Final = (teams[0], teams[2], teams[1])
|
||||
ordered: Final = ascending if direction == "asc" else tuple(reversed(ascending))
|
||||
pages: Final = tuple(
|
||||
gateway.request(
|
||||
"GET",
|
||||
"/v2/team/list",
|
||||
params={
|
||||
"search": prefix,
|
||||
"sort_by": "organization_alias",
|
||||
"sort_order": direction,
|
||||
"page_size": "2",
|
||||
"page": str(page),
|
||||
},
|
||||
)
|
||||
for page in (1, 2)
|
||||
)
|
||||
assert all(page.status_code == 200 for page in pages), [page.text for page in pages]
|
||||
assert tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages)) == (*ordered, teams[-1])
|
||||
finally:
|
||||
for team_id in teams:
|
||||
write_rows('UPDATE "LiteLLM_TeamTable" SET organization_id = NULL WHERE team_id = %s', (team_id,))
|
||||
for organization_id, _ in organizations:
|
||||
write_rows('DELETE FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (organization_id,))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ("organization_alias", "members", "models", "keys_count"))
|
||||
@pytest.mark.parametrize("direction", ("asc", "desc"))
|
||||
def test_computed_team_sorts_preserve_org_and_membership_union(
|
||||
field: str, direction: str, gateway: Gateway, scenario: Scenario
|
||||
) -> None:
|
||||
prefix: Final = f"sort-org-scope-{uuid4().hex}"
|
||||
owner: Final = scenario.user(user_role="internal_user")
|
||||
budget: Final = scenario.budget(max_budget=10)
|
||||
orgs: Final = (f"{prefix}-allowed", f"{prefix}-outside")
|
||||
teams: Final = tuple(
|
||||
scenario.team(
|
||||
team_id=f"{prefix}-{i}",
|
||||
team_alias=f"{prefix}-{i}",
|
||||
members_with_roles=[{"user_id": owner, "role": "admin"}] if i == 2 else [],
|
||||
)
|
||||
for i in range(4)
|
||||
)
|
||||
try:
|
||||
for org, alias in zip(orgs, ("alpha", "zulu"), strict=True):
|
||||
write_rows(
|
||||
'INSERT INTO "LiteLLM_OrganizationTable" '
|
||||
"(organization_id, organization_alias, budget_id, created_by, updated_by, updated_at) "
|
||||
"VALUES (%s, %s, %s, 'sorting-test', 'sorting-test', NOW())",
|
||||
(org, alias, budget),
|
||||
)
|
||||
write_rows(
|
||||
'INSERT INTO "LiteLLM_OrganizationMembership" (user_id, organization_id, user_role) '
|
||||
"VALUES (%s, %s, 'org_admin')",
|
||||
(owner, orgs[0]),
|
||||
)
|
||||
for index, team in enumerate(teams):
|
||||
write_rows(
|
||||
'UPDATE "LiteLLM_TeamTable" SET organization_id = %s, members_with_roles = %s::jsonb, '
|
||||
"models = ARRAY(SELECT generate_series(1, %s::int)::text) WHERE team_id = %s",
|
||||
(
|
||||
orgs[0] if index < 2 else orgs[1],
|
||||
json.dumps(
|
||||
[
|
||||
{"user_id": owner if i == 0 else f"{prefix}-member-{i}", "role": "admin"}
|
||||
for i in range(index + 1)
|
||||
]
|
||||
),
|
||||
str(index + 1),
|
||||
team,
|
||||
),
|
||||
)
|
||||
write_rows(
|
||||
'INSERT INTO "LiteLLM_VerificationToken" (token, team_id) '
|
||||
"SELECT %s || '-key-' || n::text, %s FROM generate_series(1, %s::int) n",
|
||||
(team, team, str(index + 1)),
|
||||
)
|
||||
caller: Final = scenario.key(user_id=owner)
|
||||
values: Final = (0, 0, 1) if field == "organization_alias" else (1, 2, 3)
|
||||
expected: Final = tuple(
|
||||
team
|
||||
for value, team in sorted(
|
||||
zip(values, teams[:3], strict=True),
|
||||
key=lambda item: (item[0] * (-1 if direction == "desc" else 1), item[1]),
|
||||
)
|
||||
)
|
||||
pages: Final = tuple(
|
||||
gateway.request(
|
||||
"GET",
|
||||
"/v2/team/list",
|
||||
key=caller,
|
||||
params={
|
||||
"user_id": owner,
|
||||
"search": prefix,
|
||||
"sort_by": field,
|
||||
"sort_order": direction,
|
||||
"page_size": "2",
|
||||
"page": str(page),
|
||||
},
|
||||
)
|
||||
for page in (1, 2)
|
||||
)
|
||||
assert all(page.status_code == 200 for page in pages), [page.text for page in pages]
|
||||
assert all(page.json()["total"] == 3 for page in pages)
|
||||
assert tuple(row["team_id"] for row in chain.from_iterable(page.json()["teams"] for page in pages)) == expected
|
||||
denied: Final = gateway.request(
|
||||
"GET",
|
||||
"/v2/team/list",
|
||||
key=caller,
|
||||
params={
|
||||
"organization_id": orgs[1],
|
||||
"sort_by": field,
|
||||
},
|
||||
)
|
||||
assert denied.status_code == 403, denied.text
|
||||
finally:
|
||||
for team in teams:
|
||||
write_rows('DELETE FROM "LiteLLM_VerificationToken" WHERE team_id = %s', (team,))
|
||||
write_rows('UPDATE "LiteLLM_TeamTable" SET organization_id = NULL WHERE team_id = %s', (team,))
|
||||
for org in orgs:
|
||||
write_rows('DELETE FROM "LiteLLM_OrganizationMembership" WHERE organization_id = %s', (org,))
|
||||
write_rows('DELETE FROM "LiteLLM_OrganizationTable" WHERE organization_id = %s', (org,))
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import JsonValue
|
||||
|
||||
from litellm.proxy.management_helpers.team_list_sorting import UnsupportedTeamFilter, _where_sql, sorted_team_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("where", "expected"),
|
||||
(
|
||||
({}, ("a", "b", "c")),
|
||||
({"OR": []}, ()),
|
||||
({"AND": []}, ("a", "b", "c")),
|
||||
({"team_id": "a"}, ("a",)),
|
||||
({"team_alias": {"contains": "ALPHA", "mode": "insensitive"}}, ("a", "c")),
|
||||
({"team_id": {"startsWith": "b"}}, ("b",)),
|
||||
({"organization_id": {"in": ["org-1"]}}, ("a", "b")),
|
||||
(
|
||||
{
|
||||
"OR": [{"team_id": "b"}, {"team_alias": {"contains": "alpha", "mode": "insensitive"}}],
|
||||
"AND": [{"OR": [{"organization_id": "org-1"}, {"team_id": "c"}]}],
|
||||
"team_id": {"in": ["a", "c"]},
|
||||
},
|
||||
("a", "c"),
|
||||
),
|
||||
({"team_id": "a' OR 1=1 --"}, ()),
|
||||
),
|
||||
)
|
||||
def test_team_filters_preserve_search_and_authorization_intersection(
|
||||
where: dict[str, JsonValue], expected: tuple[str, ...]
|
||||
) -> None:
|
||||
result: Final = _where_sql(where)
|
||||
assert not isinstance(result, UnsupportedTeamFilter)
|
||||
predicate, params = result
|
||||
sqlite_predicate: Final = re.sub(
|
||||
r"= ANY\((\$\d+)::text\[\]\)", r"IN (SELECT value FROM json_each(\1))", predicate
|
||||
).replace(" ILIKE ", " LIKE ")
|
||||
with sqlite3.connect(":memory:") as db:
|
||||
db.execute("CREATE TABLE teams (team_id TEXT, team_alias TEXT, organization_id TEXT)")
|
||||
db.executemany(
|
||||
"INSERT INTO teams VALUES (?, ?, ?)",
|
||||
(("a", "Alpha", "org-1"), ("b", "Beta", "org-1"), ("c", "Alpha", "org-2")),
|
||||
)
|
||||
rows: Final = db.execute(
|
||||
f"SELECT t.team_id FROM teams t WHERE {sqlite_predicate} ORDER BY t.team_id",
|
||||
{
|
||||
str(index): json.dumps(value) if isinstance(value, list) else value
|
||||
for index, value in enumerate(params, 1)
|
||||
},
|
||||
).fetchall()
|
||||
assert tuple(row[0] for row in rows) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("where", ({"unknown": "x"}, {"team_id": {"not": "x"}}, {"AND": [False]}))
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_filter_fails_without_reading_unscoped_teams(where: dict[str, JsonValue]) -> None:
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.query_raw = AsyncMock()
|
||||
result: Final = await sorted_team_ids(prisma, where=where, sort_by="keys_count", descending=False, skip=0, take=25)
|
||||
assert isinstance(result, UnsupportedTeamFilter)
|
||||
prisma.db.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sort_by", ("keys_count", "members", "models", "organization_alias"))
|
||||
@pytest.mark.parametrize("descending", (False, True))
|
||||
@pytest.mark.asyncio
|
||||
async def test_sorted_ids_bind_search_and_pagination_without_interpolating_input(
|
||||
sort_by: str, descending: bool
|
||||
) -> None:
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.query_raw = AsyncMock(return_value=[])
|
||||
search: Final = "x' OR TRUE --"
|
||||
result: Final = await sorted_team_ids(
|
||||
prisma, where={"team_id": search}, sort_by=sort_by, descending=descending, skip=25, take=25
|
||||
)
|
||||
assert result == ()
|
||||
sql, *bindings = prisma.db.query_raw.await_args.args
|
||||
assert search not in sql
|
||||
assert bindings == [search, 25, 25]
|
||||
assert ("DESC" if descending else "ASC") + " NULLS LAST, t.team_id ASC" in sql
|
||||
|
|
@ -163,16 +163,26 @@ describe("sort contract – only backend-sortable columns are sortable", () => {
|
|||
|
||||
it("right-aligns Spend / Budget but not Team", () => {
|
||||
renderTable();
|
||||
expect(screen.getByRole("columnheader", { name: "Spend / Budget" })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: /Spend\s*\/\s*Budget/ })).toHaveClass("text-right");
|
||||
expect(screen.getByRole("columnheader", { name: "Team" })).not.toHaveClass("text-right");
|
||||
});
|
||||
|
||||
it("does not make Spend / Budget sortable (the backend rejects sort_by=spend)", () => {
|
||||
it.each([
|
||||
["Spend or Budget", "Spend", "spend"],
|
||||
["Spend or Budget", "Budget", "max_budget"],
|
||||
["Members or Models or Keys", "Members", "members"],
|
||||
["Members or Models or Keys", "Models", "models"],
|
||||
["Members or Models or Keys", "Keys", "keys_count"],
|
||||
])("sorts %s by %s through the API", async (menu, label, field) => {
|
||||
const user = userEvent.setup();
|
||||
renderTable();
|
||||
expect(screen.queryByText("Spend / Budget").closest("button")).toBeNull();
|
||||
// Team and Created are the only sortable headers.
|
||||
expect(screen.getByText("Team").closest("button")).not.toBeNull();
|
||||
expect(screen.getByText("Created").closest("button")).not.toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: `Sort options for ${menu}` }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: `${label} descending` }));
|
||||
await waitFor(() => expect(lastOptions()).toMatchObject({ sortBy: field, sortOrder: "desc" }));
|
||||
expect(mockUseTeamsTable.mock.calls.at(-1)?.[0]).toBe(1);
|
||||
await user.click(screen.getByRole("button", { name: `Sort options for ${menu}` }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: `${label} ascending` }));
|
||||
await waitFor(() => expect(lastOptions()).toMatchObject({ sortBy: field, sortOrder: "asc" }));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, KeyRound, Layers, MoreHorizontal, Pencil, Trash2, Users } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, SpendBudgetCell } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -174,9 +174,10 @@ export const getTeamTableColumns = ({
|
|||
id: "organization_alias",
|
||||
accessorKey: "organization_id",
|
||||
meta: { title: "Organization" },
|
||||
header: "Organization",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Organization" />,
|
||||
sortDescFirst: false,
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: (info) => {
|
||||
const orgId = info.getValue() as string | null;
|
||||
if (!orgId) return <span className="text-muted-foreground">—</span>;
|
||||
|
|
@ -202,7 +203,16 @@ export const getTeamTableColumns = ({
|
|||
</div>
|
||||
),
|
||||
},
|
||||
header: "Resources",
|
||||
header: ({ table }) => (
|
||||
<DataTableMultiSortHeader
|
||||
table={table}
|
||||
fields={[
|
||||
{ id: "members", label: "Members" },
|
||||
{ id: "models", label: "Models" },
|
||||
{ id: "keys_count", label: "Keys" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
size: 210,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ResourcesCell team={row.original} />,
|
||||
|
|
@ -211,7 +221,15 @@ export const getTeamTableColumns = ({
|
|||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend / Budget", skeleton: "meter", numeric: true },
|
||||
header: "Spend / Budget",
|
||||
header: ({ table }) => (
|
||||
<DataTableMultiSortHeader
|
||||
table={table}
|
||||
fields={[
|
||||
{ id: "spend", label: "Spend" },
|
||||
{ id: "max_budget", label: "Budget" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -234,24 +252,36 @@ export const getTeamTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "members",
|
||||
accessorFn: teamMemberCount,
|
||||
meta: { title: "Members", numeric: true },
|
||||
header: "Members",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Members" />,
|
||||
sortDescFirst: true,
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <span className="text-sm tabular-nums">{teamMemberCount(row.original)}</span>,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorFn: teamModelCount,
|
||||
meta: { title: "Models", numeric: true },
|
||||
header: "Models",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Models" />,
|
||||
sortDescFirst: true,
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <span className="text-sm tabular-nums">{teamModelCount(row.original)}</span>,
|
||||
},
|
||||
{
|
||||
id: "rate_limits",
|
||||
meta: { title: "Rate Limits", skeleton: "twoLine" },
|
||||
header: "Rate Limits",
|
||||
header: ({ table }) => (
|
||||
<DataTableMultiSortHeader
|
||||
table={table}
|
||||
fields={[
|
||||
{ id: "tpm_limit", label: "TPM" },
|
||||
{ id: "rpm_limit", label: "RPM" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -265,9 +295,10 @@ export const getTeamTableColumns = ({
|
|||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated" },
|
||||
header: "Updated",
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated" />,
|
||||
sortDescFirst: false,
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
enableSorting: true,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null} precision="date" fallback="Never" />,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue