Админка научилась показывать и править тексты сообщений

Появились таблицы шаблонов, истории правок и расписаний: тексты по умолчанию переехали в миграцию-сид, а раздел «Сообщения» показывает список, даёт отредактировать текст с проверкой тегов и предпросмотром через тот же рендерер, что у бота. Каждое сохранение пишет старую и новую версию в историю — это аудит и заготовка для отката. Бот пока читает тексты из кода: переключение на базу — следующий шаг (4.5).
This commit is contained in:
MpakobecEx 2026-10-19 03:34:41 +05:00
parent b11676764e
commit 6e3209a339
17 changed files with 763 additions and 22 deletions

View File

@ -52,6 +52,15 @@ APP_ENV=prod python -m bot # prod
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
## Админка (Блок 4)
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
Раздел «Сообщения» читает и правит шаблоны из `bot.message_templates`, каждое изменение
пишется в `bot.message_template_history` (аудит и материал для отката). Стартовые тексты —
`sql/V15__seed_message_templates.sql` (генерируются из `common/texts`, тест сверяет их).
Предпросмотр использует тот же `render()`, что и бот. Бот пока читает тексты из кода —
переключение на БД в шаге 4.5.
## Аналитика (ClickHouse)
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
@ -112,7 +121,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.0`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.1`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -2,28 +2,42 @@
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
from admin.routes import health, pages
from admin.routes import health, messages, pages
from common import __version__
from common.config import AdminSettings, get_admin_settings
from common.db.pool import close_pool, init_pool
from common.logging_config import setup_logging
BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = BASE_DIR / "templates"
def create_app(settings: AdminSettings | None = None) -> FastAPI:
def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) -> FastAPI:
"""Собрать приложение: настройки, шаблоны, роутеры.
Настройки можно передать явно (тесты); по умолчанию читаются из окружения.
``init_db=False`` — не открывать пул Postgres (юнит-тесты без БД).
"""
setup_logging()
settings = settings or get_admin_settings()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
if init_db:
await init_pool(app.state.settings)
try:
yield
finally:
if init_db:
await close_pool()
app = FastAPI(
title="DispelEvilBot Admin",
version=__version__,
@ -31,10 +45,12 @@ def create_app(settings: AdminSettings | None = None) -> FastAPI:
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=lifespan,
)
app.state.settings = settings
app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.include_router(health.router)
app.include_router(pages.router)
app.include_router(messages.router)
return app

37
admin/preview.py Normal file
View File

@ -0,0 +1,37 @@
"""Предпросмотр шаблонов: тестовые значения для всех тегов.
Контекст общий для админки и тестов: он должен покрывать ``TAG_REGISTRY``,
иначе в предпросмотре часть тегов останется пустой.
"""
from __future__ import annotations
from typing import Any
from common import __version__
SAMPLE_CONTEXT: dict[str, Any] = {
"username": "@test_user",
"nick": "ТестовыйНик",
"title": "Ветеран",
"lvl": 3,
"exp": 120,
"exp_max": 300,
"money": 1500,
"dt": "01.01.2026",
"msg_count": 42,
"eggs": 2,
"h": "12",
"m": "05",
"s": "09",
"exp_add": 10,
"money_add": 50,
"usernames": "@test_user @second_user",
"users_list": "1: @test_user - ТестовыйНик\n2: @second_user - Второй",
"users_list_count": 2,
"tops": "1) ТестовыйНик - 42\n2) Второй - 17",
"promo": "PROMO-2026",
"chat_title": "Dispel Evil",
"version": __version__,
"uptime": "2ч 15м",
}

128
admin/routes/messages.py Normal file
View File

@ -0,0 +1,128 @@
"""Раздел «Сообщения»: список шаблонов, редактор и предпросмотр (HTMX)."""
from __future__ import annotations
import html
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from admin.preview import SAMPLE_CONTEXT
from common import __version__
from common.repositories import templates_repo
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, TemplateError, render, validate
router = APIRouter(prefix="/messages", tags=["messages"])
def _editor_context(
request: Request,
template: dict[str, object],
*,
text: str | None = None,
errors: list[str] | None = None,
saved: bool = False,
history: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""Контекст страницы редактора (общий для GET и повторного показа после ошибки)."""
return {
"version": __version__,
"template": template,
"text": text if text is not None else template["text"],
"errors": errors or [],
"saved": saved,
"history": history or [],
"tags": TAG_REGISTRY,
"max_length": MAX_MESSAGE_LENGTH,
}
@router.get("", response_class=HTMLResponse)
async def list_messages(request: Request) -> HTMLResponse:
"""Список шаблонов: ключ, описание, длина и время последней правки."""
templates = request.app.state.templates
return templates.TemplateResponse(
request=request,
name="messages/list.html",
context={"version": __version__, "templates": await templates_repo.list_templates()},
)
@router.post("/preview", response_class=HTMLResponse)
async def preview_message(request: Request) -> HTMLResponse:
"""Отрендерить текст с тестовыми значениями (HTMX-фрагмент).
Результат кладётся в ``iframe sandbox``: разметка Telegram видна «как в чате»,
но скрипты из введённого текста не выполняются.
"""
form = await request.form()
text = str(form.get("text", ""))
unknown = validate(text)
if unknown:
return HTMLResponse(
'<p class="text-sm text-red-600">Неизвестные теги: ' + html.escape(", ".join(unknown)) + "</p>"
)
try:
rendered = render(text, SAMPLE_CONTEXT)
except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
return HTMLResponse(f'<p class="text-sm text-red-600">{html.escape(str(exc))}</p>')
if len(rendered) > MAX_MESSAGE_LENGTH:
return HTMLResponse(
f'<p class="text-sm text-red-600">Текст длиннее лимита Telegram: '
f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.</p>"
)
return HTMLResponse(
'<iframe sandbox class="h-64 w-full rounded border border-slate-200 bg-white" '
f'srcdoc="{html.escape(rendered, quote=True)}"></iframe>'
)
@router.post("/{key}")
async def save_message(request: Request, key: str) -> Response:
"""Сохранить текст шаблона; при ошибках вернуть редактор с подсветкой."""
form = await request.form()
text = str(form.get("text", ""))
template = await templates_repo.get_template(key)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
errors: list[str] = []
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
if not text.strip():
errors.append("Текст не может быть пустым")
if len(text) > MAX_MESSAGE_LENGTH:
errors.append(f"Длина {len(text)} превышает лимит Telegram ({MAX_MESSAGE_LENGTH})")
if errors:
history = await templates_repo.template_history(key)
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/edit.html",
context=_editor_context(request, template, text=text, errors=errors, history=history),
status_code=400,
)
await templates_repo.update_template(key, text)
return RedirectResponse(url=f"/messages/{key}?saved=1", status_code=303)
@router.get("/{key}", response_class=HTMLResponse)
async def edit_message(request: Request, key: str, saved: bool = False) -> HTMLResponse:
"""Редактор шаблона: текст, подсказка по тегам, предпросмотр и история."""
template = await templates_repo.get_template(key)
if template is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
history = await templates_repo.template_history(key)
return request.app.state.templates.TemplateResponse(
request=request,
name="messages/edit.html",
context=_editor_context(request, template, saved=saved, history=history),
)

View File

@ -9,12 +9,28 @@ from common import __version__
router = APIRouter(tags=["pages"])
#: Разделы админки по плану Блока 4 (пока заглушки на главной).
SECTIONS: tuple[tuple[str, str, str], ...] = (
("messages", "Сообщения", "Тексты бота и предпросмотр с тегами {tag}"),
("schedules", "Расписания", "Время рассылок, дни недели и шаблон"),
("users", "Пользователи", "Ники, звания, баланс и подписка"),
("logs", "Логи", "События из ClickHouse с фильтрами"),
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
SECTIONS: tuple[dict[str, str | None], ...] = (
{
"title": "Сообщения",
"description": "Тексты бота и предпросмотр с тегами {tag}",
"url": "/messages",
},
{
"title": "Расписания",
"description": "Время рассылок, дни недели и шаблон",
"url": None,
},
{
"title": "Пользователи",
"description": "Ники, звания, баланс и подписка",
"url": None,
},
{
"title": "Логи",
"description": "События из ClickHouse с фильтрами",
"url": None,
},
)

View File

@ -11,7 +11,11 @@
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<a href="/" class="text-lg font-semibold">DispelEvilBot</a>
<span class="text-sm text-slate-500">админка · v{{ version }}</span>
<nav class="flex items-center gap-4 text-sm">
<a href="/" class="text-slate-600 hover:text-slate-900">Главная</a>
<a href="/messages" class="text-slate-600 hover:text-slate-900">Сообщения</a>
<span class="text-slate-500">админка · v{{ version }}</span>
</nav>
</div>
</header>

View File

@ -10,13 +10,15 @@
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{% for key, title, description in sections %}
{% for section in sections %}
<section class="rounded-lg border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium">{{ title }}</h2>
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">скоро</span>
<h2 class="text-lg font-medium">
{% if section.url %}<a href="{{ section.url }}" class="hover:underline">{{ section.title }}</a>{% else %}{{ section.title }}{% endif %}
</h2>
{% if not section.url %}<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">скоро</span>{% endif %}
</div>
<p class="mt-2 text-sm text-slate-600">{{ description }}</p>
<p class="mt-2 text-sm text-slate-600">{{ section.description }}</p>
</section>
{% endfor %}
</div>

View File

@ -0,0 +1,101 @@
{% extends "base.html" %}
{% block title %}{{ template.key }}{% endblock %}
{% block content %}
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500"><a href="/messages" class="hover:underline">← Сообщения</a></p>
<h1 class="mt-1 font-mono text-xl font-semibold">{{ template.key }}</h1>
<p class="text-sm text-slate-600">{{ template.description }}</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">
Текст сохранён.
</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="/messages/{{ template.key }}" class="mt-6 space-y-4">
<div>
<label for="text" class="text-sm font-medium text-slate-700">Текст шаблона</label>
<textarea
id="text"
name="text"
rows="18"
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>
<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>
</div>
</form>
<div class="mt-8">
<h2 class="text-lg font-medium">Предпросмотр</h2>
<p class="text-sm text-slate-600">Тестовые значения: @test_user, Ветеран, 3 уровень, 1500 DE.</p>
<div id="preview" class="mt-2">
<p class="text-sm text-slate-400">Нажмите «Предпросмотр», чтобы увидеть текст с подставленными тегами.</p>
</div>
</div>
<details class="mt-8 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>
<div class="mt-8">
<h2 class="text-lg font-medium">История изменений</h2>
{% if history %}
<ul class="mt-3 space-y-2">
{% for item in history %}
<li class="rounded-lg border border-slate-200 bg-white p-3 text-sm">
<div class="flex items-center justify-between text-slate-500">
<span>{{ item.changed_at.strftime("%d.%m.%Y %H:%M") }}</span>
<span>редактор: {{ item.editor_id or "—" }}</span>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-slate-600">старое → новое</summary>
<div class="mt-2 grid gap-3 sm:grid-cols-2">
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.old_text or "" }}</pre>
<pre class="max-h-48 overflow-auto rounded bg-slate-50 p-2 text-xs whitespace-pre-wrap">{{ item.new_text }}</pre>
</div>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Сообщения{% endblock %}
{% 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>
<p class="mt-2 text-slate-600">
Тексты хранятся в Postgres. В редакторе можно проверить подстановку тегов
<span class="font-mono">{tag}</span> на тестовых данных.
</p>
<div class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
Правки пока не влияют на бота: он читает тексты из кода. Подключение шаблонов из БД —
следующий шаг Блока 4 (4.5).
</div>
<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">Ключ</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 template in templates %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<a href="/messages/{{ template.key }}" class="font-mono text-sky-700 hover:underline">{{ template.key }}</a>
</td>
<td class="px-4 py-3 text-slate-700">{{ template.description }}</td>
<td class="px-4 py-3 text-slate-500">{{ template.text_length }}</td>
<td class="px-4 py-3 text-slate-500">{{ template.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -11,20 +11,23 @@ import logging
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool
from common.config import get_settings
from common.config import Settings, get_settings
logger = logging.getLogger(__name__)
_pool: AsyncConnectionPool | None = None
async def init_pool() -> AsyncConnectionPool:
"""Создать и открыть пул. Идемпотентно."""
async def init_pool(settings: Settings | None = None) -> AsyncConnectionPool:
"""Создать и открыть пул. Идемпотентно.
``settings`` передаёт админка (свои настройки); по умолчанию — настройки бота.
"""
global _pool
if _pool is not None:
return _pool
settings = get_settings()
settings = settings or get_settings()
_pool = AsyncConnectionPool(
conninfo=settings.dsn,
min_size=settings.db_pool_min,

View File

@ -0,0 +1,89 @@
"""Репозиторий шаблонов сообщений (Блок 4.1: редактирование в админке).
Каждое изменение текста пишет запись в ``message_template_history`` — это аудит
и материал для отката. Бот подключится к этим данным в Блоке 4.5.
"""
from __future__ import annotations
from typing import Any
from common.db.pool import get_pool
async def list_templates() -> list[dict[str, Any]]:
"""Вернуть список шаблонов без текста (для таблицы в админке)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT key, description, enabled, updated_at, updated_by,
length(text) AS text_length
FROM bot.message_templates
ORDER BY key
"""
)
return await cur.fetchall()
async def get_template(key: str) -> dict[str, Any] | None:
"""Вернуть шаблон целиком (с текстом) или ``None``."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT key, text, description, enabled, updated_at, updated_by
FROM bot.message_templates
WHERE key = %s
""",
(key,),
)
return await cur.fetchone()
async def update_template(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any] | None:
"""Обновить текст шаблона и записать историю.
Возвращает ``None``, если шаблона нет. Старое значение читается под
``FOR UPDATE``, поэтому параллельные правки не потеряют историю.
"""
async with get_pool().connection() as conn:
cur = await conn.execute("SELECT text FROM bot.message_templates WHERE key = %s FOR UPDATE", (key,))
row = await cur.fetchone()
if row is None:
return None
old_text = row["text"]
if old_text == text:
return {"key": key, "old_text": old_text, "text": text, "changed": False}
await conn.execute(
"""
UPDATE bot.message_templates
SET text = %s, updated_at = now(), updated_by = %s
WHERE key = %s
""",
(text, editor_id, key),
)
await conn.execute(
"""
INSERT INTO bot.message_template_history (key, old_text, new_text, editor_id)
VALUES (%s, %s, %s, %s)
""",
(key, old_text, text, editor_id),
)
return {"key": key, "old_text": old_text, "text": text, "changed": True}
async def template_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
"""Последние изменения шаблона (для блока истории в редакторе)."""
async with get_pool().connection() as conn:
cur = await conn.execute(
"""
SELECT id, old_text, new_text, editor_id, changed_at
FROM bot.message_template_history
WHERE key = %s
ORDER BY changed_at DESC
LIMIT %s
""",
(key, limit),
)
return await cur.fetchall()

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.4.0"
__version__ = "0.4.1"

63
sql/V14__admin_tables.sql Normal file
View File

@ -0,0 +1,63 @@
-- Таблицы админки (Блок 4.1): шаблоны сообщений, история изменений и расписания.
-- Схема bot.* уже создана миграцией V1.
-- Тексты сообщений: источник правды для админки (бот подключается в 4.5).
CREATE TABLE IF NOT EXISTS bot.message_templates (
key text PRIMARY KEY,
text text NOT NULL,
description text NOT NULL DEFAULT '',
enabled boolean NOT NULL DEFAULT true,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by bigint
);
COMMENT ON TABLE bot.message_templates IS 'Шаблоны сообщений бота (редактируются в админке)';
COMMENT ON COLUMN bot.message_templates.key IS 'Стабильный ключ шаблона (например, info.help)';
COMMENT ON COLUMN bot.message_templates.updated_by IS 'Telegram-ID администратора, последним изменившего текст';
-- История правок: старые значения и автор (аудит + откат).
CREATE TABLE IF NOT EXISTS bot.message_template_history (
id bigserial PRIMARY KEY,
key text NOT NULL REFERENCES bot.message_templates (key) ON DELETE CASCADE,
old_text text,
new_text text NOT NULL,
editor_id bigint,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_template_history_key_time
ON bot.message_template_history (key, changed_at DESC);
COMMENT ON TABLE bot.message_template_history IS 'История изменений шаблонов (кто, когда, старое и новое значение)';
-- Расписания рассылок: редактирует админка, исполняет бот (4.5).
CREATE TABLE IF NOT EXISTS bot.schedules (
id bigserial PRIMARY KEY,
template_key text NOT NULL REFERENCES bot.message_templates (key) ON DELETE RESTRICT,
hour smallint NOT NULL CHECK (hour BETWEEN 0 AND 23),
minute smallint NOT NULL CHECK (minute BETWEEN 0 AND 59),
weekdays smallint[] NOT NULL DEFAULT '{}',
timezone text NOT NULL DEFAULT 'Europe/Moscow',
enabled boolean NOT NULL DEFAULT true,
description text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by bigint
);
COMMENT ON TABLE bot.schedules IS 'Расписания рассылок (исполняет бот, редактирует админка)';
COMMENT ON COLUMN bot.schedules.weekdays IS 'Дни недели 1..7 (ISO, 1 = понедельник); пусто — каждый день';
-- История изменений расписаний: снимок строки до и после правки.
CREATE TABLE IF NOT EXISTS bot.schedules_history (
id bigserial PRIMARY KEY,
schedule_id bigint NOT NULL REFERENCES bot.schedules (id) ON DELETE CASCADE,
old_data jsonb,
new_data jsonb NOT NULL,
editor_id bigint,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_schedules_history_schedule_time
ON bot.schedules_history (schedule_id, changed_at DESC);
COMMENT ON TABLE bot.schedules_history IS 'История изменений расписаний (аудит)';

View File

@ -0,0 +1,84 @@
-- Стартовые тексты сообщений (сид для message_templates).
-- Сгенерировано из common.texts.TEMPLATES; содержимое сверяет tests/test_seed_templates.py.
INSERT INTO bot.message_templates (key, text, description)
VALUES
('welcome.text', $tpl$💠{username}, добро пожаловать в Dispel Evil!
❗️Правила клана
<i>Незнание не освобождает от ответственности.
1. <u><b>Конфликты/разборки</b></u>: сами решаете между собой в лс. Админ имеет право дать мут участникам конфликта до его решения, потом пишите в лс, что решили вопрос и мут снимут. <b>Если замечены одни и те же люди в конфликте три раза - вас исключат из клана.</b>
2. <u><b>Слушать администрацию</b></u>: если кто-то из админов просит закрыть тему - вы её закрываете, если вам делают замечание - не агрессировать и отвечать спокойно. Со стороны админов не будет претензий на пустом месте.
3. <u><b>Маты в меру</b></u>. Если используйте маты в игре - на вашей совести, по правилам игры они запрещены, за последствия отвечать будете сами.</i> $tpl$, 'Приветствие, часть 1'),
('welcome.text2', $tpl$<i>4. <u><b>Сомнительные темы</b></u>: не рекомендуем заводить разговоры про политику/национальность/религию и другие спорные темы создающие конфликты. В противном случае вам могут выдать мут.
5. <u><b>Места на благо</b></u> - становитесь на любое свободное место, не выделяйте «своё личное место». <u><b>В съёмочный день оставьте 3е место для админа</b></u>. </i>
<u>Уважительная просьба</u>: если вам не приятен человек, решайте вопросы в лс. Пожалуйста, не выливайте это в общие чаты, чтобы сохранить приятную атмосферу для остальных.
<b>💠Ниже кнопка на наш основной канал со всеми новостями, конкурсами и темы для блага.</b>$tpl$, 'Приветствие, часть 2'),
('welcome.text3', $tpl$❗️<b>По игре</b>:
💠Очки командного боя 20000+;
💠Вклад 1000+;
💠АФК 3 дня без предупреждения = Кик;
💠Чистка от бездельников по воскресеньям;
🕹Если появляются вопросы, Вы всегда можете написать админам: @Sad_Ju, @yaki_ra, @irinka87364
💡Также мы всегда ждём людей с новыми идеями и предложениями по развитию клана!
💠https://t.me/c/1800439573/397
В комментариях под постом, мы ждём твоего сообщения! Там же ты можешь посмотреть анкеты других участников и найти новых друзей!$tpl$, 'Приветствие, часть 3'),
('info.profile', $tpl$<b>👤Информация о пользователе {username} </b>
<b>✅ Ник в игре:</b> {nick}
<b>🏆 Звание:</b> {title}
<b>🧩 Уровень:</b> {lvl}
<b>🥪 Опыт:</b> {exp}/{exp_max}
<b>💰 Деньги:</b> {money}
<b>📈 Начало активности в боте:</b> {dt}
<b>📊 Количество сообщений в чате:</b> {msg_count}
<b>🥚 Количество яичек:</b> {eggs}
$tpl$, 'Профиль (/инфо)'),
('info.help', $tpl$<b><u>Список команд бота</u></b>
<b>/ник <i>НИК</i></b> - указать свой ник в игре <u>[чат, бот]</u>
<b>/список</b> - вывести список ников <u>[бот]</u>
<b>/кто</b> - вывод ника человека в ответ на сообщение <u>[чат]</u>
<b>/инфо</b> - вывод информации о себе <u>[чат, бот]</u>
<b>/очищение</b> - получение бонусов раз в сутки <u>[чат, бот]</u>
<b>/магазин</b> - магазин бота <u>[бот]</u>
<b>/рейтинг</b> - топ-10 игроков по сообщениям <u>[чат, бот]</u>
<b>/отписка</b> - отписаться от рассылок <u>[чат, бот]</u>
<b>/подписка</b> - подписаться на рассылки <u>[чат, бот]</u>
$tpl$, 'Справка (/помощь)'),
('shop.main', $tpl$Разделы магазина$tpl$, 'Магазин: главное меню'),
('shop.title', $tpl$Доступные звания для покупки$tpl$, 'Магазин: звания'),
('shop.donat', $tpl$Доступные донаты для покупки$tpl$, 'Магазин: донаты'),
('economy.nick_list', $tpl$<b><u>📑 Список игроков[{users_list_count}] 📑</u></b>
{users_list}$tpl$, 'Список игроков (/список)'),
('economy.top', $tpl$<u><b>⭐️ТОП10 общительных участников⭐️</b></u>:
{tops}$tpl$, 'Топ по сообщениям (/рейтинг)'),
('economy.purification', $tpl$<b>💠Успешное очищение!💠</b>
<b>Получено:</b> 💰{money_add} DE и 🥪{exp_add} exp!$tpl$, 'Успешное очищение'),
('economy.no_purification', $tpl$Очищение возможно не чаще раза в сутки!
Возвращайся через {h}ч {m}м {s}с$tpl$, 'Очищение ещё недоступно'),
('economy.eggs', $tpl$<b>💠Держи свои яички!💠</b>
<b>Получено</b> {eggs}🥚!$tpl$, 'Получение яичек'),
('economy.no_eggs', $tpl$Получать яички возможно не чаще раза в сутки!
Возвращайся через {h}ч {m}м {s}с$tpl$, 'Яички ещё недоступны'),
('notify.blago_30', $tpl$ ⏰ <b>Просыпаемся, 30 минут до блага!</b> ⏰
{usernames}$tpl$, 'Рассылка за 30 минут'),
('notify.blago_45', $tpl$ 🔔 <b>15 минут до блага, ждём всех на базе!</b> 🔔
{usernames}$tpl$, 'Рассылка за 15 минут'),
('notify.blago_55', $tpl$ ☀️ <b>5 МИНУТ ДО БЛАГА! НАРЯЖАЕМ ПОПКИ!</b> ☀️
{usernames}$tpl$, 'Рассылка за 5 минут'),
('notify.blago', $tpl$💃 <b>АКТИВНО ТРЯСЁМ ПОПКАМИ НА БАЗЕ!</b>🕺
{usernames}$tpl$, 'Начало благо-часа'),
('notify.vklad', $tpl$<b>💰 НЕ ЗАБЫВАЕМ ПРОВЕРИТЬ ВКЛАД 💰</b>
{usernames}$tpl$, 'Напоминание о вкладе')
ON CONFLICT (key) DO NOTHING;

View File

@ -1,16 +1,30 @@
"""Тесты каркаса админки: healthcheck и главная страница (без БД и сети)."""
"""Тесты админки: каркас, раздел «Сообщения» (без БД — репозиторий подменяется)."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from fastapi.testclient import TestClient
from admin.app import create_app
from common.config import AdminSettings
from common.repositories import templates_repo
TEMPLATE: dict[str, Any] = {
"key": "info.help",
"text": "Справка по командам",
"description": "Справка (/помощь)",
"enabled": True,
"updated_at": datetime(2026, 1, 1, 12, 0, tzinfo=UTC),
"updated_by": None,
}
def _client() -> TestClient:
settings = AdminSettings(_env_file=None, bot_token="", db_password="test", app_env="dev")
return TestClient(create_app(settings))
return TestClient(create_app(settings, init_db=False))
def test_healthz() -> None:
@ -28,3 +42,97 @@ def test_index_renders_sections() -> None:
assert response.status_code == 200
assert "Панель администрирования" in response.text
assert "Расписания" in response.text
def test_messages_list(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_list() -> list[dict[str, Any]]:
return [{**TEMPLATE, "text_length": len(TEMPLATE["text"])}]
monkeypatch.setattr(templates_repo, "list_templates", fake_list)
response = _client().get("/messages")
assert response.status_code == 200
assert "info.help" in response.text
assert "Справка (/помощь)" in response.text
def test_message_editor_and_history(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get(key: str) -> dict[str, Any] | None:
return TEMPLATE if key == TEMPLATE["key"] else None
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
return []
monkeypatch.setattr(templates_repo, "get_template", fake_get)
monkeypatch.setattr(templates_repo, "template_history", fake_history)
response = _client().get("/messages/info.help")
assert response.status_code == 200
assert "Справка по командам" in response.text
assert "Доступные теги" in response.text
def test_message_editor_404(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get(key: str) -> dict[str, Any] | None:
return None
monkeypatch.setattr(templates_repo, "get_template", fake_get)
assert _client().get("/messages/unknown").status_code == 404
def test_preview_renders_tags() -> None:
response = _client().post("/messages/preview", data={"text": "Привет, {nick}!"})
assert response.status_code == 200
assert "ТестовыйНик" in response.text
assert "<iframe" in response.text
def test_preview_reports_unknown_tag() -> None:
response = _client().post("/messages/preview", data={"text": "Привет, {unknown_tag}!"})
assert response.status_code == 200
assert "Неизвестные теги" in response.text
def test_save_rejects_unknown_tag(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get(key: str) -> dict[str, Any] | None:
return TEMPLATE
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
return []
monkeypatch.setattr(templates_repo, "get_template", fake_get)
monkeypatch.setattr(templates_repo, "template_history", fake_history)
response = _client().post("/messages/info.help", data={"text": "текст {unknown_tag}"})
assert response.status_code == 400
assert "Неизвестные теги" in response.text
def test_save_updates_template(monkeypatch: pytest.MonkeyPatch) -> None:
saved: dict[str, Any] = {}
async def fake_get(key: str) -> dict[str, Any] | None:
return TEMPLATE
async def fake_history(key: str, *, limit: int = 10) -> list[dict[str, Any]]:
return []
async def fake_update(key: str, text: str, *, editor_id: int | None = None) -> dict[str, Any]:
saved.update(key=key, text=text)
return {"key": key, "text": text, "old_text": TEMPLATE["text"], "changed": True}
monkeypatch.setattr(templates_repo, "get_template", fake_get)
monkeypatch.setattr(templates_repo, "template_history", fake_history)
monkeypatch.setattr(templates_repo, "update_template", fake_update)
response = _client().post("/messages/info.help", data={"text": "Новая справка"})
assert response.status_code == 200
assert "Текст сохранён" in response.text
assert saved == {"key": "info.help", "text": "Новая справка"}

View File

@ -0,0 +1,36 @@
"""Тесты сида шаблонов и контекста предпросмотра (без БД)."""
from __future__ import annotations
import re
from pathlib import Path
from admin.preview import SAMPLE_CONTEXT
from common.texts import TEMPLATES
from common.texts.render import SUPPORTED_TAGS, validate
SEED_FILE = Path(__file__).resolve().parents[1] / "sql" / "V15__seed_message_templates.sql"
_ROW_RE = re.compile(r"\('([^']+)', \$tpl\$(.*?)\$tpl\$", re.DOTALL)
_TAG_RE = re.compile(r"\{([a-z_][a-z0-9_]*)\}")
def test_seed_matches_code_templates() -> None:
"""Миграция-сид не должна расходиться с реестром TEMPLATES."""
seeded = dict(_ROW_RE.findall(SEED_FILE.read_text(encoding="utf-8")))
assert seeded == TEMPLATES
def test_templates_have_known_tags_only() -> None:
for key, text in TEMPLATES.items():
assert validate(text) == [], f"неизвестные теги в {key}"
def test_preview_context_covers_all_tags() -> None:
"""В предпросмотре у каждого тега должно быть тестовое значение."""
assert set(SAMPLE_CONTEXT) == set(SUPPORTED_TAGS)
used: set[str] = set()
for text in TEMPLATES.values():
used.update(_TAG_RE.findall(text))
assert used <= set(SAMPLE_CONTEXT)

View File

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