Админка научилась показывать и править тексты сообщений
Появились таблицы шаблонов, истории правок и расписаний: тексты по умолчанию переехали в миграцию-сид, а раздел «Сообщения» показывает список, даёт отредактировать текст с проверкой тегов и предпросмотром через тот же рендерер, что у бота. Каждое сохранение пишет старую и новую версию в историю — это аудит и заготовка для отката. Бот пока читает тексты из кода: переключение на базу — следующий шаг (4.5).
This commit is contained in:
parent
b11676764e
commit
6e3209a339
11
README.md
11
README.md
|
|
@ -52,6 +52,15 @@ APP_ENV=prod python -m bot # prod
|
||||||
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
|
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
|
||||||
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
|
При 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)
|
||||||
|
|
||||||
Все значимые действия бота пишутся в ClickHouse в единую таблицу `analytics.events`.
|
Все значимые действия бота пишутся в 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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
||||||
|
|
|
||||||
20
admin/app.py
20
admin/app.py
|
|
@ -2,28 +2,42 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from admin.routes import health, pages
|
from admin.routes import health, messages, pages
|
||||||
from common import __version__
|
from common import __version__
|
||||||
from common.config import AdminSettings, get_admin_settings
|
from common.config import AdminSettings, get_admin_settings
|
||||||
|
from common.db.pool import close_pool, init_pool
|
||||||
from common.logging_config import setup_logging
|
from common.logging_config import setup_logging
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
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()
|
setup_logging()
|
||||||
settings = settings or get_admin_settings()
|
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(
|
app = FastAPI(
|
||||||
title="DispelEvilBot Admin",
|
title="DispelEvilBot Admin",
|
||||||
version=__version__,
|
version=__version__,
|
||||||
|
|
@ -31,10 +45,12 @@ def create_app(settings: AdminSettings | None = None) -> FastAPI:
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
app.include_router(pages.router)
|
app.include_router(pages.router)
|
||||||
|
app.include_router(messages.router)
|
||||||
return app
|
return app
|
||||||
|
|
|
||||||
|
|
@ -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м",
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -9,12 +9,28 @@ from common import __version__
|
||||||
|
|
||||||
router = APIRouter(tags=["pages"])
|
router = APIRouter(tags=["pages"])
|
||||||
|
|
||||||
#: Разделы админки по плану Блока 4 (пока заглушки на главной).
|
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
|
||||||
SECTIONS: tuple[tuple[str, str, str], ...] = (
|
SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
("messages", "Сообщения", "Тексты бота и предпросмотр с тегами {tag}"),
|
{
|
||||||
("schedules", "Расписания", "Время рассылок, дни недели и шаблон"),
|
"title": "Сообщения",
|
||||||
("users", "Пользователи", "Ники, звания, баланс и подписка"),
|
"description": "Тексты бота и предпросмотр с тегами {tag}",
|
||||||
("logs", "Логи", "События из ClickHouse с фильтрами"),
|
"url": "/messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Расписания",
|
||||||
|
"description": "Время рассылок, дни недели и шаблон",
|
||||||
|
"url": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Пользователи",
|
||||||
|
"description": "Ники, звания, баланс и подписка",
|
||||||
|
"url": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Логи",
|
||||||
|
"description": "События из ClickHouse с фильтрами",
|
||||||
|
"url": None,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@
|
||||||
<header class="border-b border-slate-200 bg-white">
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,15 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
<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">
|
<section class="rounded-lg border border-slate-200 bg-white p-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-medium">{{ title }}</h2>
|
<h2 class="text-lg font-medium">
|
||||||
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">скоро</span>
|
{% 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>
|
</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>
|
</section>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -11,20 +11,23 @@ import logging
|
||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg_pool import AsyncConnectionPool
|
from psycopg_pool import AsyncConnectionPool
|
||||||
|
|
||||||
from common.config import get_settings
|
from common.config import Settings, get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_pool: AsyncConnectionPool | None = None
|
_pool: AsyncConnectionPool | None = None
|
||||||
|
|
||||||
|
|
||||||
async def init_pool() -> AsyncConnectionPool:
|
async def init_pool(settings: Settings | None = None) -> AsyncConnectionPool:
|
||||||
"""Создать и открыть пул. Идемпотентно."""
|
"""Создать и открыть пул. Идемпотентно.
|
||||||
|
|
||||||
|
``settings`` передаёт админка (свои настройки); по умолчанию — настройки бота.
|
||||||
|
"""
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is not None:
|
if _pool is not None:
|
||||||
return _pool
|
return _pool
|
||||||
|
|
||||||
settings = get_settings()
|
settings = settings or get_settings()
|
||||||
_pool = AsyncConnectionPool(
|
_pool = AsyncConnectionPool(
|
||||||
conninfo=settings.dsn,
|
conninfo=settings.dsn,
|
||||||
min_size=settings.db_pool_min,
|
min_size=settings.db_pool_min,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.4.0"
|
__version__ = "0.4.1"
|
||||||
|
|
|
||||||
|
|
@ -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 'История изменений расписаний (аудит)';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,16 +1,30 @@
|
||||||
"""Тесты каркаса админки: healthcheck и главная страница (без БД и сети)."""
|
"""Тесты админки: каркас, раздел «Сообщения» (без БД — репозиторий подменяется)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from admin.app import create_app
|
from admin.app import create_app
|
||||||
from common.config import AdminSettings
|
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:
|
def _client() -> TestClient:
|
||||||
settings = AdminSettings(_env_file=None, bot_token="", db_password="test", app_env="dev")
|
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:
|
def test_healthz() -> None:
|
||||||
|
|
@ -28,3 +42,97 @@ def test_index_renders_sections() -> None:
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Панель администрирования" in response.text
|
assert "Панель администрирования" in response.text
|
||||||
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": "Новая справка"}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -7,4 +7,4 @@ from common import __version__
|
||||||
|
|
||||||
def test_version_is_set():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.4.0"
|
assert __version__ == "0.4.1"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue