DispelEvilBot/admin/routes/users.py

239 lines
9.5 KiB
Python
Raw 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.

"""Раздел «Пользователи»: список с поиском, карточка и управление."""
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)