239 lines
9.5 KiB
Python
239 lines
9.5 KiB
Python
"""Раздел «Пользователи»: список с поиском, карточка и управление."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, HTTPException, Request, Response
|
||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||
|
||
from admin import telegram
|
||
from admin.security import check_csrf
|
||
from common import __version__
|
||
from common.eggs import is_egg_event_active
|
||
from common.repositories import users_admin_repo, users_repo
|
||
from common.texts import alerts as alerts_text
|
||
from common.texts.render import render
|
||
|
||
router = APIRouter(prefix="/users", tags=["users"])
|
||
|
||
#: Сколько пользователей показывать на странице.
|
||
PAGE_SIZE = 25
|
||
|
||
|
||
def _editor_id(request: Request) -> int:
|
||
"""Telegram-ID текущего администратора (ставит middleware)."""
|
||
return int(request.state.admin["telegram_id"])
|
||
|
||
|
||
def _tag(username: str | None, user_id: int) -> str:
|
||
"""``@username`` или числовой ID для уведомлений."""
|
||
return f"@{username}" if username else str(user_id)
|
||
|
||
|
||
def _victim_tag(user: dict[str, object]) -> str:
|
||
if user.get("username"):
|
||
return f"@{user['username']}"
|
||
if user.get("nick"):
|
||
return str(user["nick"])
|
||
return str(user.get("id_user"))
|
||
|
||
|
||
async def _card_response(
|
||
request: Request,
|
||
user_id: int,
|
||
*,
|
||
errors: list[str] | None = None,
|
||
saved: str | None = None,
|
||
status_code: int = 200,
|
||
) -> HTMLResponse:
|
||
"""Отрисовать карточку (общая точка для GET и ошибок валидации)."""
|
||
user = await users_admin_repo.get_user(user_id)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
|
||
return request.app.state.templates.TemplateResponse(
|
||
request=request,
|
||
name="users/card.html",
|
||
context={
|
||
"version": __version__,
|
||
"user": user,
|
||
"money": await users_admin_repo.money_history(user_id),
|
||
"titles": await users_admin_repo.list_titles(),
|
||
"history": await users_admin_repo.user_history(user_id),
|
||
"errors": errors or [],
|
||
"saved": saved,
|
||
"eggs_visible": is_egg_event_active(request.app.state.settings),
|
||
"can_kick": bool(request.app.state.settings.bot_token and request.app.state.settings.chat_id),
|
||
"max_nick_length": users_admin_repo.MAX_NICK_LENGTH,
|
||
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
|
||
},
|
||
status_code=status_code,
|
||
)
|
||
|
||
|
||
@router.get("", response_class=HTMLResponse)
|
||
async def list_users(request: Request, q: str = "", page: int = 1, saved: str | None = None) -> HTMLResponse:
|
||
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
|
||
total = await users_admin_repo.count_users(q)
|
||
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
|
||
page = min(max(page, 1), pages)
|
||
|
||
return request.app.state.templates.TemplateResponse(
|
||
request=request,
|
||
name="users/list.html",
|
||
context={
|
||
"version": __version__,
|
||
"users": await users_admin_repo.list_users(
|
||
q,
|
||
limit=PAGE_SIZE,
|
||
offset=(page - 1) * PAGE_SIZE,
|
||
),
|
||
"q": q,
|
||
"page": page,
|
||
"pages": pages,
|
||
"total": total,
|
||
"saved": saved,
|
||
},
|
||
)
|
||
|
||
|
||
@router.get("/{user_id}", response_class=HTMLResponse)
|
||
async def user_card(request: Request, user_id: int, saved: str | None = None) -> HTMLResponse:
|
||
"""Карточка пользователя: сводка, управление, история и операции."""
|
||
return await _card_response(request, user_id, saved=saved)
|
||
|
||
|
||
@router.post("/{user_id}/nick")
|
||
async def update_nick(request: Request, user_id: int) -> Response:
|
||
"""Сменить ник игрока."""
|
||
form = await request.form()
|
||
check_csrf(request, str(form.get("csrf_token", "")))
|
||
nick = str(form.get("nick", "")).strip()
|
||
|
||
errors: list[str] = []
|
||
if not nick:
|
||
errors.append("Ник не может быть пустым")
|
||
elif len(nick) > users_admin_repo.MAX_NICK_LENGTH:
|
||
errors.append(f"Ник длиннее {users_admin_repo.MAX_NICK_LENGTH} символов")
|
||
elif any(char.isspace() for char in nick):
|
||
errors.append("Ник не должен содержать пробелы")
|
||
|
||
if errors:
|
||
return await _card_response(request, user_id, errors=errors, status_code=400)
|
||
|
||
if not await users_admin_repo.set_nick(user_id, nick, editor_id=_editor_id(request)):
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
return RedirectResponse(url=f"/users/{user_id}?saved=nick", status_code=303)
|
||
|
||
|
||
@router.post("/{user_id}/title")
|
||
async def update_title(request: Request, user_id: int) -> Response:
|
||
"""Сменить звание игрока."""
|
||
form = await request.form()
|
||
check_csrf(request, str(form.get("csrf_token", "")))
|
||
|
||
try:
|
||
title_id = int(str(form.get("title_id", "")))
|
||
except (TypeError, ValueError):
|
||
return await _card_response(request, user_id, errors=["Выберите звание"], status_code=400)
|
||
|
||
titles = await users_admin_repo.list_titles()
|
||
if not any(int(title["id_title"]) == title_id for title in titles):
|
||
return await _card_response(request, user_id, errors=["Звание не найдено"], status_code=400)
|
||
|
||
if not await users_admin_repo.set_title(user_id, title_id, editor_id=_editor_id(request)):
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
return RedirectResponse(url=f"/users/{user_id}?saved=title", status_code=303)
|
||
|
||
|
||
@router.post("/{user_id}/subscription")
|
||
async def update_subscription(request: Request, user_id: int) -> Response:
|
||
"""Подписать или отписать от рассылок."""
|
||
form = await request.form()
|
||
check_csrf(request, str(form.get("csrf_token", "")))
|
||
|
||
value = str(form.get("subscribed", ""))
|
||
if value not in {"0", "1"}:
|
||
return await _card_response(request, user_id, errors=["Некорректное значение подписки"], status_code=400)
|
||
|
||
if not await users_admin_repo.set_subscription(
|
||
user_id,
|
||
value == "1",
|
||
editor_id=_editor_id(request),
|
||
):
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
return RedirectResponse(url=f"/users/{user_id}?saved=subscription", status_code=303)
|
||
|
||
|
||
@router.post("/{user_id}/balance")
|
||
async def update_balance(request: Request, user_id: int) -> Response:
|
||
"""Скорректировать баланс записью в журнал money (действие «Корректировка админом»)."""
|
||
form = await request.form()
|
||
check_csrf(request, str(form.get("csrf_token", "")))
|
||
|
||
raw_amount = str(form.get("amount", "")).strip()
|
||
reason = str(form.get("reason", "")).strip()
|
||
|
||
errors: list[str] = []
|
||
try:
|
||
amount = int(raw_amount)
|
||
except (TypeError, ValueError):
|
||
amount = 0
|
||
errors.append("Сумма должна быть целым числом")
|
||
|
||
if amount == 0 and "Сумма должна быть целым числом" not in errors:
|
||
errors.append("Сумма не может быть нулевой")
|
||
if not reason:
|
||
errors.append("Укажите причину корректировки")
|
||
elif len(reason) > users_admin_repo.MAX_COMMENT_LENGTH:
|
||
errors.append(f"Причина длиннее {users_admin_repo.MAX_COMMENT_LENGTH} символов")
|
||
|
||
if errors:
|
||
return await _card_response(request, user_id, errors=errors, status_code=400)
|
||
|
||
balance = await users_admin_repo.adjust_balance(
|
||
user_id,
|
||
amount,
|
||
reason,
|
||
editor_id=_editor_id(request),
|
||
)
|
||
if balance is None:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303)
|
||
|
||
|
||
@router.post("/{user_id}/kick")
|
||
async def kick_user(request: Request, user_id: int) -> Response:
|
||
"""Исключить пользователя из основного чата (бан + разбан)."""
|
||
form = await request.form()
|
||
check_csrf(request, str(form.get("csrf_token", "")))
|
||
|
||
user = await users_admin_repo.get_user(user_id)
|
||
if user is None:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
|
||
settings = request.app.state.settings
|
||
try:
|
||
await telegram.kick_member(settings, user_id)
|
||
except telegram.TelegramError as exc:
|
||
return await _card_response(
|
||
request,
|
||
user_id,
|
||
errors=[f"Не удалось исключить: {exc}"],
|
||
status_code=400,
|
||
)
|
||
|
||
# Данные удаляем сразу, не полагаясь на событие chat_member (бот может быть остановлен).
|
||
await users_repo.del_user_by_id(user_id)
|
||
|
||
admin = request.state.admin
|
||
text = render(
|
||
alerts_text.MSG_KICK,
|
||
{
|
||
"actor": _tag(admin.get("username"), int(admin["telegram_id"])),
|
||
"username": _victim_tag(user),
|
||
"chat_title": await telegram.chat_title(settings),
|
||
},
|
||
)
|
||
await telegram.send_alert(settings, text)
|
||
return RedirectResponse(url="/users?saved=kick", status_code=303)
|