feat(admin): поиск пользователей по названию команды

AdminUserRepository.list_paginated ищет теперь и по названию команды
(LEFT JOIN teams, как и раньше) — пользователи без команды по-прежнему
не пропадают из общей выдачи, просто не совпадают с этой частью поиска.
Плейсхолдер поля поиска в админке обновлён под новое поведение.
This commit is contained in:
2026-07-27 21:04:26 +03:00
parent 09215da22a
commit c4721fcf99
4 changed files with 42 additions and 7 deletions

View File

@@ -180,7 +180,7 @@ async def list_users(
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut: ) -> AdminUserListOut:
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без """Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
параметра — все) и текстовый поиск по email/имени.""" параметра — все) и текстовый поиск по email/имени/названию команды."""
rows, total = await AdminUserRepository(session).list_paginated( rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset status=status, q=q, limit=limit, offset=offset
) )

View File

@@ -67,9 +67,11 @@ class AdminUserRepository:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений. """Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` — `status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
без фильтра (все пользователи). `LEFT JOIN` на `teams` — имя команды без фильтра (все пользователи). `q` ищет по email/имени пользователя
нужно карточке профиля/таблице админки, у пользователя без команды — И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
`None`. поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
`INNER`, — пользователи без команды не должны пропадать из общей
выдачи, только не совпадать с поиском по названию команды).
""" """
filters: list[ColumnElement[bool]] = [] filters: list[ColumnElement[bool]] = []
if status == "active": if status == "active":
@@ -78,9 +80,13 @@ class AdminUserRepository:
filters.append(User.is_blocked.is_(True)) filters.append(User.is_blocked.is_(True))
if q: if q:
like = f"%{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 = ( items_stmt = (
select(User, Team.name) select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id) .outerjoin(Team, User.team_id == Team.id)

View File

@@ -413,6 +413,35 @@ async def test_list_users_filters_by_status(
assert str(blocked_user.id) in all_ids 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( async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:

View File

@@ -124,7 +124,7 @@ export function AdminUsersTab() {
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" /> <Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input <input
type="text" type="text"
placeholder="Поиск по имени или email…" placeholder="Поиск по имени, email или команде…"
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
/> />