diff --git a/backend/api/admin.py b/backend/api/admin.py index 1fb7d80..d03d584 100644 --- a/backend/api/admin.py +++ b/backend/api/admin.py @@ -13,7 +13,7 @@ import uuid from pathlib import Path -from typing import Annotated +from typing import Annotated, Literal import anyio from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status @@ -174,12 +174,16 @@ async def send_conference_invitations( async def list_users( admin: Annotated[User, Depends(require_admin)], session: Annotated[AsyncSession, Depends(get_session)], + status: Annotated[Literal["active", "blocked"] | None, Query()] = None, q: Annotated[str | None, Query()] = None, limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT, offset: Annotated[int, Query(ge=0)] = 0, ) -> AdminUserListOut: - """Список всех пользователей инстанса с текстовым поиском по email/имени.""" - rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset) + """Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без + параметра — все) и текстовый поиск по email/имени.""" + rows, total = await AdminUserRepository(session).list_paginated( + status=status, q=q, limit=limit, offset=offset + ) media_root = _media_root() items = [ _to_admin_user_out(user, team_name=team_name, media_root=media_root) diff --git a/backend/repositories/admin.py b/backend/repositories/admin.py index 91e0a2a..6ee3819 100644 --- a/backend/repositories/admin.py +++ b/backend/repositories/admin.py @@ -2,7 +2,7 @@ import uuid -from sqlalchemy import func, or_, select +from sqlalchemy import ColumnElement, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from models.conference import Conference @@ -56,20 +56,26 @@ class AdminConferenceRepository: class AdminUserRepository: - """Постраничный список пользователей с текстовым поиском по email/имени.""" + """Постраничный список пользователей с фильтром по статусу и текстовым поиском.""" def __init__(self, session: AsyncSession) -> None: self._session = session async def list_paginated( - self, *, q: str | None, limit: int, offset: int + self, *, status: str | None, q: str | None, limit: int, offset: int ) -> tuple[list[tuple[User, str | None]], int]: """Вернуть страницу пользователей (+ имя команды) и общее число совпадений. - `LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице - админки, у пользователя без команды — `None`. + `status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` — + без фильтра (все пользователи). `LEFT JOIN` на `teams` — имя команды + нужно карточке профиля/таблице админки, у пользователя без команды — + `None`. """ - filters = [] + filters: list[ColumnElement[bool]] = [] + if status == "active": + filters.append(User.is_blocked.is_(False)) + elif status == "blocked": + filters.append(User.is_blocked.is_(True)) if q: like = f"%{q}%" filters.append(or_(User.email.ilike(like), User.name_user.ilike(like))) diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py index ea0dd07..0b2370c 100644 --- a/backend/tests/test_admin_api.py +++ b/backend/tests/test_admin_api.py @@ -381,6 +381,38 @@ async def test_list_users_returns_all(client: httpx.AsyncClient, db_session: Asy assert all("email_verified" in item for item in items) +async def test_list_users_filters_by_status( + client: httpx.AsyncClient, db_session: AsyncSession +) -> None: + """`status=active`/`blocked` фильтрует по `is_blocked`; без параметра — все.""" + admin = await _make_user(db_session, role="admin") + active_user = await _make_user(db_session) + blocked_user = await _make_user(db_session) + blocked_user.is_blocked = True + await db_session.commit() + + active_response = await client.get( + "/api/v1/admin/users", params={"status": "active"}, headers=_auth_headers(admin) + ) + assert active_response.status_code == 200, active_response.text + active_ids = [item["id"] for item in active_response.json()["items"]] + assert str(active_user.id) in active_ids + assert str(blocked_user.id) not in active_ids + + blocked_response = await client.get( + "/api/v1/admin/users", params={"status": "blocked"}, headers=_auth_headers(admin) + ) + assert blocked_response.status_code == 200, blocked_response.text + blocked_ids = [item["id"] for item in blocked_response.json()["items"]] + assert str(blocked_user.id) in blocked_ids + assert str(active_user.id) not in blocked_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(active_user.id) in all_ids + assert str(blocked_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/api/admin.ts b/frontend/src/api/admin.ts index 54806d3..3e5d062 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -137,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut /** Параметры выборки списка пользователей. */ export interface AdminUserQuery { + /** Фильтр по блокировке — без параметра отдаются все пользователи. */ + status?: 'active' | 'blocked' q?: string limit?: number offset?: number @@ -221,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]): /** Список пользователей для админки — с поиском и пагинацией. */ export async function listAdminUsers(query: AdminUserQuery = {}): Promise> { - const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset }) + const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset }) return apiRequest>(`/admin/users${qs}`) } diff --git a/frontend/src/components/admin/AdminUsersTab.tsx b/frontend/src/components/admin/AdminUsersTab.tsx index 6ebcbf6..33a78a1 100644 --- a/frontend/src/components/admin/AdminUsersTab.tsx +++ b/frontend/src/components/admin/AdminUsersTab.tsx @@ -13,6 +13,12 @@ const PAGE_SIZE = 10 /** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */ const TEAMS_LIMIT = 200 +const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [ + { value: 'active', label: 'Активные' }, + { value: 'blocked', label: 'Заблокированные' }, + { value: 'all', label: 'Все' }, +] + /** * Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой * роли, блокировкой и командой (design/mockups/admin.html, @@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200 */ export function AdminUsersTab() { const { user: currentUser } = useAuth() + const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('all') const [searchInput, setSearchInput] = useState('') const [search, setSearch] = useState('') const [offset, setOffset] = useState(0) @@ -45,8 +52,14 @@ export function AdminUsersTab() { }, [searchInput]) const { data, isLoading } = useQuery({ - queryKey: ['admin', 'users', search, offset], - queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }), + queryKey: ['admin', 'users', statusFilter, search, offset], + queryFn: () => + listAdminUsers({ + status: statusFilter === 'all' ? undefined : statusFilter, + q: search || undefined, + limit: PAGE_SIZE, + offset, + }), }) const { data: teamsData } = useQuery({ @@ -89,6 +102,22 @@ export function AdminUsersTab() { return ( <> +
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+