В админке появился список игроков с поиском и карточкой

Искать можно по нику, username, имени и Telegram-ID; в карточке видно уровень, опыт с серией, баланс (SUM(money.amount)), яички, сообщения, очищения, подписку и последние операции по балансу. Страница пока только читает PostgreSQL: правку ника, звания и подписки включим следующим шагом, когда посмотрим на реальные данные.
This commit is contained in:
MpakobecEx 2026-10-19 03:56:08 +05:00
parent f345844dfe
commit 82fa21da3d
11 changed files with 471 additions and 5 deletions

View File

@ -72,6 +72,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
база недоступна — использует расписание из кода (совпадает с сидом `V16`).
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
подписка на рассылки, дата вступления и последние операции по балансу.
## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
@ -133,7 +137,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.5`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.6`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -13,7 +13,7 @@ from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from admin.middleware import AdminAuthMiddleware
from admin.routes import auth, health, messages, pages, schedules
from admin.routes import auth, health, messages, pages, schedules, users
from common import __version__
from common.config import AdminSettings, get_admin_settings
from common.db.pool import close_pool, init_pool
@ -87,4 +87,5 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
app.include_router(pages.router)
app.include_router(messages.router)
app.include_router(schedules.router)
app.include_router(users.router)
return app

View File

@ -24,7 +24,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
{
"title": "Пользователи",
"description": "Ники, звания, баланс и подписка",
"url": None,
"url": "/users",
},
{
"title": "Логи",

57
admin/routes/users.py Normal file
View File

@ -0,0 +1,57 @@
"""Раздел «Пользователи»: список с поиском и карточка игрока."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from common import __version__
from common.repositories import users_admin_repo
router = APIRouter(prefix="/users", tags=["users"])
#: Сколько пользователей показывать на странице.
PAGE_SIZE = 25
@router.get("", response_class=HTMLResponse)
async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse:
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
total = await users_admin_repo.count_users(q)
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
page = min(max(page, 1), pages)
return request.app.state.templates.TemplateResponse(
request=request,
name="users/list.html",
context={
"version": __version__,
"users": await users_admin_repo.list_users(
q,
limit=PAGE_SIZE,
offset=(page - 1) * PAGE_SIZE,
),
"q": q,
"page": page,
"pages": pages,
"total": total,
},
)
@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="Пользователь не найден")
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),
},
)

View File

@ -15,6 +15,7 @@
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a>
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
{% if request.session.get('admin_id') %}
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
<form method="post" action="/logout">

View File

@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block title %}{{ user.nick or user.id_user }}{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/users" class="hover:underline">← Пользователи</a></p>
<div class="mt-2 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">{{ user.nick or "Без ника" }}</h1>
<p class="mt-1 text-slate-600">
{% if user.username %}<span class="font-mono">@{{ user.username }}</span> · {% endif %}
<span class="font-mono">{{ user.id_user }}</span>
{% if user.first_name or user.last_name %}
· {{ user.first_name or "" }} {{ user.last_name or "" }}
{% endif %}
</p>
</div>
<div class="flex items-center gap-2">
{% if user.title %}
<span class="rounded bg-indigo-100 px-3 py-1 text-sm text-indigo-800">{{ user.title }}</span>
{% endif %}
{% if user.subscribed %}
<span class="rounded bg-emerald-100 px-3 py-1 text-sm text-emerald-800">подписан на рассылки</span>
{% else %}
<span class="rounded bg-slate-100 px-3 py-1 text-sm text-slate-500">без рассылок</span>
{% endif %}
</div>
</div>
<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>
<div class="mt-1 text-xl font-semibold">{{ user.lvl }}</div>
</div>
<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="mt-1 text-xl font-semibold">{{ user.expr }}</div>
<div class="text-xs text-slate-500">серия: {{ user.expr_streak }}</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Баланс DE</div>
<div class="mt-1 text-xl font-semibold">{{ user.balance }}</div>
</div>
<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="mt-1 text-xl font-semibold">{{ user.eggs }}</div>
</div>
<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="mt-1 text-xl font-semibold">{{ user.msg_count }}</div>
<div class="text-xs text-slate-500">
последнее: {{ user.last_message_at.strftime("%d.%m.%Y %H:%M") if user.last_message_at else "—" }}
</div>
</div>
<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="mt-1 text-xl font-semibold">{{ user.purification_count }}</div>
<div class="text-xs text-slate-500">
последнее: {{ user.last_purification_at.strftime("%d.%m.%Y %H:%M") if user.last_purification_at else "—" }}
</div>
</div>
<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="mt-1 text-xl font-semibold">
{{ user.dt_create.strftime("%d.%m.%Y") if user.dt_create else "—" }}
</div>
</div>
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">Telegram-ID</div>
<div class="mt-1 font-mono text-sm">{{ user.id_user }}</div>
</div>
</div>
<div class="mt-8">
<h2 class="text-lg font-medium">Последние операции по балансу</h2>
{% if money %}
<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>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for item in money %}
<tr>
<td class="px-4 py-3 text-slate-600">
{{ item.dt_action.strftime("%d.%m.%Y %H:%M") if item.dt_action else "—" }}
</td>
<td class="px-4 py-3 font-medium {% if item.amount < 0 %}text-red-600{% else %}text-emerald-700{% endif %}">
{{ "%+d" | format(item.amount) }}
</td>
<td class="px-4 py-3 text-slate-700">{{ item.action or "—" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-2 text-sm text-slate-500">Операций по балансу не было.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Пользователи{% endblock %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold">Пользователи</h1>
<p class="mt-1 text-sm text-slate-600">Всего: {{ total }}</p>
</div>
<form method="get" action="/users" class="flex items-center gap-2">
<input
type="search"
name="q"
value="{{ q }}"
placeholder="Ник, @username, имя или Telegram-ID"
class="w-80 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>
{% if q %}<a href="/users" class="text-sm text-slate-600 hover:underline">Сбросить</a>{% endif %}
</form>
</div>
<div class="mt-6 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">Telegram</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">
{% for user in users %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/users/{{ user.id_user }}" class="font-medium text-sky-700 hover:underline">
{{ user.nick or "—" }}
</a>
<div class="text-xs text-slate-500">{{ user.title or "" }}</div>
</td>
<td class="px-4 py-3">
{% if user.username %}<div>@{{ user.username }}</div>{% endif %}
<div class="font-mono text-xs text-slate-500">{{ user.id_user }}</div>
</td>
<td class="px-4 py-3">{{ user.lvl }}</td>
<td class="px-4 py-3">{{ user.balance }}</td>
<td class="px-4 py-3">{{ user.msg_count }}</td>
<td class="px-4 py-3">
{% if user.subscribed %}
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">да</span>
{% else %}
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">нет</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="px-4 py-6 text-center text-slate-500">
{% if q %}Никого не нашли по запросу «{{ q }}».{% else %}Пользователей пока нет.{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pages > 1 %}
<div class="mt-4 flex items-center justify-between text-sm text-slate-600">
<div>Страница {{ page }} из {{ pages }}</div>
<div class="flex gap-3">
{% if page > 1 %}
<a href="/users?q={{ q | urlencode }}&page={{ page - 1 }}" class="hover:underline">← Назад</a>
{% endif %}
{% if page < pages %}
<a href="/users?q={{ q | urlencode }}&page={{ page + 1 }}" class="hover:underline">Вперёд →</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,113 @@
"""Чтение данных пользователей для админки (Блок 4.3).
Баланс — контракт ``SUM(bot.money.amount)``; яички — ``SUM(bot.eggs.amount)``.
Подписка на рассылки — отсутствие строки в ``bot.unsubscribe``.
"""
from __future__ import annotations
from typing import Any
from common.db.pool import get_pool
#: Общая часть выборки: агрегаты по сообщениям, деньгам, яичкам и очищениям.
_FROM = """
FROM bot.tg_users AS tg
LEFT JOIN bot.de_users AS du ON du.id_user = tg.id_user
LEFT JOIN bot.titles AS t ON t.id_title = du.id_title
LEFT JOIN (
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
FROM bot.messages GROUP BY id_user
) AS msg ON msg.id_user = tg.id_user
LEFT JOIN (
SELECT id_user, sum(amount) AS balance
FROM bot.money GROUP BY id_user
) AS money ON money.id_user = tg.id_user
LEFT JOIN (
SELECT id_user, sum(amount) AS eggs
FROM bot.eggs GROUP BY id_user
) AS eggs ON eggs.id_user = tg.id_user
LEFT JOIN (
SELECT id_user, count(*) AS cnt, max(dt) AS last_dt
FROM bot.purifications GROUP BY id_user
) AS pur ON pur.id_user = tg.id_user
LEFT JOIN bot.unsubscribe AS unsub ON unsub.id_user = tg.id_user
"""
_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,
t.title,
coalesce(msg.cnt, 0) AS msg_count,
msg.last_dt AS last_message_at,
coalesce(money.balance, 0) AS balance,
coalesce(eggs.eggs, 0) AS eggs,
coalesce(pur.cnt, 0) AS purification_count,
pur.last_dt AS last_purification_at,
(unsub.id_user IS NULL) AS subscribed
"""
#: Колонки, по которым идёт поиск (в порядке подстановки параметров).
_SEARCH_COLUMNS = ("tg.username", "tg.first_name", "tg.last_name", "du.nick")
def _search_clause(term: str) -> tuple[str, list[Any]]:
"""WHERE по нику, username, имени и Telegram-ID."""
term = term.strip()
if not term:
return "", []
like = f"%{term}%"
clauses = [f"{column} ILIKE %s" for column in _SEARCH_COLUMNS]
params: list[Any] = [like] * len(_SEARCH_COLUMNS)
telegram_id = term.lstrip("@")
if telegram_id.isdigit():
clauses.append("tg.id_user = %s")
params.append(int(telegram_id))
return " WHERE (" + " OR ".join(clauses) + ")", params
async def count_users(search: str = "") -> int:
"""Сколько пользователей подходит под поиск."""
where, params = _search_clause(search)
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT count(*) AS total " + _FROM + where, tuple(params))
row = await cur.fetchone()
return int(row["total"]) if row else 0
async def list_users(search: str = "", *, limit: int = 25, offset: int = 0) -> list[dict[str, Any]]:
"""Страница списка пользователей (с поиском)."""
where, params = _search_clause(search)
async with get_pool().connection() as conn:
cur = await conn.execute(
_SELECT + _FROM + where + " ORDER BY du.nick NULLS LAST, tg.id_user LIMIT %s OFFSET %s",
(*params, limit, offset),
)
return await cur.fetchall()
async def get_user(user_id: int) -> dict[str, Any] | None:
"""Карточка пользователя со сводными показателями."""
async with get_pool().connection() as conn:
cur = await conn.execute(_SELECT + _FROM + " WHERE tg.id_user = %s", (user_id,))
return await cur.fetchone()
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
"""Последние операции по балансу с названием действия."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT m.id_money, m.amount, m.dt_action, a.title AS action
FROM bot.money AS m
LEFT JOIN bot.actions AS a ON a.id_action = m.id_action
WHERE m.id_user = %s
ORDER BY m.dt_action DESC, m.id_money DESC
LIMIT %s
""",
(user_id, limit),
)
return await cur.fetchall()

