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
|
||||
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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user