diff --git a/backend/api/admin.py b/backend/api/admin.py
index d03d584..d53ce60 100644
--- a/backend/api/admin.py
+++ b/backend/api/admin.py
@@ -180,7 +180,7 @@ async def list_users(
offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut:
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
- параметра — все) и текстовый поиск по email/имени."""
+ параметра — все) и текстовый поиск по email/имени/названию команды."""
rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset
)
diff --git a/backend/repositories/admin.py b/backend/repositories/admin.py
index 6ee3819..e7376be 100644
--- a/backend/repositories/admin.py
+++ b/backend/repositories/admin.py
@@ -67,9 +67,11 @@ class AdminUserRepository:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
- без фильтра (все пользователи). `LEFT JOIN` на `teams` — имя команды
- нужно карточке профиля/таблице админки, у пользователя без команды —
- `None`.
+ без фильтра (все пользователи). `q` ищет по email/имени пользователя
+ И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
+ поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
+ `INNER`, — пользователи без команды не должны пропадать из общей
+ выдачи, только не совпадать с поиском по названию команды).
"""
filters: list[ColumnElement[bool]] = []
if status == "active":
@@ -78,9 +80,13 @@ class AdminUserRepository:
filters.append(User.is_blocked.is_(True))
if q:
like = f"%{q}%"
- filters.append(or_(User.email.ilike(like), User.name_user.ilike(like)))
+ filters.append(
+ or_(User.email.ilike(like), User.name_user.ilike(like), Team.name.ilike(like))
+ )
- count_stmt = select(func.count()).select_from(User)
+ count_stmt = (
+ select(func.count()).select_from(User).outerjoin(Team, User.team_id == Team.id)
+ )
items_stmt = (
select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id)
diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py
index 0b2370c..7747c2d 100644
--- a/backend/tests/test_admin_api.py
+++ b/backend/tests/test_admin_api.py
@@ -413,6 +413,35 @@ async def test_list_users_filters_by_status(
assert str(blocked_user.id) in all_ids
+async def test_list_users_search_matches_team_name(
+ client: httpx.AsyncClient, db_session: AsyncSession
+) -> None:
+ """`q` находит пользователей по (части) названия их команды; пользователи
+ без команды при этом не пропадают из общей (без поиска) выдачи."""
+ admin = await _make_user(db_session, role="admin")
+ suffix = uuid.uuid4().hex[:8]
+ team = Team(name=f"Rocket-{suffix} Squad")
+ db_session.add(team)
+ await db_session.flush()
+ teamed_user = await _make_user(db_session)
+ teamed_user.team_id = team.id
+ teamless_user = await _make_user(db_session)
+ await db_session.commit()
+
+ response = await client.get(
+ "/api/v1/admin/users", params={"q": f"Rocket-{suffix}"}, headers=_auth_headers(admin)
+ )
+ assert response.status_code == 200, response.text
+ ids = [item["id"] for item in response.json()["items"]]
+ assert str(teamed_user.id) in ids
+ assert str(teamless_user.id) not in ids
+ assert str(admin.id) not in ids
+
+ all_response = await client.get("/api/v1/admin/users", headers=_auth_headers(admin))
+ all_ids = [item["id"] for item in all_response.json()["items"]]
+ assert str(teamless_user.id) in all_ids
+
+
async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
diff --git a/frontend/src/components/admin/AdminUsersTab.tsx b/frontend/src/components/admin/AdminUsersTab.tsx
index 33a78a1..cd05e54 100644
--- a/frontend/src/components/admin/AdminUsersTab.tsx
+++ b/frontend/src/components/admin/AdminUsersTab.tsx
@@ -124,7 +124,7 @@ export function AdminUsersTab() {
setSearchInput(e.target.value)}
/>