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 @@
DispelEvilBot - админка · v{{ version }} +
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 %} + +{% endif %} + +
+
+ + +

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

+
+ +
+ + +
+
+ +
+

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

+

Тестовые значения: @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 %} + + + + + + + {% endfor %} + +
КлючОписаниеДлинаИзменён
+ {{ template.key }} + {{ template.description }}{{ template.text_length }}{{ template.updated_at.strftime("%d.%m.%Y %H:%M") }}
+
+{% 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 " 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": "Новая справка"} diff --git a/tests/test_seed_templates.py b/tests/test_seed_templates.py new file mode 100644 index 0000000..cfdc782 --- /dev/null +++ b/tests/test_seed_templates.py @@ -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) diff --git a/tests/test_version.py b/tests/test_version.py index ddac615..ba9616f 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.4.0" + assert __version__ == "0.4.1"