From 6e3209a3397c92f92ac4e543c611b8e7412b192d Mon Sep 17 00:00:00 2001
From: MpakobecEx
Date: Mon, 19 Oct 2026 03:34:41 +0500
Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B0=20?=
=?UTF-8?q?=D0=BD=D0=B0=D1=83=D1=87=D0=B8=D0=BB=D0=B0=D1=81=D1=8C=20=D0=BF?=
=?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B8?=
=?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D1=82=D0=B5?=
=?UTF-8?q?=D0=BA=D1=81=D1=82=D1=8B=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?=
=?UTF-8?q?=D0=BD=D0=B8=D0=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Появились таблицы шаблонов, истории правок и расписаний: тексты по умолчанию переехали в миграцию-сид, а раздел «Сообщения» показывает список, даёт отредактировать текст с проверкой тегов и предпросмотром через тот же рендерер, что у бота. Каждое сохранение пишет старую и новую версию в историю — это аудит и заготовка для отката. Бот пока читает тексты из кода: переключение на базу — следующий шаг (4.5).
---
README.md | 11 ++-
admin/app.py | 20 +++-
admin/preview.py | 37 ++++++++
admin/routes/messages.py | 128 ++++++++++++++++++++++++++
admin/routes/pages.py | 28 ++++--
admin/templates/base.html | 6 +-
admin/templates/index.html | 10 +-
admin/templates/messages/edit.html | 101 ++++++++++++++++++++
admin/templates/messages/list.html | 45 +++++++++
common/db/pool.py | 11 ++-
common/repositories/templates_repo.py | 89 ++++++++++++++++++
common/version.py | 2 +-
sql/V14__admin_tables.sql | 63 +++++++++++++
sql/V15__seed_message_templates.sql | 84 +++++++++++++++++
tests/test_admin.py | 112 +++++++++++++++++++++-
tests/test_seed_templates.py | 36 ++++++++
tests/test_version.py | 2 +-
17 files changed, 763 insertions(+), 22 deletions(-)
create mode 100644 admin/preview.py
create mode 100644 admin/routes/messages.py
create mode 100644 admin/templates/messages/edit.html
create mode 100644 admin/templates/messages/list.html
create mode 100644 common/repositories/templates_repo.py
create mode 100644 sql/V14__admin_tables.sql
create mode 100644 sql/V15__seed_message_templates.sql
create mode 100644 tests/test_seed_templates.py
diff --git a/README.md b/README.md
index 0f3a42a..facf34b 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/admin/app.py b/admin/app.py
index 89378ab..c4bed2c 100644
--- a/admin/app.py
+++ b/admin/app.py
@@ -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
diff --git a/admin/preview.py b/admin/preview.py
new file mode 100644
index 0000000..2feef6a
--- /dev/null
+++ b/admin/preview.py
@@ -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м",
+}
diff --git a/admin/routes/messages.py b/admin/routes/messages.py
new file mode 100644
index 0000000..2ad7550
--- /dev/null
+++ b/admin/routes/messages.py
@@ -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(
+ 'Неизвестные теги: ' + html.escape(", ".join(unknown)) + "
"
+ )
+
+ try:
+ rendered = render(text, SAMPLE_CONTEXT)
+ except TemplateError as exc: # pragma: no cover - validate уже отсекает неизвестные теги
+ return HTMLResponse(f'{html.escape(str(exc))}
')
+
+ if len(rendered) > MAX_MESSAGE_LENGTH:
+ return HTMLResponse(
+ f'Текст длиннее лимита Telegram: '
+ f"{len(rendered)} из {MAX_MESSAGE_LENGTH} символов.
"
+ )
+
+ return HTMLResponse(
+ ''
+ )
+
+
+@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),
+ )
diff --git a/admin/routes/pages.py b/admin/routes/pages.py
index 9928a36..552f727 100644
--- a/admin/routes/pages.py
+++ b/admin/routes/pages.py
@@ -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,
+ },
)
diff --git a/admin/templates/base.html b/admin/templates/base.html
index 5f68b25..9f43ae1 100644
--- a/admin/templates/base.html
+++ b/admin/templates/base.html
@@ -11,7 +11,11 @@
diff --git a/admin/templates/index.html b/admin/templates/index.html
index c794ee7..740970f 100644
--- a/admin/templates/index.html
+++ b/admin/templates/index.html
@@ -10,13 +10,15 @@
- {% for key, title, description in sections %}
+ {% for section in sections %}
-
{{ title }}
-
скоро
+
+ {% if section.url %}{{ section.title }}{% else %}{{ section.title }}{% endif %}
+
+ {% if not section.url %}
скоро{% endif %}
- {{ description }}
+ {{ section.description }}
{% endfor %}
diff --git a/admin/templates/messages/edit.html b/admin/templates/messages/edit.html
new file mode 100644
index 0000000..92cceb5
--- /dev/null
+++ b/admin/templates/messages/edit.html
@@ -0,0 +1,101 @@
+{% extends "base.html" %}
+
+{% block title %}{{ template.key }}{% endblock %}
+
+{% block content %}
+
+
+
← Сообщения
+
{{ template.key }}
+
{{ template.description }}
+
+
+
+{% if saved %}
+
+ Текст сохранён.
+
+{% endif %}
+
+{% if errors %}
+
+ {% for error in errors %}- {{ error }}
{% endfor %}
+
+{% endif %}
+
+
+
+
+
Предпросмотр
+
Тестовые значения: @test_user, Ветеран, 3 уровень, 1500 DE.
+
+
Нажмите «Предпросмотр», чтобы увидеть текст с подставленными тегами.
+
+
+
+
+ Доступные теги ({{ tags | length }})
+
+ {% for name, description in tags.items() %}
+
+
- { {{ name }} }
+ - {{ description }}
+
+ {% endfor %}
+
+
+
+
+
История изменений
+ {% if history %}
+
+ {% else %}
+
Изменений ещё не было.
+ {% endif %}
+
+{% endblock %}
diff --git a/admin/templates/messages/list.html b/admin/templates/messages/list.html
new file mode 100644
index 0000000..29b7c26
--- /dev/null
+++ b/admin/templates/messages/list.html
@@ -0,0 +1,45 @@
+{% extends "base.html" %}
+
+{% block title %}Сообщения{% endblock %}
+
+{% block content %}
+
+
Сообщения
+ {{ templates | length }} шаблонов
+
+
+
+ Тексты хранятся в Postgres. В редакторе можно проверить подстановку тегов
+ {tag} на тестовых данных.
+
+
+
+ Правки пока не влияют на бота: он читает тексты из кода. Подключение шаблонов из БД —
+ следующий шаг Блока 4 (4.5).
+
+
+
+
+
+
+ | Ключ |
+ Описание |
+ Длина |
+ Изменён |
+
+
+
+ {% for template in templates %}
+
+ |
+ {{ template.key }}
+ |
+ {{ template.description }} |
+ {{ template.text_length }} |
+ {{ template.updated_at.strftime("%d.%m.%Y %H:%M") }} |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/common/db/pool.py b/common/db/pool.py
index b015463..eebee34 100644
--- a/common/db/pool.py
+++ b/common/db/pool.py
@@ -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,
diff --git a/common/repositories/templates_repo.py b/common/repositories/templates_repo.py
new file mode 100644
index 0000000..3b97a71
--- /dev/null
+++ b/common/repositories/templates_repo.py
@@ -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()
diff --git a/common/version.py b/common/version.py
index e72b0e7..4a60f38 100644
--- a/common/version.py
+++ b/common/version.py
@@ -7,4 +7,4 @@
from __future__ import annotations
-__version__ = "0.4.0"
+__version__ = "0.4.1"
diff --git a/sql/V14__admin_tables.sql b/sql/V14__admin_tables.sql
new file mode 100644
index 0000000..532fc69
--- /dev/null
+++ b/sql/V14__admin_tables.sql
@@ -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 'История изменений расписаний (аудит)';
diff --git a/sql/V15__seed_message_templates.sql b/sql/V15__seed_message_templates.sql
new file mode 100644
index 0000000..877a732
--- /dev/null
+++ b/sql/V15__seed_message_templates.sql
@@ -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!
+❗️Правила клана
+
+Незнание не освобождает от ответственности.
+
+1. Конфликты/разборки: сами решаете между собой в лс. Админ имеет право дать мут участникам конфликта до его решения, потом пишите в лс, что решили вопрос и мут снимут. Если замечены одни и те же люди в конфликте три раза - вас исключат из клана.
+
+2. Слушать администрацию: если кто-то из админов просит закрыть тему - вы её закрываете, если вам делают замечание - не агрессировать и отвечать спокойно. Со стороны админов не будет претензий на пустом месте.
+
+3. Маты в меру. Если используйте маты в игре - на вашей совести, по правилам игры они запрещены, за последствия отвечать будете сами. $tpl$, 'Приветствие, часть 1'),
+ ('welcome.text2', $tpl$4. Сомнительные темы: не рекомендуем заводить разговоры про политику/национальность/религию и другие спорные темы создающие конфликты. В противном случае вам могут выдать мут.
+
+5. Места на благо - становитесь на любое свободное место, не выделяйте «своё личное место». В съёмочный день оставьте 3е место для админа.
+
+Уважительная просьба: если вам не приятен человек, решайте вопросы в лс. Пожалуйста, не выливайте это в общие чаты, чтобы сохранить приятную атмосферу для остальных.
+
+💠Ниже кнопка на наш основной канал со всеми новостями, конкурсами и темы для блага.$tpl$, 'Приветствие, часть 2'),
+ ('welcome.text3', $tpl$❗️По игре:
+
+💠Очки командного боя 20000+;
+💠Вклад 1000+;
+💠АФК 3 дня без предупреждения = Кик;
+💠Чистка от бездельников по воскресеньям;
+
+🕹Если появляются вопросы, Вы всегда можете написать админам: @Sad_Ju, @yaki_ra, @irinka87364
+
+💡Также мы всегда ждём людей с новыми идеями и предложениями по развитию клана!
+
+💠https://t.me/c/1800439573/397
+В комментариях под постом, мы ждём твоего сообщения! Там же ты можешь посмотреть анкеты других участников и найти новых друзей!$tpl$, 'Приветствие, часть 3'),
+ ('info.profile', $tpl$👤Информация о пользователе {username}
+
+✅ Ник в игре: {nick}
+🏆 Звание: {title}
+🧩 Уровень: {lvl}
+🥪 Опыт: {exp}/{exp_max}
+💰 Деньги: {money}
+📈 Начало активности в боте: {dt}
+📊 Количество сообщений в чате: {msg_count}
+🥚 Количество яичек: {eggs}
+$tpl$, 'Профиль (/инфо)'),
+ ('info.help', $tpl$Список команд бота
+
+/ник НИК - указать свой ник в игре [чат, бот]
+/список - вывести список ников [бот]
+/кто - вывод ника человека в ответ на сообщение [чат]
+/инфо - вывод информации о себе [чат, бот]
+/очищение - получение бонусов раз в сутки [чат, бот]
+/магазин - магазин бота [бот]
+/рейтинг - топ-10 игроков по сообщениям [чат, бот]
+/отписка - отписаться от рассылок [чат, бот]
+/подписка - подписаться на рассылки [чат, бот]
+$tpl$, 'Справка (/помощь)'),
+ ('shop.main', $tpl$Разделы магазина$tpl$, 'Магазин: главное меню'),
+ ('shop.title', $tpl$Доступные звания для покупки$tpl$, 'Магазин: звания'),
+ ('shop.donat', $tpl$Доступные донаты для покупки$tpl$, 'Магазин: донаты'),
+ ('economy.nick_list', $tpl$📑 Список игроков[{users_list_count}] 📑
+
+{users_list}$tpl$, 'Список игроков (/список)'),
+ ('economy.top', $tpl$⭐️ТОП10 общительных участников⭐️:
+{tops}$tpl$, 'Топ по сообщениям (/рейтинг)'),
+ ('economy.purification', $tpl$💠Успешное очищение!💠
+Получено: 💰{money_add} DE и 🥪{exp_add} exp!$tpl$, 'Успешное очищение'),
+ ('economy.no_purification', $tpl$Очищение возможно не чаще раза в сутки!
+Возвращайся через {h}ч {m}м {s}с$tpl$, 'Очищение ещё недоступно'),
+ ('economy.eggs', $tpl$💠Держи свои яички!💠
+Получено {eggs}🥚!$tpl$, 'Получение яичек'),
+ ('economy.no_eggs', $tpl$Получать яички возможно не чаще раза в сутки!
+Возвращайся через {h}ч {m}м {s}с$tpl$, 'Яички ещё недоступны'),
+ ('notify.blago_30', $tpl$ ⏰ Просыпаемся, 30 минут до блага! ⏰
+{usernames}$tpl$, 'Рассылка за 30 минут'),
+ ('notify.blago_45', $tpl$ 🔔 15 минут до блага, ждём всех на базе! 🔔
+{usernames}$tpl$, 'Рассылка за 15 минут'),
+ ('notify.blago_55', $tpl$ ☀️ 5 МИНУТ ДО БЛАГА! НАРЯЖАЕМ ПОПКИ! ☀️
+{usernames}$tpl$, 'Рассылка за 5 минут'),
+ ('notify.blago', $tpl$💃 АКТИВНО ТРЯСЁМ ПОПКАМИ НА БАЗЕ!🕺
+{usernames}$tpl$, 'Начало благо-часа'),
+ ('notify.vklad', $tpl$💰 НЕ ЗАБЫВАЕМ ПРОВЕРИТЬ ВКЛАД 💰
+{usernames}$tpl$, 'Напоминание о вкладе')
+ON CONFLICT (key) DO NOTHING;
diff --git a/tests/test_admin.py b/tests/test_admin.py
index d462363..ed8ada4 100644
--- a/tests/test_admin.py
+++ b/tests/test_admin.py
@@ -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 "