diff --git a/README.md b/README.md index 609d7de..ebeb4af 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/admin/app.py b/admin/app.py index 8e94d5c..efe5cf3 100644 --- a/admin/app.py +++ b/admin/app.py @@ -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 diff --git a/admin/routes/pages.py b/admin/routes/pages.py index 62c9507..162ca48 100644 --- a/admin/routes/pages.py +++ b/admin/routes/pages.py @@ -24,7 +24,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( { "title": "Пользователи", "description": "Ники, звания, баланс и подписка", - "url": None, + "url": "/users", }, { "title": "Логи", diff --git a/admin/routes/users.py b/admin/routes/users.py new file mode 100644 index 0000000..31615e1 --- /dev/null +++ b/admin/routes/users.py @@ -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), + }, + ) diff --git a/admin/templates/base.html b/admin/templates/base.html index d555c63..7430314 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -15,6 +15,7 @@ Главная Сообщения Расписания + Пользователи {% if request.session.get('admin_id') %} {{ request.session.get('admin_username') or 'админ' }}
diff --git a/admin/templates/users/card.html b/admin/templates/users/card.html new file mode 100644 index 0000000..7b78798 --- /dev/null +++ b/admin/templates/users/card.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} + +{% block title %}{{ user.nick or user.id_user }}{% endblock %} + +{% block content %} +

← Пользователи

+ +
+
+

{{ user.nick or "Без ника" }}

+

+ {% if user.username %}@{{ user.username }} · {% endif %} + {{ user.id_user }} + {% if user.first_name or user.last_name %} + · {{ user.first_name or "" }} {{ user.last_name or "" }} + {% endif %} +

+
+
+ {% if user.title %} + {{ user.title }} + {% endif %} + {% if user.subscribed %} + подписан на рассылки + {% else %} + без рассылок + {% endif %} +
+
+ +
+
+
Уровень
+
{{ user.lvl }}
+
+
+
Опыт
+
{{ user.expr }}
+
серия: {{ user.expr_streak }}
+
+
+
Баланс DE
+
{{ user.balance }}
+
+
+
Яички
+
{{ user.eggs }}
+
+
+
Сообщения
+
{{ user.msg_count }}
+
+ последнее: {{ user.last_message_at.strftime("%d.%m.%Y %H:%M") if user.last_message_at else "—" }} +
+
+
+
Очищения
+
{{ user.purification_count }}
+
+ последнее: {{ user.last_purification_at.strftime("%d.%m.%Y %H:%M") if user.last_purification_at else "—" }} +
+
+
+
В клане с
+
+ {{ user.dt_create.strftime("%d.%m.%Y") if user.dt_create else "—" }} +
+
+
+
Telegram-ID
+
{{ user.id_user }}
+
+
+ +
+

Последние операции по балансу

+ {% if money %} +
+ + + + + + + + + + {% for item in money %} + + + + + + {% endfor %} + +
КогдаСуммаДействие
+ {{ item.dt_action.strftime("%d.%m.%Y %H:%M") if item.dt_action else "—" }} + + {{ "%+d" | format(item.amount) }} + {{ item.action or "—" }}
+
+ {% else %} +

Операций по балансу не было.

+ {% endif %} +
+{% endblock %} diff --git a/admin/templates/users/list.html b/admin/templates/users/list.html new file mode 100644 index 0000000..330005a --- /dev/null +++ b/admin/templates/users/list.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} + +{% block title %}Пользователи{% endblock %} + +{% block content %} +
+
+

Пользователи

+

Всего: {{ total }}

+
+ + + + {% if q %}Сбросить{% endif %} + +
+ +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
НикTelegramУровеньБалансСообщенияПодписка
+ + {{ user.nick or "—" }} + +
{{ user.title or "" }}
+
+ {% if user.username %}
@{{ user.username }}
{% endif %} +
{{ user.id_user }}
+
{{ user.lvl }}{{ user.balance }}{{ user.msg_count }} + {% if user.subscribed %} + да + {% else %} + нет + {% endif %} +
+ {% if q %}Никого не нашли по запросу «{{ q }}».{% else %}Пользователей пока нет.{% endif %} +
+
+ +{% if pages > 1 %} +
+
Страница {{ page }} из {{ pages }}
+
+ {% if page > 1 %} + ← Назад + {% endif %} + {% if page < pages %} + Вперёд → + {% endif %} +
+
+{% endif %} +{% endblock %} diff --git a/common/repositories/users_admin_repo.py b/common/repositories/users_admin_repo.py new file mode 100644 index 0000000..b6d0d12 --- /dev/null +++ b/common/repositories/users_admin_repo.py @@ -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() diff --git a/common/version.py b/common/version.py index 5e9c5e2..2fc6bee 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.4.5" +__version__ = "0.4.6" diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py new file mode 100644 index 0000000..4d2e512 --- /dev/null +++ b/tests/test_admin_users.py @@ -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 diff --git a/tests/test_version.py b/tests/test_version.py index b7acb5f..aff00da 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -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"