diff --git a/README.md b/README.md
index 75c7b11..b06c0f9 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/admin/app.py b/admin/app.py
index d8c514e..f96db1e 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, 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
diff --git a/admin/preview.py b/admin/preview.py
index 23a336e..3f4769f 100644
--- a/admin/preview.py
+++ b/admin/preview.py
@@ -12,6 +12,7 @@ from common import __version__
SAMPLE_CONTEXT: dict[str, Any] = {
"username": "@test_user",
+ "actor": "@admin_user",
"nick": "ТестовыйНик",
"title": "Ветеран",
"lvl": 3,
diff --git a/admin/routes/admins.py b/admin/routes/admins.py
new file mode 100644
index 0000000..771517a
--- /dev/null
+++ b/admin/routes/admins.py
@@ -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)
diff --git a/admin/routes/messages.py b/admin/routes/messages.py
index 2793cd9..d9a94e0 100644
--- a/admin/routes/messages.py
+++ b/admin/routes/messages.py
@@ -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-фрагмент).
diff --git a/admin/routes/users.py b/admin/routes/users.py
index 2a2e10a..b9ed6a2 100644
--- a/admin/routes/users.py
+++ b/admin/routes/users.py
@@ -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)
diff --git a/admin/telegram.py b/admin/telegram.py
new file mode 100644
index 0000000..b3a711f
--- /dev/null
+++ b/admin/telegram.py
@@ -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)
diff --git a/admin/templates/admins/list.html b/admin/templates/admins/list.html
new file mode 100644
index 0000000..02ca81c
--- /dev/null
+++ b/admin/templates/admins/list.html
@@ -0,0 +1,143 @@
+{% extends "base.html" %}
+
+{% block title %}Админы{% endblock %}
+
+{% block content %}
+
+
+
Админы
+
Доступ к веб-панели через Telegram.
+
+
+
+{% if saved %}
+
+ {% if saved == 'added' %}Администратор добавлен.
+ {% elif saved == 'role' %}Роль обновлена.
+ {% elif saved == 'toggle' %}Доступ обновлён.
+ {% elif saved == 'deleted' %}Администратор удалён.
+ {% else %}Изменения сохранены.{% endif %}
+
+{% endif %}
+
+{% if errors %}
+
+ {% for error in errors %}- {{ error }}
{% endfor %}
+
+{% endif %}
+
+
+
+
+
+
+
+ | Telegram-ID |
+ Username |
+ Роль |
+ Доступ |
+ Последний вход |
+ |
+
+
+
+ {% for admin in admins %}
+
+ |
+ {{ admin.telegram_id }}
+ {% if admin.telegram_id == current_id %}(вы){% endif %}
+ |
+
+ {% if admin.username %}@{{ admin.username }}{% else %}—{% endif %}
+ |
+
+
+ |
+
+ {% if admin.enabled %}
+ включён
+ {% else %}
+ выключен
+ {% endif %}
+ |
+
+ {{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
+ |
+
+
+ {% if admin.telegram_id != current_id %}
+
+
+ {% endif %}
+
+ |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/admin/templates/base.html b/admin/templates/base.html
index e00e35a..61ef119 100644
--- a/admin/templates/base.html
+++ b/admin/templates/base.html
@@ -16,6 +16,7 @@
Сообщения
Расписания
Пользователи
+ Админы
Логи
{% if request.session.get('admin_id') %}
{{ request.session.get('admin_username') or 'админ' }}
diff --git a/admin/templates/messages/list.html b/admin/templates/messages/list.html
index e6bed58..937ad6e 100644
--- a/admin/templates/messages/list.html
+++ b/admin/templates/messages/list.html
@@ -5,7 +5,12 @@
{% block content %}
Сообщения
-
{{ templates | length }} шаблонов
+
diff --git a/admin/templates/messages/new.html b/admin/templates/messages/new.html
new file mode 100644
index 0000000..41e9444
--- /dev/null
+++ b/admin/templates/messages/new.html
@@ -0,0 +1,97 @@
+{% extends "base.html" %}
+
+{% block title %}Новый шаблон{% endblock %}
+
+{% block content %}
+
← Сообщения
+Новый шаблон
+
+{% if errors %}
+
+ {% for error in errors %}- {{ error }}
{% endfor %}
+
+{% endif %}
+
+
+
+
+
Предпросмотр
+
+
Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.
+
+
+
+
+ Доступные теги ({{ tags | length }})
+
+ {% for name, description in tags.items() %}
+
+
- { {{ name }} }
+ - {{ description }}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/admin/templates/users/card.html b/admin/templates/users/card.html
index 2b49de3..b0dd9dc 100644
--- a/admin/templates/users/card.html
+++ b/admin/templates/users/card.html
@@ -171,6 +171,27 @@
+{% if can_kick %}
+
+
Исключить из чата
+
+ Бот заблокирует пользователя и сразу разблокирует (чтобы мог вернуться),
+ а данные из бота будут удалены.
+
+
+
+{% endif %}
+
История изменений
{% if history %}
@@ -182,6 +203,7 @@
Что |
Было → стало |
Комментарий |
+ Источник |
Админ |
@@ -193,6 +215,9 @@
{{ field_labels.get(item.field, item.field) }} |
{{ item.old_value or "—" }} → {{ item.new_value or "—" }} |
{{ item.comment or "—" }} |
+
+ {% if item.source == 'user' %}Пользователь{% else %}Админ{% endif %}
+ |
{{ item.editor_id or "—" }} |
{% endfor %}
diff --git a/admin/templates/users/list.html b/admin/templates/users/list.html
index 330005a..f6bac3e 100644
--- a/admin/templates/users/list.html
+++ b/admin/templates/users/list.html
@@ -23,6 +23,12 @@
+{% if saved == 'kick' %}
+
+ Пользователь исключён из чата, данные удалены.
+
+{% endif %}
+
diff --git a/bot/handlers/member_events.py b/bot/handlers/member_events.py
index a1ef09f..8caf68b 100644
--- a/bot/handlers/member_events.py
+++ b/bot/handlers/member_events.py
@@ -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(
- event.bot,
- username=user.username,
- user_id=user.id,
- chat_title=event.chat.title or "",
- )
+ # Исключение самим ботом (кнопка в админке): уведомление уже отправила админка.
+ 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,
+ 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 "",
+ )
diff --git a/bot/services/alerts.py b/bot/services/alerts.py
index 2e45f5c..5b27b5d 100644
--- a/bot/services/alerts.py
+++ b/bot/services/alerts.py
@@ -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 = {
diff --git a/bot/services/users.py b/bot/services/users.py
index de0bcb7..56889f9 100644
--- a/bot/services/users.py
+++ b/bot/services/users.py
@@ -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})
diff --git a/common/repositories/admins_repo.py b/common/repositories/admins_repo.py
index a8f6ee9..a665b91 100644
--- a/common/repositories/admins_repo.py
+++ b/common/repositories/admins_repo.py
@@ -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)
diff --git a/common/repositories/templates_repo.py b/common/repositories/templates_repo.py
index 476bbff..dccfb31 100644
--- a/common/repositories/templates_repo.py
+++ b/common/repositories/templates_repo.py
@@ -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:
"""Обновить текст шаблона и записать историю.
diff --git a/common/repositories/users_admin_repo.py b/common/repositories/users_admin_repo.py
index 5bdf2c2..6be072a 100644
--- a/common/repositories/users_admin_repo.py
+++ b/common/repositories/users_admin_repo.py
@@ -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),
)
diff --git a/common/repositories/users_repo.py b/common/repositories/users_repo.py
index 0968001..18f5f7e 100644
--- a/common/repositories/users_repo.py
+++ b/common/repositories/users_repo.py
@@ -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:
diff --git a/common/texts/alerts.py b/common/texts/alerts.py
index 3448990..15bd17b 100644
--- a/common/texts/alerts.py
+++ b/common/texts/alerts.py
@@ -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} стучится в чат"
diff --git a/common/texts/render.py b/common/texts/render.py
index 253508d..77ea2e8 100644
--- a/common/texts/render.py
+++ b/common/texts/render.py
@@ -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": "Уровень",
diff --git a/common/version.py b/common/version.py
index 8cf244f..621672c 100644
--- a/common/version.py
+++ b/common/version.py
@@ -7,4 +7,4 @@
from __future__ import annotations
-__version__ = "0.4.13"
+__version__ = "0.4.14"
diff --git a/docs/events.md b/docs/events.md
index 6ea2aa1..a7c96ed 100644
--- a/docs/events.md
+++ b/docs/events.md
@@ -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` | Отписка от рассылок (`/отписка`) | — |
diff --git a/requirements-admin.txt b/requirements-admin.txt
index ec9fd20..d5cc4f6 100644
--- a/requirements-admin.txt
+++ b/requirements-admin.txt
@@ -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
diff --git a/sql/V19__user_changes_source.sql b/sql/V19__user_changes_source.sql
new file mode 100644
index 0000000..5b07cb0
--- /dev/null
+++ b/sql/V19__user_changes_source.sql
@@ -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 (веб-панель)';
diff --git a/tests/test_admin.py b/tests/test_admin.py
index c3df462..75136ba 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -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
diff --git a/tests/test_admin_admins.py b/tests/test_admin_admins.py
new file mode 100644
index 0000000..5d77398
--- /dev/null
+++ b/tests/test_admin_admins.py
@@ -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
diff --git a/tests/test_admin_users.py b/tests/test_admin_users.py
index 615daf6..7f01c3a 100644
--- a/tests/test_admin_users.py
+++ b/tests/test_admin_users.py
@@ -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
diff --git a/tests/test_version.py b/tests/test_version.py
index d1fe57a..9491810 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.13"
+ assert __version__ == "0.4.14"