Админка научилась управлять админами, шаблонами и исключать из чата
Появился раздел «Админы»: добавить человека по Telegram-ID, сменить роль, выключить или удалить (с защитой от отключения себя и последнего администратора). В карточке пользователя теперь можно выгнать из чата: бот банит и сразу разбанивает, данные удаляются, кто кого выгнал — уходит в чат логов. Заодно исправлен старый баг: при исключении уведомление и удаление применялись к тому, кто выгонял, а не к выгнанному. Шаблоны можно создавать из «Сообщений», а смена ника через /ник теперь видна в истории — с пометкой, что это сделал сам пользователь, а не админ.
This commit is contained in:
parent
3a5d786aa2
commit
6f12eab278
|
|
@ -84,6 +84,12 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
|
||||||
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
|
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
|
||||||
если он недоступен, страница показывает ошибку, а не падает.
|
если он недоступен, страница показывает ошибку, а не падает.
|
||||||
|
|
||||||
|
Раздел «Админы» — выдача и отзыв доступа к панели по Telegram-ID, роли и вкл/выкл.
|
||||||
|
В карточке пользователя есть исключение из чата (бан + разбан) с удалением данных и
|
||||||
|
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
|
||||||
|
пользователь через `/ник` или админ. Новые шаблоны создаются в «Сообщениях»: ключ,
|
||||||
|
описание, текст и предпросмотр.
|
||||||
|
|
||||||
## Аналитика (ClickHouse)
|
## Аналитика (ClickHouse)
|
||||||
|
|
||||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
Все значимые действия бота пишутся в 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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from fastapi.templating import Jinja2Templates
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from admin.middleware import AdminAuthMiddleware
|
from admin.middleware import AdminAuthMiddleware
|
||||||
from admin.routes import auth, health, logs, messages, pages, schedules, users
|
from admin.routes import admins, auth, health, logs, messages, pages, schedules, users
|
||||||
from common import __version__
|
from common import __version__
|
||||||
from common.analytics import read as analytics_read
|
from common.analytics import read as analytics_read
|
||||||
from common.config import AdminSettings, get_admin_settings
|
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(messages.router)
|
||||||
app.include_router(schedules.router)
|
app.include_router(schedules.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
|
app.include_router(admins.router)
|
||||||
app.include_router(logs.router)
|
app.include_router(logs.router)
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from common import __version__
|
||||||
|
|
||||||
SAMPLE_CONTEXT: dict[str, Any] = {
|
SAMPLE_CONTEXT: dict[str, Any] = {
|
||||||
"username": "@test_user",
|
"username": "@test_user",
|
||||||
|
"actor": "@admin_user",
|
||||||
"nick": "ТестовыйНик",
|
"nick": "ТестовыйНик",
|
||||||
"title": "Ветеран",
|
"title": "Ветеран",
|
||||||
"lvl": 3,
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
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"])
|
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(
|
def _editor_context(
|
||||||
request: Request,
|
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)
|
@router.post("/preview", response_class=HTMLResponse)
|
||||||
async def preview_message(request: Request) -> HTMLResponse:
|
async def preview_message(request: Request) -> HTMLResponse:
|
||||||
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).
|
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ from __future__ import annotations
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from admin import telegram
|
||||||
from admin.security import check_csrf
|
from admin.security import check_csrf
|
||||||
from common import __version__
|
from common import __version__
|
||||||
from common.eggs import is_egg_event_active
|
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"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
@ -21,6 +24,19 @@ def _editor_id(request: Request) -> int:
|
||||||
return int(request.state.admin["telegram_id"])
|
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(
|
async def _card_response(
|
||||||
request: Request,
|
request: Request,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|
@ -46,6 +62,7 @@ async def _card_response(
|
||||||
"errors": errors or [],
|
"errors": errors or [],
|
||||||
"saved": saved,
|
"saved": saved,
|
||||||
"eggs_visible": is_egg_event_active(request.app.state.settings),
|
"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_nick_length": users_admin_repo.MAX_NICK_LENGTH,
|
||||||
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
|
"max_comment_length": users_admin_repo.MAX_COMMENT_LENGTH,
|
||||||
},
|
},
|
||||||
|
|
@ -54,7 +71,7 @@ async def _card_response(
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
@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 и пагинация."""
|
"""Список пользователей: поиск по нику/username/имени/ID и пагинация."""
|
||||||
total = await users_admin_repo.count_users(q)
|
total = await users_admin_repo.count_users(q)
|
||||||
pages = max((total + PAGE_SIZE - 1) // PAGE_SIZE, 1)
|
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,
|
"page": page,
|
||||||
"pages": pages,
|
"pages": pages,
|
||||||
"total": total,
|
"total": total,
|
||||||
|
"saved": saved,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -181,3 +199,40 @@ async def update_balance(request: Request, user_id: int) -> Response:
|
||||||
if balance is None:
|
if balance is None:
|
||||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||||
return RedirectResponse(url=f"/users/{user_id}?saved=balance", status_code=303)
|
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="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
|
||||||
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
|
<a href="/schedules" class="text-slate-600 hover:text-slate-900">Расписания</a>
|
||||||
<a href="/users" class="text-slate-600 hover:text-slate-900">Пользователи</a>
|
<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>
|
<a href="/logs" class="text-slate-600 hover:text-slate-900">Логи</a>
|
||||||
{% if request.session.get('admin_id') %}
|
{% if request.session.get('admin_id') %}
|
||||||
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
<span class="text-slate-500">{{ request.session.get('admin_username') or 'админ' }}</span>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-semibold">Сообщения</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p class="mt-2 text-slate-600">
|
<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>
|
||||||
</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">
|
<div class="mt-8">
|
||||||
<h2 class="text-lg font-medium">История изменений</h2>
|
<h2 class="text-lg font-medium">История изменений</h2>
|
||||||
{% if history %}
|
{% 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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</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">{{ 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-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">{{ 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>
|
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ item.editor_id or "—" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<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">
|
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||||
<thead class="bg-slate-50 text-left text-slate-600">
|
<thead class="bg-slate-50 text-left text-slate-600">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiogram import Router
|
from aiogram import Router
|
||||||
|
from aiogram.enums import ChatMemberStatus
|
||||||
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter
|
from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter
|
||||||
from aiogram.types import ChatMemberUpdated, FSInputFile
|
from aiogram.types import ChatMemberUpdated, FSInputFile
|
||||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
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))
|
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=LEAVE_TRANSITION))
|
||||||
async def goodbye_handler(event: ChatMemberUpdated) -> None:
|
async def goodbye_handler(event: ChatMemberUpdated) -> None:
|
||||||
"""Пользователь вышел из чата — удалить запись."""
|
"""Пользователь вышел или был исключён — удалить данные именно его."""
|
||||||
user = event.from_user
|
leaver = event.old_chat_member.user
|
||||||
if user is None or user.is_bot:
|
if leaver.is_bot:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
actor = event.from_user
|
||||||
|
kicked = event.new_chat_member.status == ChatMemberStatus.KICKED
|
||||||
|
by_self = actor is None or actor.id == leaver.id
|
||||||
|
|
||||||
record(
|
record(
|
||||||
EventType.LEAVE,
|
EventType.LEAVE,
|
||||||
user_id=user.id,
|
user_id=leaver.id,
|
||||||
username=user.username,
|
username=leaver.username,
|
||||||
chat_id=event.chat.id,
|
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)
|
await users_service.delete_user(leaver.id)
|
||||||
logger.info("Удалён пользователь %s после выхода из чата", user.id)
|
logger.info("Удалён пользователь %s после выхода из чата", leaver.id)
|
||||||
|
|
||||||
await alerts.notify_leave(
|
# Исключение самим ботом (кнопка в админке): уведомление уже отправила админка.
|
||||||
event.bot,
|
if kicked and actor is not None and actor.id == event.bot.id:
|
||||||
username=user.username,
|
return
|
||||||
user_id=user.id,
|
|
||||||
chat_title=event.chat.title or "",
|
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))
|
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:
|
async def notify_purification(bot: Bot, *, username: str | None, user_id: int, result: PurificationResult) -> bool:
|
||||||
"""Уведомление об успешном очищении."""
|
"""Уведомление об успешном очищении."""
|
||||||
context = {
|
context = {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ async def register_from_event(event: ChatMemberUpdated) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def set_nick(id_user: int, nick: str) -> 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})
|
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",
|
"UPDATE bot.admin_users SET last_login_at = now() WHERE telegram_id = %s",
|
||||||
(telegram_id,),
|
(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()
|
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:
|
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:
|
async with get_pool().connection() as conn:
|
||||||
cur = await conn.execute(
|
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
|
FROM bot.user_changes
|
||||||
WHERE id_user = %s
|
WHERE id_user = %s
|
||||||
ORDER BY changed_at DESC
|
ORDER BY changed_at DESC
|
||||||
|
|
@ -157,13 +157,14 @@ async def _log_change(
|
||||||
new_value: str | None,
|
new_value: str | None,
|
||||||
editor_id: int | None,
|
editor_id: int | None,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
|
source: str = "admin",
|
||||||
) -> None:
|
) -> None:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id)
|
INSERT INTO bot.user_changes (id_user, field, old_value, new_value, comment, editor_id, source)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
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))
|
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]]:
|
async def get_nick_list() -> list[dict[str, Any]]:
|
||||||
"""Список всех игроков (username, nick), отсортированный по нику."""
|
"""Список всех игроков (username, nick), отсортированный по нику."""
|
||||||
async with get_pool().connection() as conn, conn.cursor() as cur:
|
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_JOIN = "[вход] {username} вошёл в чат «{chat_title}»"
|
||||||
MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»"
|
MSG_LEAVE = "[выход] {username} покинул чат «{chat_title}»"
|
||||||
|
MSG_KICK = "[кик] {actor} выгнал {username} из чата «{chat_title}»"
|
||||||
MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}"
|
MSG_PURIFICATION = "[очищение] {username} — опыт +{exp_add}, монеты +{money_add}"
|
||||||
MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}"
|
MSG_ALIVE = "[бот живой] версия {version}, аптайм {uptime}"
|
||||||
MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат"
|
MSG_ACCESS_DENIED = "[доступ] {username} стучится в чат"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ RAW_TAGS: frozenset[str] = frozenset({"usernames", "users_list", "users_list_cou
|
||||||
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
#: Полный реестр поддерживаемых тегов: имя -> короткое описание.
|
||||||
TAG_REGISTRY: dict[str, str] = {
|
TAG_REGISTRY: dict[str, str] = {
|
||||||
"username": "Имя пользователя (@username или имя)",
|
"username": "Имя пользователя (@username или имя)",
|
||||||
|
"actor": "Кто выполнил действие (для уведомлений)",
|
||||||
"nick": "Игровой ник",
|
"nick": "Игровой ник",
|
||||||
"title": "Звание",
|
"title": "Звание",
|
||||||
"lvl": "Уровень",
|
"lvl": "Уровень",
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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_title` | Покупка звания | `title_id`, `price`, `success` |
|
||||||
| `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` |
|
| `purchase_donat` | Покупка доната | `donat_id`, `price`, `success` |
|
||||||
| `join` | Вход участника в основной чат | `chat_title` |
|
| `join` | Вход участника в основной чат | `chat_title` |
|
||||||
| `leave` | Выход участника из основного чата | `chat_title` |
|
| `leave` | Выход/исключение участника | `chat_title`, `kicked`, `by_id`, `by_username` |
|
||||||
| `nick_set` | Смена ника (`/ник`) | `nick` |
|
| `nick_set` | Смена ника (`/ник`) | `nick` |
|
||||||
| `subscribe` | Подписка на рассылки (`/подписка`) | — |
|
| `subscribe` | Подписка на рассылки (`/подписка`) | — |
|
||||||
| `unsubscribe` | Отписка от рассылок (`/отписка`) | — |
|
| `unsubscribe` | Отписка от рассылок (`/отписка`) | — |
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,8 @@ psycopg[binary,pool]>=3.2,<4.0
|
||||||
# ClickHouse — чтение логов в админке (раздел «Логи»).
|
# ClickHouse — чтение логов в админке (раздел «Логи»).
|
||||||
clickhouse-connect[async]>=1.0,<2.0
|
clickhouse-connect[async]>=1.0,<2.0
|
||||||
|
|
||||||
|
# Telegram Bot API для модерации (исключение из чата) и уведомлений.
|
||||||
|
httpx>=0.27,<1.0
|
||||||
|
|
||||||
# Разбор form-data (HTMX-формы).
|
# Разбор form-data (HTMX-формы).
|
||||||
python-multipart>=0.0.9,<1.0
|
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 response.status_code == 200
|
||||||
assert "Текст сохранён" in response.text
|
assert "Текст сохранён" in response.text
|
||||||
assert saved == {"key": "info.help", "text": "Новая справка", "editor_id": 1}
|
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": "ТестовыйНик",
|
"new_value": "ТестовыйНик",
|
||||||
"comment": None,
|
"comment": None,
|
||||||
"editor_id": 1,
|
"editor_id": 1,
|
||||||
|
"source": "admin",
|
||||||
"changed_at": datetime(2026, 10, 18, 12, 0, tzinfo=UTC),
|
"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 response.status_code == 400
|
||||||
assert "Сумма не может быть нулевой" in response.text
|
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():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.4.13"
|
assert __version__ == "0.4.14"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue