Админка научилась управлять админами, шаблонами и исключать из чата
Появился раздел «Админы»: добавить человека по Telegram-ID, сменить роль, выключить или удалить (с защитой от отключения себя и последнего администратора). В карточке пользователя теперь можно выгнать из чата: бот банит и сразу разбанивает, данные удаляются, кто кого выгнал — уходит в чат логов. Заодно исправлен старый баг: при исключении уведомление и удаление применялись к тому, кто выгонял, а не к выгнанному. Шаблоны можно создавать из «Сообщений», а смена ника через /ник теперь видна в истории — с пометкой, что это сделал сам пользователь, а не админ.
This commit is contained in:
parent
3a5d786aa2
commit
8ae4e8bb6c
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from common import __version__
|
|||
|
||||
SAMPLE_CONTEXT: dict[str, Any] = {
|
||||
"username": "@test_user",
|
||||
"actor": "@admin_user",
|
||||
"nick": "ТестовыйНик",
|
||||
"title": "Ветеран",
|
||||
"lvl": 3,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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-фрагмент).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Админы{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Админы</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Доступ к веб-панели через Telegram.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{% if saved == 'added' %}Администратор добавлен.
|
||||
{% elif saved == 'role' %}Роль обновлена.
|
||||
{% elif saved == 'toggle' %}Доступ обновлён.
|
||||
{% elif saved == 'deleted' %}Администратор удалён.
|
||||
{% else %}Изменения сохранены.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admins" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<h2 class="text-lg font-medium">Добавить администратора</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label for="telegram_id" class="text-xs text-slate-500">Telegram-ID</label>
|
||||
<input
|
||||
id="telegram_id"
|
||||
name="telegram_id"
|
||||
value="{{ form.telegram_id }}"
|
||||
inputmode="numeric"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="username" class="text-xs text-slate-500">Username (необязательно)</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
value="{{ form.username }}"
|
||||
placeholder="без @"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="role" class="text-xs text-slate-500">Роль</label>
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {% if role == form.role %}selected{% endif %}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
Обе роли сейчас дают полный доступ; <span class="font-mono">moderator</span> — задел на ограничения.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Telegram-ID</th>
|
||||
<th class="px-4 py-3 font-medium">Username</th>
|
||||
<th class="px-4 py-3 font-medium">Роль</th>
|
||||
<th class="px-4 py-3 font-medium">Доступ</th>
|
||||
<th class="px-4 py-3 font-medium">Последний вход</th>
|
||||
<th class="px-4 py-3 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for admin in admins %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-mono text-xs">
|
||||
{{ admin.telegram_id }}
|
||||
{% if admin.telegram_id == current_id %}<span class="ml-1 text-slate-400">(вы)</span>{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if admin.username %}<span class="font-mono">@{{ admin.username }}</span>{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<form method="post" action="/admins/{{ admin.telegram_id }}/role" class="flex items-center gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<select
|
||||
name="role"
|
||||
class="rounded-lg border border-slate-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none"
|
||||
>
|
||||
{% for role in roles %}
|
||||
<option value="{{ role }}" {% if role == admin.role %}selected{% endif %}>{{ role }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="text-xs text-sky-700 hover:underline">Сохранить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if admin.enabled %}
|
||||
<span class="rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включён</span>
|
||||
{% else %}
|
||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключен</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{{ admin.last_login_at.strftime("%d.%m.%Y %H:%M") if admin.last_login_at else "—" }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
{% if admin.telegram_id != current_id %}
|
||||
<form method="post" action="/admins/{{ admin.telegram_id }}/toggle">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-slate-600 hover:underline">
|
||||
{% if admin.enabled %}Выключить{% else %}Включить{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admins/{{ admin.telegram_id }}/delete" onsubmit="return confirm('Удалить администратора?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
|
||||
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
|
||||
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
|
||||
<a href="/admins" class="text-slate-600 hover:text-slate-900">Админы</a>
|
||||
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a>
|
||||
{% if request.session.get('admin_id') %}
|
||||
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@
|
|||
{% block content %}
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Сообщения</h1>
|
||||
<span class="text-sm text-slate-500">{{ templates | length }} шаблонов</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-slate-500">{{ templates | length }} шаблонов</span>
|
||||
<a href="/messages/new" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Новый шаблон
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-slate-600">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Новый шаблон{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
|
||||
<h1 class="mt-1 text-2xl font-semibold">Новый шаблон</h1>
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/messages" class="mt-6 max-w-3xl space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="key" class="text-sm font-medium text-slate-700">Ключ</label>
|
||||
<input
|
||||
id="key"
|
||||
name="key"
|
||||
value="{{ key }}"
|
||||
placeholder="например, shop.rules"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 font-mono text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500">Латиница, цифры, точка, подчёркивание, дефис. По ключу шаблон выбирается в расписаниях.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="text-sm font-medium text-slate-700">Описание</label>
|
||||
<input
|
||||
id="description"
|
||||
name="description"
|
||||
value="{{ description }}"
|
||||
maxlength="{{ max_description_length }}"
|
||||
placeholder="для себя и админов"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
|
||||
<textarea
|
||||
id="text"
|
||||
name="text"
|
||||
rows="16"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 p-3 font-mono text-sm leading-relaxed focus:border-sky-500 focus:outline-none"
|
||||
>{{ text }}</textarea>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Лимит Telegram — {{ max_length }} символов. Теги подставляет рендерер бота; неизвестный тег — ошибка.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="enabled" {% if enabled %}checked{% endif %} />
|
||||
Включён (бот читает текст)
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Создать
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||
hx-post="/messages/preview"
|
||||
hx-include="closest form"
|
||||
hx-target="#preview"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
Предпросмотр
|
||||
</button>
|
||||
<a href="/messages" class="text-sm text-slate-600 hover:underline">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 max-w-3xl">
|
||||
<h2 class="text-lg font-medium">Предпросмотр</h2>
|
||||
<div id="preview" class="mt-2">
|
||||
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с тестовыми тегами.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mt-8 max-w-3xl rounded-lg border border-slate-200 bg-white p-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-slate-700">Доступные теги ({{ tags | length }})</summary>
|
||||
<dl class="mt-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
|
||||
{% for name, description in tags.items() %}
|
||||
<div class="flex gap-2">
|
||||
<dt class="font-mono text-sky-700">{ {{ name }} }</dt>
|
||||
<dd class="text-slate-600">{{ description }}</dd>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</details>
|
||||
{% endblock %}
|
||||
|
|
@ -171,6 +171,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_kick %}
|
||||
<div class="mt-6 rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<h2 class="text-lg font-medium text-red-800">Исключить из чата</h2>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
Бот заблокирует пользователя и сразу разблокирует (чтобы мог вернуться),
|
||||
а данные из бота будут удалены.
|
||||
</p>
|
||||
<form
|
||||
method="post"
|
||||
action="/users/{{ user.id_user }}/kick"
|
||||
class="mt-3"
|
||||
onsubmit="return confirm('Исключить {{ user.nick or user.id_user }} из чата?');"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
|
||||
Выгнать из чата
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-medium">История изменений</h2>
|
||||
{% if history %}
|
||||
|
|
@ -182,6 +203,7 @@
|
|||
<th class="px-4 py-3 font-medium">Что</th>
|
||||
<th class="px-4 py-3 font-medium">Было → стало</th>
|
||||
<th class="px-4 py-3 font-medium">Комментарий</th>
|
||||
<th class="px-4 py-3 font-medium">Источник</th>
|
||||
<th class="px-4 py-3 font-medium">Админ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -193,6 +215,9 @@
|
|||
<td class="px-4 py-3">{{ field_labels.get(item.field, item.field) }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ item.old_value or "—" }} → {{ item.new_value or "—" }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ item.comment or "—" }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">
|
||||
{% if item.source == 'user' %}Пользователь{% else %}Админ{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
{% if saved == 'kick' %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
Пользователь исключён из чата, данные удалены.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-slate-600">
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Обновить текст шаблона и записать историю.
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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} стучится в чат"
|
||||
|
|
|
|||
|
|
@ -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": "Уровень",
|
||||
|
|
|
|||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.4.13"
|
||||
__version__ = "0.4.14"
|
||||
|
|
|
|||
|
|
@ -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` | Отписка от рассылок (`/отписка`) | — |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (веб-панель)';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue