Files
Max Ronzhin c4721fcf99 feat(admin): поиск пользователей по названию команды
AdminUserRepository.list_paginated ищет теперь и по названию команды
(LEFT JOIN teams, как и раньше) — пользователи без команды по-прежнему
не пропадают из общей выдачи, просто не совпадают с этой частью поиска.
Плейсхолдер поля поиска в админке обновлён под новое поведение.
2026-07-27 21:04:26 +03:00

146 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Репозитории списков конференций/пользователей/команд для админ-API (пагинация, поиск)."""
import uuid
from sqlalchemy import ColumnElement, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.conference import Conference
from models.team import Team
from models.user import User
class AdminConferenceRepository:
"""Постраничный список конференций с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_paginated(
self, *, status: str | None, q: str | None, limit: int, offset: int
) -> tuple[list[tuple[Conference, str | None, str | None]], int]:
"""Вернуть страницу конференций (+ имя/email владельца) и общее число совпадений.
`q` ищет по названию/номеру/ссылке (регистронезависимо, `ILIKE`).
Владелец — `LEFT JOIN` (может отсутствовать, ADR-001) — имя/email
нужны колонке «Владелец» в таблице админки.
"""
filters = []
if status is not None:
filters.append(Conference.status == status)
if q:
like = f"%{q}%"
filters.append(
or_(
Conference.title.ilike(like),
Conference.number.ilike(like),
Conference.slug.ilike(like),
)
)
count_stmt = select(func.count()).select_from(Conference)
items_stmt = (
select(Conference, User.name_user, User.email)
.outerjoin(User, Conference.owner_id == User.id)
.order_by(Conference.created_at.desc())
)
for condition in filters:
count_stmt = count_stmt.where(condition)
items_stmt = items_stmt.where(condition)
items_stmt = items_stmt.limit(limit).offset(offset)
total = (await self._session.execute(count_stmt)).scalar_one()
rows = (await self._session.execute(items_stmt)).all()
items = [(conference, name, email) for conference, name, email in rows]
return items, total
class AdminUserRepository:
"""Постраничный список пользователей с фильтром по статусу и текстовым поиском."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_paginated(
self, *, status: str | None, q: str | None, limit: int, offset: int
) -> tuple[list[tuple[User, str | None]], int]:
"""Вернуть страницу пользователей (+ имя команды) и общее число совпадений.
`status` — `"active"`/`"blocked"` (фильтр по `is_blocked`), `None` —
без фильтра (все пользователи). `q` ищет по email/имени пользователя
И по названию команды (`LEFT JOIN` на `teams` — нужен и для этого
поиска, и для колонки «Команда» в таблице админки; `LEFT`, а не
`INNER`, — пользователи без команды не должны пропадать из общей
выдачи, только не совпадать с поиском по названию команды).
"""
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), Team.name.ilike(like))
)
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)
.order_by(User.created_at.desc())
)
for condition in filters:
count_stmt = count_stmt.where(condition)
items_stmt = items_stmt.where(condition)
items_stmt = items_stmt.limit(limit).offset(offset)
total = (await self._session.execute(count_stmt)).scalar_one()
rows = (await self._session.execute(items_stmt)).all()
return [(user, team_name) for user, team_name in rows], total
async def get_with_team(self, user_id: uuid.UUID) -> tuple[User, str | None] | None:
"""Пользователь + имя команды по id (карточка профиля); `None` — не найден."""
result = await self._session.execute(
select(User, Team.name)
.outerjoin(Team, User.team_id == Team.id)
.where(User.id == user_id)
)
row = result.first()
return (row[0], row[1]) if row is not None else None
class TeamRepository:
"""Справочник команд: список (сортировка по названию), поиск по имени, CRUD."""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_all(self) -> tuple[list[Team], int]:
"""Все команды, отсортированные по названию, и их общее число."""
items_stmt = select(Team).order_by(Team.name)
items = (await self._session.execute(items_stmt)).scalars().all()
return list(items), len(items)
async def get(self, team_id: uuid.UUID) -> Team | None:
"""Найти команду по id."""
return await self._session.get(Team, team_id)
async def get_by_name(self, name: str) -> Team | None:
"""Найти команду по точному названию (проверка дубля перед созданием/переименованием)."""
result = await self._session.execute(select(Team).where(Team.name == name))
return result.scalar_one_or_none()
async def create(self, name: str) -> Team:
"""Создать команду."""
team = Team(name=name)
self._session.add(team)
await self._session.flush()
return team
async def delete(self, team: Team) -> None:
"""Удалить команду (у пользователей `team_id` обнулится через ON DELETE SET NULL)."""
await self._session.delete(team)