Админ может править игроков, не заходя в базу

В карточке появились ник, звание, подписка на рассылки и корректировка баланса: баланс меняется обычной записью в журнал money с действием «Корректировка админом», поэтому /инфо сразу показывает новую сумму. Все четыре действия пишутся в bot.user_changes — кто, когда, было → стало и причина; история видна в той же карточке. Бот читает эти таблицы напрямую, так что изменения действуют без перезапуска.
This commit is contained in:
MpakobecEx 2026-10-19 03:58:25 +05:00
parent 82fa21da3d
commit 92791f1ba9
8 changed files with 618 additions and 21 deletions

View File

@ -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`.

View File

@ -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)

View File

@ -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 %}

View File

@ -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

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.4.6"
__version__ = "0.4.7"

22
sql/V18__user_changes.sql Normal file
View File

@ -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;

View File

@ -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

View File

@ -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"