Админ может править игроков, не заходя в базу
В карточке появились ник, звание, подписка на рассылки и корректировка баланса: баланс меняется обычной записью в журнал money с действием «Корректировка админом», поэтому /инфо сразу показывает новую сумму. Все четыре действия пишутся в bot.user_changes — кто, когда, было → стало и причина; история видна в той же карточке. Бот читает эти таблицы напрямую, так что изменения действуют без перезапуска.
This commit is contained in:
parent
82fa21da3d
commit
92791f1ba9
|
|
@ -74,7 +74,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
|
||||||
|
|
||||||
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
|
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
|
||||||
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
|
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
|
||||||
подписка на рассылки, дата вступления и последние операции по балансу.
|
подписка на рассылки, дата вступления и последние операции по балансу. В карточке можно
|
||||||
|
сменить ник и звание, включить/выключить подписку и скорректировать баланс (запись в
|
||||||
|
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
||||||
|
`bot.user_changes`: кто, когда, было → стало и причина.
|
||||||
|
|
||||||
## Аналитика (ClickHouse)
|
## Аналитика (ClickHouse)
|
||||||
|
|
||||||
|
|
@ -137,7 +140,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
|
|
||||||
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.6`.
|
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.7`.
|
||||||
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""Раздел «Пользователи»: список с поиском и карточка игрока."""
|
"""Раздел «Пользователи»: список с поиском, карточка и управление."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from admin.security import check_csrf
|
||||||
from common import __version__
|
from common import __version__
|
||||||
from common.repositories import users_admin_repo
|
from common.repositories import users_admin_repo
|
||||||
|
|
||||||
|
|
@ -14,6 +15,42 @@ router = APIRouter(prefix="/users", tags=["users"])
|
||||||
PAGE_SIZE = 25
|
PAGE_SIZE = 25
|
||||||
|
|
||||||
|
|
||||||
|
def _editor_id(request: Request) -> int:
|
||||||
|
"""Telegram-ID текущего администратора (ставит middleware)."""
|
||||||
|
return int(request.state.admin["telegram_id"])
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
"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)
|
@router.get("", response_class=HTMLResponse)
|
||||||
async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse:
|
async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse:
|
||||||
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
|
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
|
||||||
|
|
@ -40,18 +77,105 @@ async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLRespon
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_class=HTMLResponse)
|
@router.get("/{user_id}", response_class=HTMLResponse)
|
||||||
async def user_card(request: Request, user_id: int) -> HTMLResponse:
|
async def user_card(request: Request, user_id: int, saved: str | None = None) -> HTMLResponse:
|
||||||
"""Карточка пользователя со сводкой и последними операциями по балансу."""
|
"""Карточка пользователя: сводка, управление, история и операции."""
|
||||||
user = await users_admin_repo.get_user(user_id)
|
return await _card_response(request, user_id, saved=saved)
|
||||||
if user is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
|
||||||
|
|
||||||
return request.app.state.templates.TemplateResponse(
|
|
||||||
request=request,
|
@router.post("/{user_id}/nick")
|
||||||
name="users/card.html",
|
async def update_nick(request: Request, user_id: int) -> Response:
|
||||||
context={
|
"""Сменить ник игрока."""
|
||||||
"version": __version__,
|
form = await request.form()
|
||||||
"user": user,
|
check_csrf(request, str(form.get("csrf_token", "")))
|
||||||
"money": await users_admin_repo.money_history(user_id),
|
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)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,22 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if saved %}
|
||||||
|
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||||
|
{% if saved == 'nick' %}Ник обновлён.
|
||||||
|
{% elif saved == 'title' %}Звание обновлено.
|
||||||
|
{% elif saved == 'subscription' %}Подписка обновлена.
|
||||||
|
{% elif saved == 'balance' %}Баланс скорректирован.
|
||||||
|
{% else %}Изменения сохранены.{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if errors %}
|
||||||
|
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||||
|
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
<div class="text-xs uppercase tracking-wide text-slate-500">Уровень</div>
|
<div class="text-xs uppercase tracking-wide text-slate-500">Уровень</div>
|
||||||
|
|
@ -72,6 +88,120 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-lg font-medium">Управление</h2>
|
||||||
|
<div class="mt-3 grid gap-4 lg:grid-cols-2">
|
||||||
|
<form method="post" action="/users/{{ user.id_user }}/nick" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||||
|
<label for="nick" class="text-sm font-medium text-slate-700">Ник</label>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
id="nick"
|
||||||
|
name="nick"
|
||||||
|
type="text"
|
||||||
|
value="{{ user.nick or '' }}"
|
||||||
|
maxlength="{{ max_nick_length }}"
|
||||||
|
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/users/{{ user.id_user }}/title" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||||
|
<label for="title_id" class="text-sm font-medium text-slate-700">Звание</label>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<select
|
||||||
|
id="title_id"
|
||||||
|
name="title_id"
|
||||||
|
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{% for title in titles %}
|
||||||
|
<option value="{{ title.id_title }}" {% if title.id_title == user.id_title %}selected{% endif %}>
|
||||||
|
{{ title.title }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/users/{{ user.id_user }}/subscription" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||||
|
<input type="hidden" name="subscribed" value="{{ '0' if user.subscribed else '1' }}" />
|
||||||
|
<div class="text-sm font-medium text-slate-700">Рассылки</div>
|
||||||
|
<p class="mt-1 text-sm text-slate-600">
|
||||||
|
{% if user.subscribed %}Сейчас подписан.{% else %}Сейчас не подписан.{% endif %}
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="mt-2 rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
|
{% if user.subscribed %}Отписать{% else %}Подписать{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/users/{{ user.id_user }}/balance" class="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||||
|
<div class="text-sm font-medium text-slate-700">Корректировка баланса</div>
|
||||||
|
<div class="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
name="amount"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
placeholder="+50 или -20"
|
||||||
|
class="w-32 rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="reason"
|
||||||
|
type="text"
|
||||||
|
maxlength="{{ max_comment_length }}"
|
||||||
|
placeholder="Причина"
|
||||||
|
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||||
|
Провести
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Запись уйдёт в журнал денег с действием «Корректировка админом».</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-lg font-medium">История изменений</h2>
|
||||||
|
{% if history %}
|
||||||
|
<div class="mt-3 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||||
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead class="bg-slate-50 text-left text-slate-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-medium">Когда</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Что</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Было → стало</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Комментарий</th>
|
||||||
|
<th class="px-4 py-3 font-medium">Админ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-100">
|
||||||
|
{% set field_labels = {"nick": "Ник", "title": "Звание", "subscription": "Подписка", "balance": "Баланс"} %}
|
||||||
|
{% for item in history %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</td>
|
||||||
|
<td class="px-4 py-3">{{ field_labels.get(item.field, item.field) }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-700">{{ item.old_value or "—" }} → {{ item.new_value or "—" }}</td>
|
||||||
|
<td class="px-4 py-3 text-slate-600">{{ item.comment or "—" }}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Ручных изменений ещё не было.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-lg font-medium">Последние операции по балансу</h2>
|
<h2 class="text-lg font-medium">Последние операции по балансу</h2>
|
||||||
{% if money %}
|
{% if money %}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ _FROM = """
|
||||||
_SELECT = """
|
_SELECT = """
|
||||||
SELECT tg.id_user, tg.username, tg.first_name, tg.last_name,
|
SELECT tg.id_user, tg.username, tg.first_name, tg.last_name,
|
||||||
du.nick, du.lvl, du.expr, du.expr_streak, du.dt_create,
|
du.nick, du.lvl, du.expr, du.expr_streak, du.dt_create,
|
||||||
|
du.id_title,
|
||||||
t.title,
|
t.title,
|
||||||
coalesce(msg.cnt, 0) AS msg_count,
|
coalesce(msg.cnt, 0) AS msg_count,
|
||||||
msg.last_dt AS last_message_at,
|
msg.last_dt AS last_message_at,
|
||||||
|
|
@ -111,3 +112,172 @@ async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]
|
||||||
(user_id, limit),
|
(user_id, limit),
|
||||||
)
|
)
|
||||||
return await cur.fetchall()
|
return await cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Управление пользователем (Блок 4.3) -------------------------------------
|
||||||
|
|
||||||
|
#: Действие «Корректировка админом» из V18 (ручное изменение баланса).
|
||||||
|
ADMIN_ACTION_ID = 6
|
||||||
|
|
||||||
|
#: Ограничение длины ника (как в команде /ник).
|
||||||
|
MAX_NICK_LENGTH = 32
|
||||||
|
|
||||||
|
#: Ограничение длины комментария к корректировке баланса.
|
||||||
|
MAX_COMMENT_LENGTH = 200
|
||||||
|
|
||||||
|
|
||||||
|
async def list_titles() -> list[dict[str, Any]]:
|
||||||
|
"""Доступные звания для выбора в карточке."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
cur = await conn.execute("SELECT id_title, title FROM bot.titles WHERE visible ORDER BY price DESC, id_title")
|
||||||
|
return await cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""История ручных изменений пользователя."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
cur = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, field, old_value, new_value, comment, editor_id, changed_at
|
||||||
|
FROM bot.user_changes
|
||||||
|
WHERE id_user = %s
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(user_id, limit),
|
||||||
|
)
|
||||||
|
return await cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_change(
|
||||||
|
conn: Any,
|
||||||
|
user_id: int,
|
||||||
|
field: str,
|
||||||
|
old_value: str | None,
|
||||||
|
new_value: str | None,
|
||||||
|
editor_id: int | None,
|
||||||
|
comment: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(user_id, field, old_value, new_value, comment, editor_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool:
|
||||||
|
"""Сменить ник (создаёт запись de_users, если её ещё нет)."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (user_id,))
|
||||||
|
row = await cur.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (user_id, nick))
|
||||||
|
old_nick = None
|
||||||
|
else:
|
||||||
|
old_nick = row["nick"]
|
||||||
|
if old_nick == nick:
|
||||||
|
return True
|
||||||
|
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, user_id))
|
||||||
|
|
||||||
|
await _log_change(conn, user_id, "nick", old_nick, nick, editor_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool:
|
||||||
|
"""Сменить звание пользователя. False — если пользователя или звания нет."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
cur = await conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT du.id_title, coalesce(t.title, '') AS title
|
||||||
|
FROM bot.de_users AS du
|
||||||
|
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
|
||||||
|
WHERE du.id_user = %s
|
||||||
|
FOR UPDATE OF du
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
current = await cur.fetchone()
|
||||||
|
if current is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cur = await conn.execute("SELECT title FROM bot.titles WHERE id_title = %s", (title_id,))
|
||||||
|
title = await cur.fetchone()
|
||||||
|
if title is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if current["id_title"] == title_id:
|
||||||
|
return True
|
||||||
|
|
||||||
|
await conn.execute("UPDATE bot.de_users SET id_title = %s WHERE id_user = %s", (title_id, user_id))
|
||||||
|
await _log_change(conn, user_id, "title", current["title"], title["title"], editor_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool:
|
||||||
|
"""Подписать или отписать пользователя от рассылок."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
||||||
|
if await cur.fetchone() is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cur = await conn.execute("SELECT 1 FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
||||||
|
was_subscribed = await cur.fetchone() is None
|
||||||
|
if was_subscribed == subscribed:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if subscribed:
|
||||||
|
await conn.execute("DELETE FROM bot.unsubscribe WHERE id_user = %s", (user_id,))
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO bot.unsubscribe (id_user) VALUES (%s) ON CONFLICT DO NOTHING",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
await _log_change(
|
||||||
|
conn,
|
||||||
|
user_id,
|
||||||
|
"subscription",
|
||||||
|
"да" if was_subscribed else "нет",
|
||||||
|
"да" if subscribed else "нет",
|
||||||
|
editor_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def adjust_balance(
|
||||||
|
user_id: int,
|
||||||
|
amount: int,
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
editor_id: int | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
"""Изменить баланс записью в money. Возвращает новый баланс или None."""
|
||||||
|
async with get_pool().connection() as conn:
|
||||||
|
cur = await conn.execute("SELECT 1 FROM bot.tg_users WHERE id_user = %s", (user_id,))
|
||||||
|
if await cur.fetchone() is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur = await conn.execute(
|
||||||
|
"SELECT coalesce(sum(amount), 0) AS balance FROM bot.money WHERE id_user = %s", (user_id,)
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
old_balance = int(row["balance"])
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO bot.money (id_user, amount, id_action) VALUES (%s, %s, %s)",
|
||||||
|
(user_id, amount, ADMIN_ACTION_ID),
|
||||||
|
)
|
||||||
|
new_balance = old_balance + amount
|
||||||
|
await _log_change(
|
||||||
|
conn,
|
||||||
|
user_id,
|
||||||
|
"balance",
|
||||||
|
str(old_balance),
|
||||||
|
str(new_balance),
|
||||||
|
editor_id,
|
||||||
|
comment=reason,
|
||||||
|
)
|
||||||
|
return new_balance
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.4.6"
|
__version__ = "0.4.7"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- История ручных изменений пользователей из админки (Блок 4.3).
|
||||||
|
CREATE TABLE IF NOT EXISTS bot.user_changes (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
id_user bigint NOT NULL,
|
||||||
|
field text NOT NULL CHECK (field IN ('nick', 'title', 'subscription', 'balance')),
|
||||||
|
old_value text,
|
||||||
|
new_value text,
|
||||||
|
comment text,
|
||||||
|
editor_id bigint,
|
||||||
|
changed_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_changes_user_time
|
||||||
|
ON bot.user_changes (id_user, changed_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE bot.user_changes IS 'Аудит ручных изменений пользователей администратором';
|
||||||
|
COMMENT ON COLUMN bot.user_changes.field IS 'Что изменено: nick, title, subscription или balance';
|
||||||
|
|
||||||
|
-- Отдельное действие для ручных корректировок баланса (видно в истории операций).
|
||||||
|
INSERT INTO bot.actions (id_action, title)
|
||||||
|
VALUES (6, 'Корректировка админом')
|
||||||
|
ON CONFLICT (id_action) DO NOTHING;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Тесты раздела «Пользователи» (без БД — репозиторий подменяется)."""
|
"""Тесты раздела «Пользователи»: список, карточка и управление (без БД)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -6,6 +6,7 @@ from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from common.repositories import users_admin_repo
|
from common.repositories import users_admin_repo
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ USER: dict[str, Any] = {
|
||||||
"expr": 120,
|
"expr": 120,
|
||||||
"expr_streak": 2,
|
"expr_streak": 2,
|
||||||
"dt_create": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
"dt_create": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
||||||
|
"id_title": 1,
|
||||||
"title": "Ветеран",
|
"title": "Ветеран",
|
||||||
"msg_count": 42,
|
"msg_count": 42,
|
||||||
"last_message_at": datetime(2026, 10, 18, 20, 0, tzinfo=UTC),
|
"last_message_at": datetime(2026, 10, 18, 20, 0, tzinfo=UTC),
|
||||||
|
|
@ -34,6 +36,23 @@ MONEY: list[dict[str, Any]] = [
|
||||||
{"id_money": 1, "amount": -20, "dt_action": datetime(2026, 10, 17, 12, 0, tzinfo=UTC), "action": "Покупка"},
|
{"id_money": 1, "amount": -20, "dt_action": datetime(2026, 10, 17, 12, 0, tzinfo=UTC), "action": "Покупка"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TITLES: list[dict[str, Any]] = [
|
||||||
|
{"id_title": 1, "title": "Ветеран"},
|
||||||
|
{"id_title": 2, "title": "Новичок"},
|
||||||
|
]
|
||||||
|
|
||||||
|
HISTORY: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"field": "nick",
|
||||||
|
"old_value": "Старый",
|
||||||
|
"new_value": "ТестовыйНик",
|
||||||
|
"comment": None,
|
||||||
|
"editor_id": 1,
|
||||||
|
"changed_at": datetime(2026, 10, 18, 12, 0, tzinfo=UTC),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def fake_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
def fake_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
|
|
@ -53,13 +72,51 @@ def fake_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||||
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||||
return MONEY if user_id == USER["id_user"] else []
|
return MONEY if user_id == USER["id_user"] else []
|
||||||
|
|
||||||
|
async def list_titles() -> list[dict[str, Any]]:
|
||||||
|
return TITLES
|
||||||
|
|
||||||
|
async def user_history(user_id: int, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
return HISTORY if user_id == USER["id_user"] else []
|
||||||
|
|
||||||
|
async def set_nick(user_id: int, nick: str, *, editor_id: int | None = None) -> bool:
|
||||||
|
state["nick"] = (user_id, nick, editor_id)
|
||||||
|
return user_id == USER["id_user"]
|
||||||
|
|
||||||
|
async def set_title(user_id: int, title_id: int, *, editor_id: int | None = None) -> bool:
|
||||||
|
state["title"] = (user_id, title_id, editor_id)
|
||||||
|
return user_id == USER["id_user"]
|
||||||
|
|
||||||
|
async def set_subscription(user_id: int, subscribed: bool, *, editor_id: int | None = None) -> bool:
|
||||||
|
state["subscription"] = (user_id, subscribed, editor_id)
|
||||||
|
return user_id == USER["id_user"]
|
||||||
|
|
||||||
|
async def adjust_balance(
|
||||||
|
user_id: int,
|
||||||
|
amount: int,
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
editor_id: int | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
state["balance"] = (user_id, amount, reason, editor_id)
|
||||||
|
return USER["balance"] + amount if user_id == USER["id_user"] else None
|
||||||
|
|
||||||
monkeypatch.setattr(users_admin_repo, "count_users", count_users)
|
monkeypatch.setattr(users_admin_repo, "count_users", count_users)
|
||||||
monkeypatch.setattr(users_admin_repo, "list_users", list_users)
|
monkeypatch.setattr(users_admin_repo, "list_users", list_users)
|
||||||
monkeypatch.setattr(users_admin_repo, "get_user", get_user)
|
monkeypatch.setattr(users_admin_repo, "get_user", get_user)
|
||||||
monkeypatch.setattr(users_admin_repo, "money_history", money_history)
|
monkeypatch.setattr(users_admin_repo, "money_history", money_history)
|
||||||
|
monkeypatch.setattr(users_admin_repo, "list_titles", list_titles)
|
||||||
|
monkeypatch.setattr(users_admin_repo, "user_history", user_history)
|
||||||
|
monkeypatch.setattr(users_admin_repo, "set_nick", set_nick)
|
||||||
|
monkeypatch.setattr(users_admin_repo, "set_title", set_title)
|
||||||
|
monkeypatch.setattr(users_admin_repo, "set_subscription", set_subscription)
|
||||||
|
monkeypatch.setattr(users_admin_repo, "adjust_balance", adjust_balance)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _csrf(client: TestClient, csrf_token) -> str:
|
||||||
|
return csrf_token(client.get("/users/42").text)
|
||||||
|
|
||||||
|
|
||||||
def test_users_list(fake_repo: dict[str, Any], client_factory) -> None:
|
def test_users_list(fake_repo: dict[str, Any], client_factory) -> None:
|
||||||
response = client_factory().get("/users")
|
response = client_factory().get("/users")
|
||||||
|
|
||||||
|
|
@ -89,10 +146,101 @@ def test_user_card(fake_repo: dict[str, Any], client_factory) -> None:
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Ветеран" in response.text
|
assert "Ветеран" in response.text
|
||||||
|
assert "Управление" in response.text
|
||||||
assert "Очищение" in response.text
|
assert "Очищение" in response.text
|
||||||
assert "+50" in response.text
|
assert "+50" in response.text
|
||||||
assert "-20" in response.text
|
assert "-20" in response.text
|
||||||
|
assert "Старый → ТестовыйНик" in response.text
|
||||||
|
|
||||||
|
|
||||||
def test_user_card_404(fake_repo: dict[str, Any], client_factory) -> None:
|
def test_user_card_404(fake_repo: dict[str, Any], client_factory) -> None:
|
||||||
assert client_factory().get("/users/99").status_code == 404
|
assert client_factory().get("/users/99").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nick(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post("/users/42/nick", data={"nick": "НовыйНик", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Ник обновлён" in response.text
|
||||||
|
assert fake_repo["nick"] == (42, "НовыйНик", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nick_rejects_empty(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post("/users/42/nick", data={"nick": " ", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Ник не может быть пустым" in response.text
|
||||||
|
assert "nick" not in fake_repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_title(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post("/users/42/title", data={"title_id": "2", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Звание обновлено" in response.text
|
||||||
|
assert fake_repo["title"] == (42, 2, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_title_rejects_unknown(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post("/users/42/title", data={"title_id": "999", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Звание не найдено" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_subscription(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post("/users/42/subscription", data={"subscribed": "0", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Подписка обновлена" in response.text
|
||||||
|
assert fake_repo["subscription"] == (42, False, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_balance(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/users/42/balance",
|
||||||
|
data={"amount": "-100", "reason": "Штраф", "csrf_token": token},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Баланс скорректирован" in response.text
|
||||||
|
assert fake_repo["balance"] == (42, -100, "Штраф", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_balance_requires_reason(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post("/users/42/balance", data={"amount": "100", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Укажите причину" in response.text
|
||||||
|
assert "balance" not in fake_repo
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_balance_rejects_zero(fake_repo: dict[str, Any], client_factory, csrf_token) -> None:
|
||||||
|
client: TestClient = client_factory()
|
||||||
|
token = _csrf(client, csrf_token)
|
||||||
|
|
||||||
|
response = client.post("/users/42/balance", data={"amount": "0", "reason": "Тест", "csrf_token": token})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "Сумма не может быть нулевой" in response.text
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.4.6"
|
assert __version__ == "0.4.7"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue