feat(admin): вкладки фильтра по статусу в списке пользователей
Задача 3 переопределена оператором: вместо окончательного удаления пользователя (упёрлось в CHECK-constraint'ы participant/chat_messages, требующие миграции схемы — решили отложить) добавлены вкладки «Активные»/«Заблокированные»/«Все» перед полем поиска в админке — список фильтруется по `is_blocked` на бэкенде (GET /admin/users?status=).
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user