Раздел «Последовательности» стал «Шаблонами сообщений», а счётчик сообщений починен
В списке колонка «Сообщений» показывала метод словаря вместо числа: SQL-алиас items переименован в item_count, и шаблон больше не натыкается на dict.items. Заодно в интерфейсе и README раздел называется «Шаблоны сообщений», а одинаковое название и описание больше не дублируются в списке.
This commit is contained in:
parent
3347dacbb5
commit
5c6c49685a
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "0.6.1"
|
__version__ = "0.6.2"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue