Админ может править игроков, не заходя в базу
В карточке появились ник, звание, подписка на рассылки и корректировка баланса: баланс меняется обычной записью в журнал 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 и карточка:
|
||||
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
|
||||
подписка на рассылки, дата вступления и последние операции по балансу.
|
||||
подписка на рассылки, дата вступления и последние операции по балансу. В карточке можно
|
||||
сменить ник и звание, включить/выключить подписку и скорректировать баланс (запись в
|
||||
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
||||
`bot.user_changes`: кто, когда, было → стало и причина.
|
||||
|
||||
## Аналитика (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`).
|
||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""Раздел «Пользователи»: список с поиском и карточка игрока."""
|
||||
"""Раздел «Пользователи»: список с поиском, карточка и управление."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from admin.security import check_csrf
|
||||
from common import __version__
|
||||
from common.repositories import users_admin_repo
|
||||
|
||||
|
|
@ -14,6 +15,42 @@ 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"])
|
||||
|
||||
|
||||
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)
|
||||
async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse:
|
||||
"""Список пользователей: поиск по нику/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)
|
||||
async def user_card(request: Request, user_id: int) -> HTMLResponse:
|
||||
"""Карточка пользователя со сводкой и последними операциями по балансу."""
|
||||
user = await users_admin_repo.get_user(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
async def user_card(request: Request, user_id: int, saved: str | None = None) -> HTMLResponse:
|
||||
"""Карточка пользователя: сводка, управление, история и операции."""
|
||||
return await _card_response(request, user_id, saved=saved)
|
||||
|
||||
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),
|
||||
},
|
||||
|
||||
@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)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,22 @@
|
|||
</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="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">Уровень</div>
|
||||
|
|
@ -72,6 +88,120 @@
|
|||
</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">
|
||||
<h2 class="text-lg font-medium">Последние операции по балансу</h2>
|
||||
{% if money %}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ _FROM = """
|
|||
_SELECT = """
|
||||
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.id_title,
|
||||
t.title,
|
||||
coalesce(msg.cnt, 0) AS msg_count,
|
||||
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),
|
||||
)
|
||||
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
|
||||
|
||||
__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
|
||||
|
||||
|
|
@ -6,6 +6,7 @@ from datetime import UTC, datetime
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common.repositories import users_admin_repo
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ USER: dict[str, Any] = {
|
|||
"expr": 120,
|
||||
"expr_streak": 2,
|
||||
"dt_create": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
|
||||
"id_title": 1,
|
||||
"title": "Ветеран",
|
||||
"msg_count": 42,
|
||||
"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": "Покупка"},
|
||||
]
|
||||
|
||||
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
|
||||
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]]:
|
||||
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, "list_users", list_users)
|
||||
monkeypatch.setattr(users_admin_repo, "get_user", get_user)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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 "Ветеран" in response.text
|
||||
assert "Управление" in response.text
|
||||
assert "Очищение" in response.text
|
||||
assert "+50" 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:
|
||||
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():
|
||||
assert __version__
|
||||
assert __version__ == "0.4.6"
|
||||
assert __version__ == "0.4.7"
|
||||
|
|
|
|||
Loading…
Reference in New Issue