В админке появился список игроков с поиском и карточкой
Искать можно по нику, username, имени и Telegram-ID; в карточке видно уровень, опыт с серией, баланс (SUM(money.amount)), яички, сообщения, очищения, подписку и последние операции по балансу. Страница пока только читает PostgreSQL: правку ника, звания и подписки включим следующим шагом, когда посмотрим на реальные данные.
This commit is contained in:
parent
f345844dfe
commit
82fa21da3d
|
|
@ -72,6 +72,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
|
||||||
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
|
таймзона, шаблон и вкл/выкл. Бот раз в минуту читает активные расписания из БД; если
|
||||||
база недоступна — использует расписание из кода (совпадает с сидом `V16`).
|
база недоступна — использует расписание из кода (совпадает с сидом `V16`).
|
||||||
|
|
||||||
|
Раздел «Пользователи» — список с поиском по нику/username/имени/Telegram-ID и карточка:
|
||||||
|
уровень, опыт и серия, баланс (`SUM(money.amount)`), яички, сообщения, очищения,
|
||||||
|
подписка на рассылки, дата вступления и последние операции по балансу.
|
||||||
|
|
||||||
## Аналитика (ClickHouse)
|
## Аналитика (ClickHouse)
|
||||||
|
|
||||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
Все значимые действия бота пишутся в 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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from admin.middleware import AdminAuthMiddleware
|
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 import __version__
|
||||||
from common.config import AdminSettings, get_admin_settings
|
from common.config import AdminSettings, get_admin_settings
|
||||||
from common.db.pool import close_pool, init_pool
|
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(pages.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
app.include_router(schedules.router)
|
app.include_router(schedules.router)
|
||||||
|
app.include_router(users.router)
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
{
|
{
|
||||||
"title": "Пользователи",
|
"title": "Пользователи",
|
||||||
"description": "Ники, звания, баланс и подписка",
|
"description": "Ники, звания, баланс и подписка",
|
||||||
"url": None,
|
"url": "/users",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "Логи",
|
"title": "Логи",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a>
|
<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="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
|
||||||
<a href="/schedules" 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') %}
|
{% if request.session.get('admin_id') %}
|
||||||
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.4.5"
|
__version__ = "0.4.6"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.4.5"
|
assert __version__ == "0.4.6"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue