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() {