Админка научилась управлять админами, шаблонами и исключать из чата
CI / lint-test (push) Successful in 16s Details
CI / build-push (push) Successful in 56s Details

Появился раздел «Админы»: добавить человека по Telegram-ID, сменить роль, выключить или удалить (с защитой от отключения себя и последнего администратора). В карточке пользователя теперь можно выгнать из чата: бот банит и сразу разбанивает, данные удаляются, кто кого выгнал — уходит в чат логов. Заодно исправлен старый баг: при исключении уведомление и удаление применялись к тому, кто выгонял, а не к выгнанному. Шаблоны можно создавать из «Сообщений», а смена ника через /ник теперь видна в истории — с пометкой, что это сделал сам пользователь, а не админ.
This commit is contained in:
MpakobecEx 2026-10-25 01:05:02 +05:00
parent 3a5d786aa2
commit 8ae4e8bb6c
30 changed files with 1191 additions and 27 deletions

View File

@ -84,6 +84,12 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
если он недоступен, страница показывает ошибку, а не падает.
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
описание, текст и предпросмотр.
## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
@ -145,7 +151,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.13`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.14`.
- Схема — 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, logs, messages, pages, schedules, users
from admin.routes import admins, auth, health, logs, messages, pages, schedules, users
from common import __version__
from common.analytics import read as analytics_read
from common.config import AdminSettings, get_admin_settings
@ -90,5 +90,6 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -
app.include_router(messages.router)
app.include_router(schedules.router)
app.include_router(users.router)
app.include_router(admins.router)
app.include_router(logs.router)
return app

View File

@ -12,6 +12,7 @@ from common import __version__
SAMPLE_CONTEXT: dict[str, Any] = {
"username": "@test_user",
"actor": "@admin_user",
"nick": "ТестовыйНик",
"title": "Ветеран",
"lvl": 3,

145
admin/routes/admins.py Normal file
View File

@ -0,0 +1,145 @@
"""Раздел «Админы»: назначение и управление доступом к веб-панели."""
from __future__ import annotations
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 admins_repo
router = APIRouter(prefix="/admins", tags=["admins"])
#: Допустимые роли (обе сейчас дают полный доступ, moderator — задел).
ROLES = ("admin", "moderator")
async def _list_response(
request: Request,
*,
errors: list[str] | None = None,
form: dict[str, str] | None = None,
saved: str | None = None,
status_code: int = 200,
) -> HTMLResponse:
return request.app.state.templates.TemplateResponse(
request=request,
name="admins/list.html",
context={
"version": __version__,
"admins": await admins_repo.list_admins(),
"roles": ROLES,
"errors": errors or [],
"form": form or {"telegram_id": "", "username": "", "role": "admin"},
"saved": saved,
"current_id": int(request.state.admin["telegram_id"]),
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def list_admins(request: Request, saved: str | None = None) -> HTMLResponse:
"""Список администраторов и форма добавления."""
return await _list_response(request, saved=saved)
@router.post("")
async def add_admin(request: Request) -> Response:
"""Добавить администратора по Telegram-ID."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
raw_id = str(form.get("telegram_id", "")).strip()
username = str(form.get("username", "")).strip().lstrip("@")
role = str(form.get("role", "admin"))
raw_form = {"telegram_id": raw_id, "username": username, "role": role}
errors: list[str] = []
try:
telegram_id = int(raw_id)
except (TypeError, ValueError):
telegram_id = 0
errors.append("Telegram-ID должен быть числом")
if telegram_id <= 0 and "Telegram-ID должен быть числом" not in errors:
errors.append("Telegram-ID должен быть положительным")
if role not in ROLES:
errors.append("Неизвестная роль")
if len(username) > 64:
errors.append("Username длиннее 64 символов")
if errors:
return await _list_response(request, errors=errors, form=raw_form, status_code=400)
await admins_repo.upsert_admin(telegram_id, username or None, role)
return RedirectResponse(url="/admins?saved=added", status_code=303)
@router.post("/{telegram_id}/role")
async def change_role(request: Request, telegram_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
role = str(form.get("role", ""))
if role not in ROLES:
return await _list_response(request, errors=["Неизвестная роль"], status_code=400)
if not await admins_repo.set_admin_role(telegram_id, role):
raise HTTPException(status_code=404, detail="Администратор не найден")
return RedirectResponse(url="/admins?saved=role", status_code=303)
@router.post("/{telegram_id}/toggle")
async def toggle_admin(request: Request, telegram_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
if telegram_id == int(request.state.admin["telegram_id"]):
return await _list_response(
request,
errors=["Нельзя выключить самого себя"],
status_code=400,
)
admins = await admins_repo.list_admins()
target = next((item for item in admins if int(item["telegram_id"]) == telegram_id), None)
if target is None:
raise HTTPException(status_code=404, detail="Администратор не найден")
enabled_count = sum(1 for item in admins if item["enabled"])
if target["enabled"] and enabled_count <= 1:
return await _list_response(
request,
errors=["Нельзя выключить последнего администратора"],
status_code=400,
)
await admins_repo.set_admin_enabled(telegram_id, not target["enabled"])
return RedirectResponse(url="/admins?saved=toggle", status_code=303)
@router.post("/{telegram_id}/delete")
async def delete_admin(request: Request, telegram_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
if telegram_id == int(request.state.admin["telegram_id"]):
return await _list_response(
request,
errors=["Нельзя удалить самого себя"],
status_code=400,
)
admins = await admins_repo.list_admins()
target = next((item for item in admins if int(item["telegram_id"]) == telegram_id), None)
if target is None:
raise HTTPException(status_code=404, detail="Администратор не найден")
if target["enabled"] and sum(1 for item in admins if item["enabled"]) <= 1:
return await _list_response(
request,
errors=["Нельзя удалить последнего администратора"],
status_code=400,
)
await admins_repo.delete_admin(telegram_id)
return RedirectResponse(url="/admins?saved=deleted", status_code=303)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import html
import re
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
@ -15,6 +16,12 @@ from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, TemplateError,
router = APIRouter(prefix="/messages", tags=["messages"])
#: Формат ключа нового шаблона (латиница, цифры, точка, подчёркивание, дефис).
KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,63}$")
#: Максимальная длина описания шаблона.
MAX_DESCRIPTION_LENGTH = 200
def _editor_context(
request: Request,
@ -49,6 +56,95 @@ async def list_messages(request: Request) -> HTMLResponse:
)
@router.get("/new", response_class=HTMLResponse)
async def new_template(request: Request) -> HTMLResponse:
"""Форма создания нового шаблона."""
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/new.html",
context={
"version": __version__,
"key": "",
"description": "",
"text": "",
"enabled": True,
"errors": [],
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
"max_description_length": MAX_DESCRIPTION_LENGTH,
},
)
@router.post("")
async def create_template(request: Request) -> Response:
"""Создать шаблон (ключ проверяется на формат и занятость)."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
key = str(form.get("key", "")).strip()
description = str(form.get("description", "")).strip()
text = str(form.get("text", ""))
enabled = form.get("enabled") is not None
errors: list[str] = []
if not KEY_RE.match(key):
errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–64 символа)")
if not text.strip():
errors.append("Текст не может быть пустым")
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
if len(text) > MAX_MESSAGE_LENGTH:
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
if len(description) > MAX_DESCRIPTION_LENGTH:
errors.append(f"Описание длиннее {MAX_DESCRIPTION_LENGTH} символов")
if errors:
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/new.html",
context={
"version": __version__,
"key": key,
"description": description,
"text": text,
"enabled": enabled,
"errors": errors,
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
"max_description_length": MAX_DESCRIPTION_LENGTH,
},
status_code=400,
)
created = await templates_repo.create_template(
key,
text,
description,
enabled=enabled,
editor_id=int(request.state.admin["telegram_id"]),
)
if not created:
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/new.html",
context={
"version": __version__,
"key": key,
"description": description,
"text": text,
"enabled": enabled,
"errors": ["Шаблон с таким ключом уже существует"],
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
"max_description_length": MAX_DESCRIPTION_LENGTH,
},
status_code=400,
)
return RedirectResponse(url=f"/messages/{key}", status_code=303)
@router.post("/preview", response_class=HTMLResponse)
async def preview_message(request: Request) -> HTMLResponse:
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).

View File

