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,
) -> AdminUserListOut:
"""Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
параметра — все) и текстовый поиск по email/имени."""
параметра — все) и текстовый поиск по email/имени/названию команды."""
rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset
)

View File

@@ -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)

View File

@@ -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: