From 8ae4e8bb6c846ae61c796b0c04811969a9d8b37a Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sun, 25 Oct 2026 01:05:02 +0500 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=83=D1=87=D0=B8=D0=BB=D0=B0=D1=81=D1=8C=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D1=82=D1=8C=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8,=20=D1=88=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D1=8C=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Появился раздел «Админы»: добавить человека по Telegram-ID, сменить роль, выключить или удалить (с защитой от отключения себя и последнего администратора). В карточке пользователя теперь можно выгнать из чата: бот банит и сразу разбанивает, данные удаляются, кто кого выгнал — уходит в чат логов. Заодно исправлен старый баг: при исключении уведомление и удаление применялись к тому, кто выгонял, а не к выгнанному. Шаблоны можно создавать из «Сообщений», а смена ника через /ник теперь видна в истории — с пометкой, что это сделал сам пользователь, а не админ. --- README.md | 8 +- admin/app.py | 3 +- admin/preview.py | 1 + admin/routes/admins.py | 145 +++++++++++++++++++++ admin/routes/messages.py | 96 ++++++++++++++ admin/routes/users.py | 59 ++++++++- admin/telegram.py | 83 ++++++++++++ admin/templates/admins/list.html | 143 +++++++++++++++++++++ admin/templates/base.html | 1 + admin/templates/messages/list.html | 7 +- admin/templates/messages/new.html | 97 ++++++++++++++ admin/templates/users/card.html | 25 ++++ admin/templates/users/list.html | 6 + bot/handlers/member_events.py | 52 +++++--- bot/services/alerts.py | 18 +++ bot/services/users.py | 2 +- common/repositories/admins_repo.py | 53 ++++++++ common/repositories/templates_repo.py | 31 +++++ common/repositories/users_admin_repo.py | 9 +- common/repositories/users_repo.py | 35 ++++++ common/texts/alerts.py | 1 + common/texts/render.py | 1 + common/version.py | 2 +- docs/events.md | 2 +- requirements-admin.txt | 3 + sql/V19__user_changes_source.sql | 5 + tests/test_admin.py | 105 ++++++++++++++++ tests/test_admin_admins.py | 161 ++++++++++++++++++++++++ tests/test_admin_users.py | 62 +++++++++ tests/test_version.py | 2 +- 30 files changed, 1191 insertions(+), 27 deletions(-) create mode 100644 admin/routes/admins.py create mode 100644 admin/telegram.py create mode 100644 admin/templates/admins/list.html create mode 100644 admin/templates/messages/new.html create mode 100644 sql/V19__user_changes_source.sql create mode 100644 tests/test_admin_admins.py 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 %} + +{% endif %} + +
+ +

Добавить администратора

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

+ Обе роли сейчас дают полный доступ; moderator — задел на ограничения. +

+
+ +
+ + + + + + + + + + + + + {% for admin in admins %} + + + + + + + + + {% endfor %} + +
Telegram-IDUsernameРольДоступПоследний вход
+ {{ 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 %} +
+
+
+{% 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 }} шаблонов +
+ {{ 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 %} + +{% endif %} + +
+ + +
+
+ + +

Латиница, цифры, точка, подчёркивание, дефис. По ключу шаблон выбирается в расписаниях.

+
+
+ + +
+
+ +
+ + +

+ Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка. +

+
+ + + +
+ + + Отмена +
+
+ +
+

Предпросмотр

+
+

Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.

+
+
+ +
+ Доступные теги ({{ 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"