View File

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

98
tests/test_admin_users.py Normal file
View File

@ -0,0 +1,98 @@
"""Тесты раздела «Пользователи» (без БД — репозиторий подменяется)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from common.repositories import users_admin_repo
USER: dict[str, Any] = {
"id_user": 42,
"username": "test_user",
"first_name": "Test",
"last_name": None,
"nick": "ТестовыйНик",
"lvl": 3,
"expr": 120,
"expr_streak": 2,
"dt_create": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
"title": "Ветеран",
"msg_count": 42,
"last_message_at": datetime(2026, 10, 18, 20, 0, tzinfo=UTC),
"balance": 1500,
"eggs": 2,
"purification_count": 5,
"last_purification_at": datetime(2026, 10, 17, 16, 0, tzinfo=UTC),
"subscribed": True,
}
MONEY: list[dict[str, Any]] = [
{"id_money": 2, "amount": 50, "dt_action": datetime(2026, 10, 18, 16, 0, tzinfo=UTC), "action": "Очищение"},
{"id_money": 1, "amount": -20, "dt_action": datetime(2026, 10, 17, 12, 0, tzinfo=UTC), "action": "Покупка"},
]
@pytest.fixture
def fake_repo(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"total": 1, "calls": []}
async def count_users(search: str = "") -> int:
state["search"] = search
return state["total"]
async def list_users(search: str = "", *, limit: int = 25, offset: int = 0) -> list[dict[str, Any]]:
state["calls"].append({"search": search, "limit": limit, "offset": offset})
return [USER]
async def get_user(user_id: int) -> dict[str, Any] | None:
return USER if user_id == USER["id_user"] else None
async def money_history(user_id: int, *, limit: int = 10) -> list[dict[str, Any]]:
return MONEY if user_id == USER["id_user"] else []
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)
return state
def test_users_list(fake_repo: dict[str, Any], client_factory) -> None:
response = client_factory().get("/users")
assert response.status_code == 200
assert "ТестовыйНик" in response.text
assert "@test_user" in response.text
assert "1500" in response.text
def test_users_search_passed_to_repo(fake_repo: dict[str, Any], client_factory) -> None:
client_factory().get("/users", params={"q": "Тест"})
assert fake_repo["calls"][0]["search"] == "Тест"
def test_users_pagination(fake_repo: dict[str, Any], client_factory) -> None:
fake_repo["total"] = 30
response = client_factory().get("/users", params={"page": "2"})
assert "Страница 2 из 2" in response.text
assert fake_repo["calls"][0]["offset"] == 25
def test_user_card(fake_repo: dict[str, Any], client_factory) -> None:
response = client_factory().get("/users/42")
assert response.status_code == 200
assert "Ветеран" in response.text
assert "Очищение" in response.text
assert "+50" in response.text
assert "-20" 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

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set():
assert __version__
assert __version__ == "0.4.5"
assert __version__ == "0.4.6"