feat(admin): вкладки фильтра по статусу в списке пользователей

Задача 3 переопределена оператором: вместо окончательного удаления
пользователя (упёрлось в CHECK-constraint'ы participant/chat_messages,
требующие миграции схемы — решили отложить) добавлены вкладки
«Активные»/«Заблокированные»/«Все» перед полем поиска в админке —
список фильтруется по `is_blocked` на бэкенде (GET /admin/users?status=).
This commit is contained in:
2026-07-27 20:59:29 +03:00
parent 25ffd9e678
commit 09215da22a
5 changed files with 85 additions and 12 deletions

View File

@@ -13,7 +13,7 @@
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated, Literal
import anyio import anyio
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
@@ -174,12 +174,16 @@ async def send_conference_invitations(
async def list_users( async def list_users(
admin: Annotated[User, Depends(require_admin)], admin: Annotated[User, Depends(require_admin)],
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
status: Annotated[Literal["active", "blocked"] | None, Query()] = None,
q: Annotated[str | None, Query()] = None, q: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT, limit: Annotated[int, Query(gt=0, le=MAX_LIMIT)] = DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
) -> AdminUserListOut: ) -> AdminUserListOut:
"""Список всех пользователей инстанса с текстовым поиском по email/имени.""" """Список пользователей инстанса — фильтр по статусу (`active`/`blocked`, без
rows, total = await AdminUserRepository(session).list_paginated(q=q, limit=limit, offset=offset) параметра — все) и текстовый поиск по email/имени."""
rows, total = await AdminUserRepository(session).list_paginated(
status=status, q=q, limit=limit, offset=offset
)
media_root = _media_root() media_root = _media_root()
items = [ items = [
_to_admin_user_out(user, team_name=team_name, media_root=media_root) _to_admin_user_out(user, team_name=team_name, media_root=media_root)

View File

@@ -2,7 +2,7 @@
import uuid import uuid
from sqlalchemy import func, or_, select from sqlalchemy import ColumnElement, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.conference import Conference from models.conference import Conference
@@ -56,20 +56,26 @@ class AdminConferenceRepository:
class AdminUserRepository: class AdminUserRepository:
"""Постраничный список пользователей с текстовым поиском по email/имени.""" """Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None: def __init__(self, session: AsyncSession) -> None:
self._session = session self._session = session
async def list_paginated( 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]: ) -> tuple[list[tuple[User, str | None]], int]:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений. """Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`LEFT JOIN` на `teams` — имя команды нужно карточке профиля/таблице `status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
админки, у пользователя без команды — `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: 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)))

View File

@@ -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) 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( async def test_patch_user_role_and_block(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:

View File

@@ -137,6 +137,8 @@ export type AdminUserDetailOut = AdminUserOut
/** Параметры выборки списка пользователей. */ /** Параметры выборки списка пользователей. */
export interface AdminUserQuery { export interface AdminUserQuery {
/** Фильтр по блокировке — без параметра отдаются все пользователи. */
status?: 'active' | 'blocked'
q?: string q?: string
limit?: number limit?: number
offset?: number offset?: number
@@ -221,7 +223,7 @@ export async function sendConferenceInvitations(id: string, emails?: string[]):
/** Список пользователей для админки — с поиском и пагинацией. */ /** Список пользователей для админки — с поиском и пагинацией. */
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> { export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
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<PagedResult<AdminUserOut>>(`/admin/users${qs}`) return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
} }

View File

@@ -13,6 +13,12 @@ const PAGE_SIZE = 10
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */ /** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
const TEAMS_LIMIT = 200 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, * роли, блокировкой и командой (design/mockups/admin.html,
@@ -28,6 +34,7 @@ const TEAMS_LIMIT = 200
*/ */
export function AdminUsersTab() { export function AdminUsersTab() {
const { user: currentUser } = useAuth() const { user: currentUser } = useAuth()
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('all')
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
@@ -45,8 +52,14 @@ export function AdminUsersTab() {
}, [searchInput]) }, [searchInput])
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', search, offset], queryKey: ['admin', 'users', statusFilter, search, offset],
queryFn: () => listAdminUsers({ q: search || undefined, limit: PAGE_SIZE, offset }), queryFn: () =>
listAdminUsers({
status: statusFilter === 'all' ? undefined : statusFilter,
q: search || undefined,
limit: PAGE_SIZE,
offset,
}),
}) })
const { data: teamsData } = useQuery({ const { data: teamsData } = useQuery({
@@ -89,6 +102,22 @@ export function AdminUsersTab() {
return ( return (
<> <>
<div className="section-tabs-inline">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value)
setOffset(0)
}}
>
{f.label}
</button>
))}
</div>
<div className="toolbar-row"> <div className="toolbar-row">
<div className="toolbar-left"> <div className="toolbar-left">
<div className="search-wrap"> <div className="search-wrap">