Раздел «Последовательности» стал «Шаблонами сообщений», а счётчик сообщений починен
CI / lint-test (push) Successful in 19s Details
CI / build-push (push) Successful in 21s Details

В списке колонка «Сообщений» показывала метод словаря вместо числа: SQL-алиас items переименован в item_count, и шаблон больше не натыкается на dict.items. Заодно в интерфейсе и README раздел называется «Шаблоны сообщений», а одинаковое название и описание больше не дублируются в списке.
This commit is contained in:
MpakobecEx 2026-10-25 22:32:37 +05:00
parent 3347dacbb5
commit 5c6c49685a
10 changed files with 37 additions and 29 deletions

View File

@ -55,7 +55,7 @@ APP_ENV=prod python -m bot # prod
## Админка (Блок 4) ## Админка (Блок 4)
Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`. Отдельный сервис `admin/` (FastAPI + Jinja2/HTMX), запуск `python -m admin`.
Все тексты бота — **последовательности** (`bot.message_sequences`): миграция `V24` Все тексты бота — **шаблоны сообщений** (`bot.message_sequences`): миграция `V24`
переносит каждый старый шаблон в последовательность из одного сообщения, а бот переносит каждый старый шаблон в последовательность из одного сообщения, а бот
отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде. отправляет их через общий sender. Старые `message_templates` остаются фолбэком в коде.
Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот. Правки применяются без перезапуска; предпросмотр использует тот же `render()`, что и бот.
@ -96,7 +96,7 @@ ID пользователя ведёт в его карточку. ClickHouse ч
уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам уведомлением «кто кого выгнал»; историю изменений показывает, кто менял ник — сам
пользователь через `/ник` или админ. пользователь через `/ник` или админ.
Раздел «Последовательности» — набор сообщений по порядку (приветствие и любые другие): Раздел «Шаблоны сообщений» — набор сообщений по порядку (приветствие и любые другие):
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок. у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание, Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
@ -137,7 +137,7 @@ docker compose up --build
CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose
по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 / по умолчанию публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`, test 8100 /
prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет prod 8101); для nginx-proxy-manager задаётся `ADMIN_BIND_IP=172.17.0.1`, наружу выйдет
вместе с доменом и TLS (Блок 4.2). Картинки последовательностей лежат в общем вместе с доменом и TLS (Блок 4.2). Картинки шаблонов лежат в общем
Docker-volume `media` (`/app/media`), который монтируется и боту, и админке. Docker-volume `media` (`/app/media`), который монтируется и боту, и админке.
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом: Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
@ -171,7 +171,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование ## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.1`. - Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.2`.
- Схема — 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`.

View File

@ -12,8 +12,8 @@ router = APIRouter(tags=["pages"])
#: Разделы админки по плану Блока 4 (url — None, пока раздел не готов). #: Разделы админки по плану Блока 4 (url — None, пока раздел не готов).
SECTIONS: tuple[dict[str, str | None], ...] = ( SECTIONS: tuple[dict[str, str | None], ...] = (
{ {
"title": "Последовательности", "title": "Шаблоны сообщений",
"description": "Приветствие и другие наборы сообщений по порядку", "description": "Тексты бота: сообщения, картинки и кнопки",
"url": "/sequences", "url": "/sequences",
}, },
{ {

View File

@ -91,7 +91,7 @@ async def _editor_response(
) -> HTMLResponse: ) -> HTMLResponse:
sequence = await sequences_repo.get_sequence(key) sequence = await sequences_repo.get_sequence(key)
if sequence is None: if sequence is None:
raise HTTPException(status_code=404, detail="Последовательность не найдена") raise HTTPException(status_code=404, detail="Шаблон не найден")
items = await sequences_repo.get_items(key) items = await sequences_repo.get_items(key)
_prepare_items(items) _prepare_items(items)
@ -151,7 +151,7 @@ async def create_sequence(request: Request) -> Response:
): ):
return await _list_response( return await _list_response(
request, request,
errors=["Последовательность с таким ключом уже существует"], errors=["Шаблон с таким ключом уже существует"],
form=raw_form, form=raw_form,
status_code=400, status_code=400,
) )
@ -188,7 +188,7 @@ async def update_sequence(request: Request, key: str) -> Response:
description, description,
editor_id=_editor_id(request), editor_id=_editor_id(request),
): ):
raise HTTPException(status_code=404, detail="Последовательность не найдена") raise HTTPException(status_code=404, detail="Шаблон не найден")
return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303) return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303)
@ -198,7 +198,7 @@ async def add_item(request: Request, key: str) -> Response:
check_csrf(request, str(form.get("csrf_token", ""))) check_csrf(request, str(form.get("csrf_token", "")))
if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None: if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Последовательность не найдена") raise HTTPException(status_code=404, detail="Шаблон не найден")
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303) return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
@ -210,7 +210,7 @@ async def update_item(request: Request, key: str, item_id: int) -> Response:
items = await sequences_repo.get_items(key) items = await sequences_repo.get_items(key)
item = next((row for row in items if int(row["id"]) == item_id), None) item = next((row for row in items if int(row["id"]) == item_id), None)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Сообщение не найдено") raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
text = str(form.get("text", "")) text = str(form.get("text", ""))
enabled = form.get("enabled") is not None enabled = form.get("enabled") is not None
@ -274,7 +274,7 @@ async def update_item(request: Request, key: str, item_id: int) -> Response:
if updated is None: if updated is None:
if saved_file: if saved_file:
_delete_image(settings, saved_file) _delete_image(settings, saved_file)
raise HTTPException(status_code=404, detail="Сообщение не найдено") raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
_, old_image = updated _, old_image = updated
if update_image and old_image and old_image != new_image: if update_image and old_image and old_image != new_image:
@ -292,7 +292,7 @@ async def move_item(request: Request, key: str, item_id: int) -> Response:
if direction not in {"up", "down"}: if direction not in {"up", "down"}:
raise HTTPException(status_code=400, detail="Неверное направление") raise HTTPException(status_code=400, detail="Неверное направление")
if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None: if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Сообщение не найдено") raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303) return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303)
@ -304,10 +304,10 @@ async def delete_item(request: Request, key: str, item_id: int) -> Response:
items = await sequences_repo.get_items(key) items = await sequences_repo.get_items(key)
item = next((row for row in items if int(row["id"]) == item_id), None) item = next((row for row in items if int(row["id"]) == item_id), None)
if item is None: if item is None:
raise HTTPException(status_code=404, detail="Сообщение не найдено") raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None: if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Сообщение не найдено") raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
_delete_image(request.app.state.settings, item["image_name"]) _delete_image(request.app.state.settings, item["image_name"])
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303) return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)

View File

@ -25,7 +25,7 @@
class="hidden w-full flex-col gap-2 border-t border-slate-100 pt-2 text-sm sm:flex sm:w-auto sm:flex-row sm:items-center sm:gap-4 sm:border-0 sm:pt-0" class="hidden w-full flex-col gap-2 border-t border-slate-100 pt-2 text-sm sm:flex sm:w-auto sm:flex-row sm:items-center sm:gap-4 sm:border-0 sm:pt-0"
> >
<a href="/" class="py-1 text-slate-600 hover:text-slate-900">Главная</a> <a href="/" class="py-1 text-slate-600 hover:text-slate-900">Главная</a>
<a href="/sequences" class="py-1 text-slate-600 hover:text-slate-900">Последовательности</a> <a href="/sequences" class="py-1 text-slate-600 hover:text-slate-900">Шаблоны</a>
<a href="/schedules" class="py-1 text-slate-600 hover:text-slate-900">Расписания</a> <a href="/schedules" class="py-1 text-slate-600 hover:text-slate-900">Расписания</a>
<a href="/users" class="py-1 text-slate-600 hover:text-slate-900">Пользователи</a> <a href="/users" class="py-1 text-slate-600 hover:text-slate-900">Пользователи</a>
<a href="/titles" class="py-1 text-slate-600 hover:text-slate-900">Звания</a> <a href="/titles" class="py-1 text-slate-600 hover:text-slate-900">Звания</a>

View File

@ -3,7 +3,7 @@
{% block title %}{{ sequence.title }}{% endblock %} {% block title %}{{ sequence.title }}{% endblock %}
{% block content %} {% block content %}
<p class="text-sm text-slate-500"><a href="/sequences" class="hover:underline">← Последовательности</a></p> <p class="text-sm text-slate-500"><a href="/sequences" class="hover:underline">← Шаблоны сообщений</a></p>
<div class="mt-1 flex flex-wrap items-center justify-between gap-3"> <div class="mt-1 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 class="text-2xl font-semibold">{{ sequence.title }}</h1> <h1 class="text-2xl font-semibold">{{ sequence.title }}</h1>
@ -13,7 +13,7 @@
{% if saved %} {% if saved %}
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800"> <div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
{% if saved == 'meta' %}Параметры последовательности сохранены. {% if saved == 'meta' %}Параметры шаблона сохранены.
{% elif saved == 'order' %}Порядок сообщений обновлён. {% elif saved == 'order' %}Порядок сообщений обновлён.
{% else %}Сообщение сохранено.{% endif %} {% else %}Сообщение сохранено.{% endif %}
</div> </div>

View File

@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Последовательности{% endblock %} {% block title %}Шаблоны сообщений{% endblock %}
{% block content %} {% block content %}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-2xl font-semibold">Последовательности</h1> <h1 class="text-2xl font-semibold">Шаблоны сообщений</h1>
<p class="mt-1 text-sm text-slate-600">Несколько сообщений по порядку: текст, картинка и кнопки.</p> <p class="mt-1 text-sm text-slate-600">Тексты бота: сообщения, картинки и кнопки.</p>
</div> </div>
</div> </div>
@ -24,7 +24,7 @@
<form method="post" action="/sequences" class="mt-6 rounded-lg border border-slate-200 bg-white p-4"> <form method="post" action="/sequences" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" /> <input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<h2 class="text-lg font-medium">Новая последовательность</h2> <h2 class="text-lg font-medium">Новый шаблон</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-4"> <div class="mt-3 grid gap-3 sm:grid-cols-4">
<div> <div>
<label for="key" class="text-xs text-slate-500">Ключ</label> <label for="key" class="text-xs text-slate-500">Ключ</label>
@ -56,7 +56,7 @@
<th class="px-4 py-3 font-medium">Ключ</th> <th class="px-4 py-3 font-medium">Ключ</th>
<th class="px-4 py-3 font-medium">Название</th> <th class="px-4 py-3 font-medium">Название</th>
<th class="px-4 py-3 font-medium">Сообщений</th> <th class="px-4 py-3 font-medium">Сообщений</th>
<th class="px-4 py-3 font-medium">Изменена</th> <th class="px-4 py-3 font-medium">Изменён</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-100"> <tbody class="divide-y divide-slate-100">
@ -65,12 +65,17 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/sequences/{{ sequence.key }}" class="font-mono text-sky-700 hover:underline">{{ sequence.key }}</a> <a href="/sequences/{{ sequence.key }}" class="font-mono text-sky-700 hover:underline">{{ sequence.key }}</a>
</td> </td>
<td class="px-4 py-3">{{ sequence.title }}<div class="text-xs text-slate-500">{{ sequence.description }}</div></td> <td class="px-4 py-3">
<td class="px-4 py-3">{{ sequence.items }}</td> {{ sequence.title }}
{% if sequence.description and sequence.description != sequence.title %}
<div class="text-xs text-slate-500">{{ sequence.description }}</div>
{% endif %}
</td>
<td class="px-4 py-3">{{ sequence.item_count }}</td>
<td class="px-4 py-3 text-slate-500">{{ sequence.updated_at.strftime("%d.%m.%Y %H:%M") }}</td> <td class="px-4 py-3 text-slate-500">{{ sequence.updated_at.strftime("%d.%m.%Y %H:%M") }}</td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">Последовательностей пока нет.</td></tr> <tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">Шаблонов пока нет.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -23,7 +23,7 @@ async def list_sequences() -> list[dict[str, Any]]:
cur = await conn.execute( cur = await conn.execute(
""" """
SELECT s.key, s.title, s.description, s.updated_at, s.updated_by, SELECT s.key, s.title, s.description, s.updated_at, s.updated_by,
count(i.id) AS items count(i.id) AS item_count
FROM bot.message_sequences AS s FROM bot.message_sequences AS s
LEFT JOIN bot.message_sequence_items AS i ON i.sequence_key = s.key LEFT JOIN bot.message_sequence_items AS i ON i.sequence_key = s.key
GROUP BY s.key, s.title, s.description, s.updated_at, s.updated_by GROUP BY s.key, s.title, s.description, s.updated_at, s.updated_by

View File

@ -7,4 +7,4 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.6.1" __version__ = "0.6.2"

View File

@ -15,6 +15,7 @@ SEQUENCE: dict[str, Any] = {
"key": "welcome", "key": "welcome",
"title": "Приветствие", "title": "Приветствие",
"description": "Сообщения при входе", "description": "Сообщения при входе",
"item_count": 2,
"updated_at": datetime(2026, 10, 25, tzinfo=UTC), "updated_at": datetime(2026, 10, 25, tzinfo=UTC),
"updated_by": None, "updated_by": None,
} }
@ -132,6 +133,8 @@ def test_sequences_list(fake_sequences: dict[str, Any], client_factory) -> None:
assert response.status_code == 200 assert response.status_code == 200
assert "welcome" in response.text assert "welcome" in response.text
assert "Приветствие" in response.text assert "Приветствие" in response.text
assert "<built-in method" not in response.text
assert "Шаблоны сообщений" in response.text
def test_sequence_editor_has_toolbar(fake_sequences: dict[str, Any], client_factory) -> None: def test_sequence_editor_has_toolbar(fake_sequences: dict[str, Any], client_factory) -> None:

View File

@ -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.6.1" assert __version__ == "0.6.2"