@ -5,10 +5,13 @@ from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin import telegram
from admin.security import check_csrf
from common import __version__
from common.eggs import is_egg_event_active
from common.repositories import users_admin_repo
from common.repositories import users_admin_repo, users_repo
from common.texts import alerts as alerts_text
from common.texts.render import render
router = APIRouter(prefix="/users", tags=["users"])
@ -21,6 +24,19 @@ def _editor_id(request: Request) -> int:
return int(request.state.admin["telegram_id"])
def _tag(username: str | None, user_id: int) -> str:
"""``@username`` или числовой ID для уведомлений."""
return f"@{username}" if username else str(user_id)
def _victim_tag(user: dict[str, object]) -> str:
if user.get("username"):
return f"@{user['username']}"
if user.get("nick"):
return str(user["nick"])
return str(user.get("id_user"))
async def _card_response(
request: Request,
user_id: int,
@ -46,6 +62,7 @@ async def _card_response(
"errors": errors or [],
"saved": saved,
"eggs_visible": is_egg_event_active(request.app.state.settings),
"can_kick": bool(request.app.state.settings.bot_token and request.app.state.settings.chat_id),
"max_nick_length": users_admin_repo.MAX_NICK_LENGTH,
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
},
@ -54,7 +71,7 @@ async def _card_response(
@router.get("", response_class=HTMLResponse)
async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLResponse:
async def list_users(request: Request, q: str = "", page: int = 1, saved: str | None = None) -> HTMLResponse:
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
total = await users_admin_repo.count_users(q)
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
@ -74,6 +91,7 @@ async def list_users(request: Request, q: str = "", page: int = 1) -> HTMLRespon
"page": page,
"pages": pages,
"total": total,
"saved": saved,
},
)
@ -181,3 +199,40 @@ async def update_balance(request: Request, user_id: int) -> Response:
if balance is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303)
@router.post("/{user_id}/kick")
async def kick_user(request: Request, user_id: int) -> Response:
"""Исключить пользователя из основного чата (бан + разбан)."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
user = await users_admin_repo.get_user(user_id)
if user is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
settings = request.app.state.settings
try:
await telegram.kick_member(settings, user_id)
except telegram.TelegramError as exc:
return await _card_response(
request,
user_id,
errors=[f"Не удалось исключить: {exc}"],
status_code=400,
)
# Данные удаляем сразу, не полагаясь на событие chat_member (бот может быть остановлен).
await users_repo.del_user_by_id(user_id)
admin = request.state.admin
text = render(
alerts_text.MSG_KICK,
{
"actor": _tag(admin.get("username"), int(admin["telegram_id"])),
"username": _victim_tag(user),
"chat_title": await telegram.chat_title(settings),
},
)
await telegram.send_alert(settings, text)
return RedirectResponse(url="/users?saved=kick", status_code=303)

83
admin/telegram.py Normal file
View File

@ -0,0 +1,83 @@
"""Минимальный клиент Telegram Bot API для админки.
Нужен для действий модерации (исключение из чата) и уведомлений в чат логов.
Специально без aiogram: админке не нужен весь фреймворк бота.
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from common.config import AdminSettings
logger = logging.getLogger(__name__)
_API = "https://api.telegram.org/bot{token}/{method}"
class TelegramError(RuntimeError):
"""Telegram отклонил запрос или недоступен."""
async def _call(settings: AdminSettings, method: str, payload: dict[str, Any]) -> Any:
if not settings.bot_token:
raise TelegramError("BOT_TOKEN не задан")
url = _API.format(token=settings.bot_token, method=method)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, json=payload)
except httpx.HTTPError as exc:
raise TelegramError(f"Telegram недоступен: {exc}") from exc
try:
data = response.json()
except ValueError as exc:
raise TelegramError("Telegram вернул некорректный ответ") from exc
if not data.get("ok"):
raise TelegramError(str(data.get("description") or "Telegram отклонил запрос"))
return data.get("result")
async def kick_member(settings: AdminSettings, user_id: int) -> None:
"""Исключить пользователя из основного чата (бан + разбан, чтобы мог вернуться)."""
if not settings.chat_id:
raise TelegramError("CHAT_ID не задан")
await _call(
settings,
"banChatMember",
{"chat_id": settings.chat_id, "user_id": user_id},
)
await _call(
settings,
"unbanChatMember",
{"chat_id": settings.chat_id, "user_id": user_id, "only_if_banned": True},
)
async def chat_title(settings: AdminSettings) -> str:
"""Название основного чата (best-effort, для текста уведомления)."""
try:
chat = await _call(settings, "getChat", {"chat_id": settings.chat_id})
except TelegramError:
return "чат"
return str(chat.get("title") or chat.get("username") or "чат")
async def send_alert(settings: AdminSettings, text: str) -> None:
"""Отправить служебное сообщение в чат логов (ошибки только в лог)."""
if not settings.alert_chat_id:
return
try:
await _call(
settings,
"sendMessage",
{"chat_id": settings.alert_chat_id, "text": text},
)
except TelegramError:
logger.warning("Не удалось отправить уведомление в чат логов", exc_info=True)

View File

@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Админы{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold">Админы</h1>
<p class="mt-1 text-sm text-slate-600">Доступ к веб-панели через Telegram.</p>
</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 == 'added' %}Администратор добавлен.
{% elif saved == 'role' %}Роль обновлена.
{% elif saved == 'toggle' %}Доступ обновлён.
{% elif saved == 'deleted' %}Администратор удалён.
{% 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 %}
<form method="post" action="/admins" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<h2 class="text-lg font-medium">Добавить администратора</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-4">
<div>
<label for="telegram_id" class="text-xs text-slate-500">Telegram-ID</label>
<input
id="telegram_id"
name="telegram_id"
value="{{ form.telegram_id }}"
inputmode="numeric"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="username" class="text-xs text-slate-500">Username (необязательно)</label>
<input
id="username"
name="username"
value="{{ form.username }}"
placeholder="без @"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
<div>
<label for="role" class="text-xs text-slate-500">Роль</label>
<select
id="role"
name="role"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
>
{% for role in roles %}
<option value="{{ role }}" {% if role == form.role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end">
<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>
</div>
<p class="mt-2 text-xs text-slate-500">
Обе роли сейчас дают полный доступ; <span class="font-mono">moderator</span> — задел на ограничения.
</p>
</form>
<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">Telegram-ID</th>
<th class="px-4 py-3 font-medium">Username</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 admin in admins %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3 font-mono text-xs">
{{ admin.telegram_id }}
{% if admin.telegram_id == current_id %}<span class="ml-1 text-slate-400">(вы)</span>{% endif %}
</td>
<td class="px-4 py-3">
{% if admin.username %}<span class="font-mono">@{{ admin.username }}</span>{% else %}—{% endif %}
</td>
<td class="px-4 py-3">
<form method="post" action="/admins/{{ admin.telegram_id }}/role" class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<select
name="role"
class="rounded-lg border border-slate-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
>
{% for role in roles %}
<option value="{{ role }}" {% if role == admin.role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
<button type="submit" class="text-xs text-sky-700 hover:underline">Сохранить</button>
</form>
</td>
<td class="px-4 py-3">
{% if admin.enabled %}
<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>
<td class="px-4 py-3 text-slate-600">
{{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
</td>
<td class="px-4 py-3">
<div class="flex items-center justify-end gap-3">
{% if admin.telegram_id != current_id %}
<form method="post" action="/admins/{{ admin.telegram_id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-slate-600 hover:underline">
{% if admin.enabled %}Выключить{% else %}Включить{% endif %}
</button>
</form>
<form method="post" action="/admins/{{ admin.telegram_id }}/delete" onsubmit="return confirm('Удалить администратора?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -16,6 +16,7 @@
<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>
<a href="/admins" class="text-slate-600 hover:text-slate-900">Админы</a>
<a href="/logs" 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>

View File

@ -5,7 +5,12 @@
{% block content %}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Сообщения</h1>
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">{{ templates | length }} шаблонов</span>
<a href="/messages/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Новый шаблон
</a>
</div>
</div>
<p class="mt-2 text-slate-600">

View File

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block title %}Новый шаблон{% endblock %}
{% block content %}
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
<h1 class="mt-1 text-2xl font-semibold">Новый шаблон</h1>
{% 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 %}
<form method="post" action="/messages" class="mt-6 max-w-3xl space-y-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="key" class="text-sm font-medium text-slate-700">Ключ</label>
<input
id="key"
name="key"
value="{{ key }}"
placeholder="например, shop.rules"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
/>
<p class="mt-1 text-xs text-slate-500">Латиница, цифры, точка, подчёркивание, дефис. По ключу шаблон выбирается в расписаниях.</p>
</div>
<div>
<label for="description" class="text-sm font-medium text-slate-700">Описание</label>
<input
id="description"
name="description"
value="{{ description }}"
maxlength="{{ max_description_length }}"
placeholder="для себя и админов"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
/>
</div>
</div>
<div>
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
<textarea
id="text"
name="text"
rows="16"
class="mt-1 w-full rounded-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none"
>{{ text }}</textarea>
<p class="mt-1 text-xs text-slate-500">
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
</p>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled" {% if enabled %}checked{% endif %} />
Включён (бот читает текст)
</label>
<div class="flex items-center gap-3">
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Создать
</button>
<button
type="button"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
hx-post="/messages/preview"
hx-include="closest form"
hx-target="#preview"
hx-swap="innerHTML"
>
Предпросмотр
</button>
<a href="/messages" class="text-sm text-slate-600 hover:underline">Отмена</a>
</div>
</form>
<div class="mt-8 max-w-3xl">
<h2 class="text-lg font-medium">Предпросмотр</h2>
<div id="preview" class="mt-2">
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.</p>
</div>
</div>
<details class="mt-8 max-w-3xl rounded-lg border border-slate-200 bg-white p-4">
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
{% for name, description in tags.items() %}
<div class="flex gap-2">
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
<dd class="text-slate-600">{{ description }}</dd>
</div>
{% endfor %}
</dl>
</details>
{% endblock %}

View File

@ -171,6 +171,27 @@
</div>
</div>
{% if can_kick %}
<div class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4">
<h2 class="text-lg font-medium text-red-800">Исключить из чата</h2>
<p class="mt-1 text-sm text-red-700">
Бот заблокирует пользователя и сразу разблокирует (чтобы мог вернуться),
а данные из бота будут удалены.
</p>
<form
method="post"
action="/users/{{ user.id_user }}/kick"
class="mt-3"
onsubmit="return confirm('Исключить {{ user.nick or user.id_user }} из чата?');"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
Выгнать из чата
</button>
</form>
</div>
{% endif %}
<div class="mt-8">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
@ -182,6 +203,7 @@
<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>
@ -193,6 +215,9 @@
<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 text-slate-600">
{% if item.source == 'user' %}Пользователь{% else %}Админ{% endif %}
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
</tr>
{% endfor %}

View File

@ -23,6 +23,12 @@
</form>
</div>
{% if saved == 'kick' %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
Пользователь исключён из чата, данные удалены.
</div>
{% endif %}
<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">

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import logging
from aiogram import Router
from aiogram.enums import ChatMemberStatus
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter
from aiogram.types import ChatMemberUpdated, FSInputFile
from aiogram.utils.keyboard import InlineKeyboardBuilder
@ -67,24 +68,47 @@ async def welcome_handler(event: ChatMemberUpdated) -> None:
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
async def goodbye_handler(event: ChatMemberUpdated) -> None:
"""Пользователь вышел из чата — удалить запись."""
user = event.from_user
if user is None or user.is_bot:
"""Пользователь вышел или был исключён — удалить данные именно его."""
leaver = event.old_chat_member.user
if leaver.is_bot:
return
actor = event.from_user
kicked = event.new_chat_member.status == ChatMemberStatus.KICKED
by_self = actor is None or actor.id == leaver.id
record(
EventType.LEAVE,
user_id=user.id,
username=user.username,
user_id=leaver.id,
username=leaver.username,
chat_id=event.chat.id,
payload={"chat_title": event.chat.title or ""},
payload={
"chat_title": event.chat.title or "",
"kicked": kicked,
"by_id": actor.id if actor and not by_self else 0,
"by_username": actor.username if actor and not by_self else "",
},
)
await users_service.delete_user(user.id)
logger.info("Удалён пользователь %s после выхода из чата", user.id)
await users_service.delete_user(leaver.id)
logger.info("Удалён пользователь %s после выхода из чата", leaver.id)
await alerts.notify_leave(
# Исключение самим ботом (кнопка в админке): уведомление уже отправила админка.
if kicked and actor is not None and actor.id == event.bot.id:
return
if kicked and not by_self:
await alerts.notify_kick(
event.bot,
username=user.username,
user_id=user.id,
actor_username=actor.username if actor else None,
actor_id=actor.id if actor else 0,
username=leaver.username,
user_id=leaver.id,
chat_title=event.chat.title or "",
)
else:
await alerts.notify_leave(
event.bot,
username=leaver.username,
user_id=leaver.id,
chat_title=event.chat.title or "",
)

View File

@ -64,6 +64,24 @@ async def notify_leave(bot: Bot, *, username: str | None, user_id: int, chat_tit
return await notify_alert(bot, render(alerts_text.MSG_LEAVE, context))
async def notify_kick(
bot: Bot,
*,
actor_username: str | None,
actor_id: int,
username: str | None,
user_id: int,
chat_title: str,
) -> bool:
"""Уведомление об исключении участника: кто и кого выгнал."""
context = {
"actor": user_tag(actor_username, actor_id),
"username": user_tag(username, user_id),
"chat_title": chat_title,
}
return await notify_alert(bot, render(alerts_text.MSG_KICK, context))
async def notify_purification(bot: Bot, *, username: str | None, user_id: int, result: PurificationResult) -> bool:
"""Уведомление об успешном очищении."""
context = {

View File

@ -43,7 +43,7 @@ async def register_from_event(event: ChatMemberUpdated) -> None:
async def set_nick(id_user: int, nick: str) -> None:
await users_repo.add_nick(id_user, nick)
await users_repo.set_nick_with_history(id_user, nick, editor_id=id_user, source="user")
record(EventType.NICK_SET, user_id=id_user, payload={"nick": nick})

View File

@ -51,3 +51,56 @@ async def touch_login(telegram_id: int) -> None:
"UPDATE bot.admin_users SET last_login_at = now() WHERE telegram_id = %s",
(telegram_id,),
)
async def list_admins() -> list[dict[str, Any]]:
"""Все администраторы веб-панели (для раздела «Админы»)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT telegram_id, username, role, enabled, created_at, last_login_at
FROM bot.admin_users
ORDER BY role, username NULLS LAST, telegram_id
"""
)
return await cur.fetchall()
async def upsert_admin(telegram_id: int, username: str | None, role: str) -> None:
"""Добавить администратора или обновить имя/роль существующего."""
async with get_pool().connection() as conn:
await conn.execute(
"""
INSERT INTO bot.admin_users (telegram_id, username, role, enabled)
VALUES (%s, %s, %s, true)
ON CONFLICT (telegram_id) DO UPDATE
SET username = EXCLUDED.username,
role = EXCLUDED.role,
enabled = true
""",
(telegram_id, username, role),
)
async def set_admin_role(telegram_id: int, role: str) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute(
"UPDATE bot.admin_users SET role = %s WHERE telegram_id = %s",
(role, telegram_id),
)
return bool(cur.rowcount)
async def set_admin_enabled(telegram_id: int, enabled: bool) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute(
"UPDATE bot.admin_users SET enabled = %s WHERE telegram_id = %s",
(enabled, telegram_id),
)
return bool(cur.rowcount)
async def delete_admin(telegram_id: int) -> bool:
async with get_pool().connection() as conn:
cur = await conn.execute("DELETE FROM bot.admin_users WHERE telegram_id = %s", (telegram_id,))
return bool(cur.rowcount)

View File

@ -47,6 +47,37 @@ async def get_template(key: str) -> dict[str, Any] | None:
return await cur.fetchone()
async def create_template(
key: str,
text: str,
description: str,
*,
enabled: bool = True,
editor_id: int | None = None,
) -> bool:
"""Создать новый шаблон. False — если ключ уже занят."""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT 1 FROM bot.message_templates WHERE key = %s", (key,))
if await cur.fetchone() is not None:
return False
await conn.execute(
"""
INSERT INTO bot.message_templates (key, text, description, enabled, updated_by)
VALUES (%s, %s, %s, %s, %s)
""",
(key, text, description, enabled, editor_id),
)
await conn.execute(
"""
INSERT INTO bot.message_template_history (key, old_text, new_text, editor_id)
VALUES (%s, NULL, %s, %s)
""",
(key, text, editor_id),
)
return True
async def update_template(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any] | None:
"""Обновить текст шаблона и записать историю.

View File

@ -138,7 +138,7 @@ 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
SELECT id, field, old_value, new_value, comment, editor_id, source, changed_at
FROM bot.user_changes
WHERE id_user = %s
ORDER BY changed_at DESC
@ -157,13 +157,14 @@ async def _log_change(
new_value: str | None,
editor_id: int | None,
comment: str | None = None,
source: str = "admin",
) -> 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)
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id, source)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""",
(user_id, field, old_value, new_value, comment, editor_id),
(user_id, field, old_value, new_value, comment, editor_id, source),
)

View File

@ -22,6 +22,41 @@ async def add_nick(id_user: int, nick: str) -> None:
await cur.execute("CALL bot.add_nick(%s, %s)", (id_user, nick))
async def set_nick_with_history(
id_user: int,
nick: str,
*,
editor_id: int | None = None,
source: str = "user",
) -> str | None:
"""Сменить ник и записать изменение в ``user_changes``.
Возвращает старый ник (``None``, если записи не было). Используется командой
``/ник``, чтобы смена ника пользователем была видна в админке.
"""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT nick FROM bot.de_users WHERE id_user = %s FOR UPDATE", (id_user,))
row = await cur.fetchone()
if row is None:
await conn.execute("INSERT INTO bot.de_users (id_user, nick) VALUES (%s, %s)", (id_user, nick))
old_nick = None
else:
old_nick = row["nick"]
if old_nick == nick:
return old_nick
await conn.execute("UPDATE bot.de_users SET nick = %s WHERE id_user = %s", (nick, id_user))
await conn.execute(
"""
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, editor_id, source)
VALUES (%s, 'nick', %s, %s, %s, %s)
""",
(id_user, old_nick, nick, editor_id, source),
)
return old_nick
async def get_nick_list() -> list[dict[str, Any]]:
"""Список всех игроков (username, nick), отсортированный по нику."""
async with get_pool().connection() as conn, conn.cursor() as cur:

View File

@ -8,6 +8,7 @@ from __future__ import annotations
MSG_JOIN = "[вход] {username} вошёл в чат «{chat_title}»"
MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»"
MSG_KICK = "[кик] {actor} выгнал {username} из чата «{chat_title}»"
MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}"
MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}"
MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат"

View File

@ -23,6 +23,7 @@ RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_cou
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
TAG_REGISTRY: dict[str, str] = {
"username": "Имя пользователя (@username или имя)",
"actor": "Кто выполнил действие (для уведомлений)",
"nick": "Игровой ник",
"title": "Звание",
"lvl": "Уровень",

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.4.13"
__version__ = "0.4.14"

View File

@ -78,7 +78,7 @@ DDL — `clickhouse/init/001_events.sql` (применяется при перв
| `purchase_title` | Покупка звания | `title_id`, `price`, `success` |
| `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` |
| `join` | Вход участника в основной чат | `chat_title` |
| `leave` | Выход участника из основного чата | `chat_title` |
| `leave` | Выход/исключение участника | `chat_title`, `kicked`, `by_id`, `by_username` |
| `nick_set` | Смена ника (`/ник`) | `nick` |
| `subscribe` | Подписка на рассылки (`/подписка`) | — |
| `unsubscribe` | Отписка от рассылок (`/отписка`) | — |

View File

@ -20,5 +20,8 @@ psycopg[binary,pool]>=3.2,<4.0
# ClickHouse — чтение логов в админке (раздел «Логи»).
clickhouse-connect[async]>=1.0,<2.0
# Telegram Bot API для модерации (исключение из чата) и уведомлений.
httpx>=0.27,<1.0
# Разбор form-data (HTMX-формы).
python-multipart>=0.0.9,<1.0

View File

@ -0,0 +1,5 @@
-- Источник изменения пользователя: действие самого пользователя (бот) или админа.
ALTER TABLE bot.user_changes
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'admin';
COMMENT ON COLUMN bot.user_changes.source IS 'Кто изменил: user (сам через бота) или admin (веб-панель)';

View File

@ -142,3 +142,108 @@ def test_save_updates_template(client_factory, monkeypatch: pytest.MonkeyPatch,
assert response.status_code == 200
assert "Текст сохранён" in response.text
assert saved == {"key": "info.help", "text": "Новая справка", "editor_id": 1}
def test_new_template_form(client_factory) -> None:
response = client_factory().get("/messages/new")
assert response.status_code == 200
assert "Новый шаблон" in response.text
assert "shop.rules" in response.text
def test_create_template(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
created: dict[str, Any] = {}
async def fake_create(
key: str,
text: str,
description: str,
*,
enabled: bool = True,
editor_id: int | None = None,
) -> bool:
created.update(key=key, text=text, description=description, enabled=enabled, editor_id=editor_id)
return True
async def fake_get(key: str) -> dict[str, Any] | None:
return {
"key": key,
"text": "Правила магазина",
"description": "Правила",
"enabled": True,
"updated_at": datetime(2026, 1, 1, tzinfo=UTC),
"updated_by": 1,
}
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
return []
monkeypatch.setattr(templates_repo, "create_template", fake_create)
monkeypatch.setattr(templates_repo, "get_template", fake_get)
monkeypatch.setattr(templates_repo, "template_history", fake_history)
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/new").text)
response = client.post(
"/messages",
data={
"key": "shop.rules",
"description": "Правила",
"text": "Правила магазина",
"enabled": "on",
"csrf_token": token,
},
)
assert response.status_code == 200
assert created == {
"key": "shop.rules",
"text": "Правила магазина",
"description": "Правила",
"enabled": True,
"editor_id": 1,
}
def test_create_template_duplicate(client_factory, monkeypatch: pytest.MonkeyPatch, csrf_token) -> None:
async def fake_create(*args: Any, **kwargs: Any) -> bool:
return False
monkeypatch.setattr(templates_repo, "create_template", fake_create)
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/new").text)
response = client.post(
"/messages",
data={"key": "shop.rules", "text": "Текст", "csrf_token": token},
)
assert response.status_code == 400
assert "уже существует" in response.text
def test_create_template_rejects_bad_key(client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/new").text)
response = client.post(
"/messages",
data={"key": "Плохой ключ", "text": "Текст", "csrf_token": token},
)
assert response.status_code == 400
assert "Ключ:" in response.text
def test_create_template_rejects_unknown_tag(client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = csrf_token(client.get("/messages/new").text)
response = client.post(
"/messages",
data={"key": "shop.rules", "text": "Текст {nope}", "csrf_token": token},
)
assert response.status_code == 400
assert "Неизвестные теги" in response.text

161
tests/test_admin_admins.py Normal file
View File

@ -0,0 +1,161 @@
"""Тесты раздела «Админы»: добавление, роли, включение и удаление."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from common.repositories import admins_repo
ADMIN_1: dict[str, Any] = {
"telegram_id": 1,
"username": "admin",
"role": "admin",
"enabled": True,
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
"last_login_at": datetime(2026, 10, 25, 12, 0, tzinfo=UTC),
}
ADMIN_2: dict[str, Any] = {
"telegram_id": 2,
"username": "second",
"role": "moderator",
"enabled": True,
"created_at": datetime(2026, 1, 1, tzinfo=UTC),
"last_login_at": None,
}
@pytest.fixture
def fake_admins(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state: dict[str, Any] = {"admins": [ADMIN_1, ADMIN_2]}
async def list_admins() -> list[dict[str, Any]]:
return state["admins"]
async def upsert_admin(telegram_id: int, username: str | None, role: str) -> None:
state["upsert"] = (telegram_id, username, role)
async def set_admin_role(telegram_id: int, role: str) -> bool:
state["role"] = (telegram_id, role)
return True
async def set_admin_enabled(telegram_id: int, enabled: bool) -> bool:
state["enabled"] = (telegram_id, enabled)
return True
async def delete_admin(telegram_id: int) -> bool:
state["deleted"] = telegram_id
return True
monkeypatch.setattr(admins_repo, "list_admins", list_admins)
monkeypatch.setattr(admins_repo, "upsert_admin", upsert_admin)
monkeypatch.setattr(admins_repo, "set_admin_role", set_admin_role)
monkeypatch.setattr(admins_repo, "set_admin_enabled", set_admin_enabled)
monkeypatch.setattr(admins_repo, "delete_admin", delete_admin)
return state
def _csrf(client: TestClient, csrf_token) -> str:
return csrf_token(client.get("/admins").text)
def test_admins_list(fake_admins: dict[str, Any], client_factory) -> None:
response = client_factory().get("/admins")
assert response.status_code == 200
assert "second" in response.text
assert "(вы)" in response.text
def test_add_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post(
"/admins",
data={"telegram_id": "12345", "username": "@third", "role": "moderator", "csrf_token": token},
)
assert response.status_code == 200
assert "Администратор добавлен" in response.text
assert fake_admins["upsert"] == (12345, "third", "moderator")
def test_add_admin_rejects_bad_id(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins", data={"telegram_id": "abc", "role": "admin", "csrf_token": token})
assert response.status_code == 400
assert "Telegram-ID должен быть числом" in response.text
assert "upsert" not in fake_admins
def test_change_role(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/role", data={"role": "admin", "csrf_token": token})
assert response.status_code == 200
assert "Роль обновлена" in response.text
assert fake_admins["role"] == (2, "admin")
def test_cannot_toggle_self(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/1/toggle", data={"csrf_token": token})
assert response.status_code == 400
assert "самого себя" in response.text
assert "enabled" not in fake_admins
def test_toggle_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/toggle", data={"csrf_token": token})
assert response.status_code == 200
assert "Доступ обновлён" in response.text
assert fake_admins["enabled"] == (2, False)
def test_cannot_delete_self(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/1/delete", data={"csrf_token": token})
assert response.status_code == 400
assert "удалить самого себя" in response.text
def test_cannot_delete_last_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
fake_admins["admins"] = [ADMIN_2]
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/delete", data={"csrf_token": token})
assert response.status_code == 400
assert "последнего администратора" in response.text
assert "deleted" not in fake_admins
def test_delete_admin(fake_admins: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/admins/2/delete", data={"csrf_token": token})
assert response.status_code == 200
assert "Администратор удалён" in response.text
assert fake_admins["deleted"] == 2

View File

@ -50,6 +50,7 @@ HISTORY: list[dict[str, Any]] = [
"new_value": "ТестовыйНик",
"comment": None,
"editor_id": 1,
"source": "admin",
"changed_at": datetime(2026, 10, 18, 12, 0, tzinfo=UTC),
}
]
@ -264,3 +265,64 @@ def test_update_balance_rejects_zero(fake_repo: dict[str, Any], client_factory,
assert response.status_code == 400
assert "Сумма не может быть нулевой" in response.text
def test_kick_user(fake_repo: dict[str, Any], client_factory, csrf_token, monkeypatch: pytest.MonkeyPatch) -> None:
from admin import telegram
from common.repositories import users_repo
calls: dict[str, Any] = {}
async def kick_member(settings: Any, user_id: int) -> None:
calls["kick"] = user_id
async def chat_title(settings: Any) -> str:
return "DEBotTest-чат"
async def send_alert(settings: Any, text: str) -> None:
calls["alert"] = text
async def del_user_by_id(user_id: int) -> None:
calls["deleted"] = user_id
monkeypatch.setattr(telegram, "kick_member", kick_member)
monkeypatch.setattr(telegram, "chat_title", chat_title)
monkeypatch.setattr(telegram, "send_alert", send_alert)
monkeypatch.setattr(users_repo, "del_user_by_id", del_user_by_id)
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/users/42/kick", data={"csrf_token": token})
assert response.status_code == 200
assert "исключён" in response.text
assert calls["kick"] == 42
assert calls["deleted"] == 42
assert "выгнал" in calls["alert"]
assert "@test_user" in calls["alert"]
def test_kick_user_telegram_error(
fake_repo: dict[str, Any],
client_factory,
csrf_token,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from admin import telegram
from common.repositories import users_repo
async def kick_member(settings: Any, user_id: int) -> None:
raise telegram.TelegramError("not enough rights")
async def del_user_by_id(user_id: int) -> None:
raise AssertionError("не должен удалять при ошибке")
monkeypatch.setattr(telegram, "kick_member", kick_member)
monkeypatch.setattr(users_repo, "del_user_by_id", del_user_by_id)
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post("/users/42/kick", data={"csrf_token": token})
assert response.status_code == 400
assert "not enough rights" in response.text

View File

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