Шаблон сохраняется целиком одной кнопкой
CI / lint-test (push) Successful in 17s Details
CI / build-push (push) Successful in 20s Details

В редакторе шаблона теперь одна форма и одна кнопка «Сохранить всё»: название, описание и все сообщения. Добавление, удаление и перестановка сообщений происходят на странице без запросов к серверу и применяются при сохранении, поэтому несохранённые правки не теряются. Сервер сохраняет весь набор одной транзакцией, включая порядок, кнопки и картинки.
This commit is contained in:
MpakobecEx 2026-10-26 02:48:51 +05:00
parent afeed5b67e
commit 1ab434d8b0
8 changed files with 500 additions and 138 deletions

View File

@ -98,6 +98,8 @@ ID пользователя ведёт в его карточку. ClickHouse ч
Раздел «Шаблоны сообщений» — набор сообщений по порядку (приветствие и любые другие):
у каждого свой текст, картинка из общего volume `media`, вкл/выкл и несколько кнопок.
Все правки — текст, кнопки, картинки, добавление/удаление и порядок сообщений —
хранятся на странице и применяются одной кнопкой «Сохранить всё».
Редактор текста — textarea с тулбаром: жирный, курсив, подчёркивание, зачёркивание,
код, блок кода, цитата, спойлер и ссылка; есть предпросмотр и счётчик символов
(1024 для подписи к фото, 4096 для обычного сообщения).
@ -171,7 +173,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.7`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.8`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -34,8 +34,9 @@ def _editor_id(request: Request) -> int:
def _prepare_items(items: list[dict[str, Any]]) -> None:
"""Добавить к каждому сообщению строку кнопок для textarea."""
"""Добавить к каждому сообщению строку кнопок и токен для формы."""
for item in items:
item.setdefault("token", str(item["id"]))
item["buttons_text"] = "\n".join(f"{button['text']} | {button['url']}" for button in item["buttons"])
@ -87,18 +88,24 @@ async def _editor_response(
errors: list[str] | None = None,
saved: str | None = None,
overrides: dict[int, dict[str, Any]] | None = None,
items_override: list[dict[str, Any]] | None = None,
status_code: int = 200,
) -> HTMLResponse:
sequence = await sequences_repo.get_sequence(key)
if sequence is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
items = await sequences_repo.get_items(key)
if items_override is not None:
items = items_override
else:
items = await sequences_repo.get_items(key)
for item in items:
override = (overrides or {}).get(int(item["id"]))
if override:
item.update(override)
_prepare_items(items)
for item in items:
override = (overrides or {}).get(int(item["id"]))
if override:
item.update(override)
item_order = ",".join(str(item["token"]) for item in items)
return request.app.state.templates.TemplateResponse(
request=request,
@ -107,6 +114,7 @@ async def _editor_response(
"version": __version__,
"sequence": sequence,
"items": items,
"item_order": item_order,
"history": await sequences_repo.history(key, limit=5),
"errors": errors or [],
"saved": saved,
@ -172,6 +180,126 @@ async def edit_sequence(request: Request, key: str, saved: str | None = None) ->
return await _editor_response(request, key, saved=saved)
@router.post("/{key}/save")
async def save_template(request: Request, key: str) -> Response:
"""Сохранить шаблон целиком: параметры и все сообщения одной формой."""
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
sequence = await sequences_repo.get_sequence(key)
if sequence is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
title = str(form.get("title", "")).strip()
description = str(form.get("description", "")).strip()
order = [token for token in str(form.get("item_order", "")).split(",") if token]
existing_items = await sequences_repo.get_items(key)
existing = {int(item["id"]): item for item in existing_items}
errors: list[str] = []
if not title:
errors.append("Название не может быть пустым")
if not order:
errors.append("Добавьте хотя бы одно сообщение")
submitted: list[dict[str, Any]] = []
pending_images: dict[str, tuple[bytes, str]] = {}
for position, token in enumerate(order):
item_id = int(token) if token.isdigit() else None
current = existing.get(item_id) if item_id is not None else None
text = str(form.get(f"text_{token}", ""))
raw_buttons = str(form.get(f"buttons_{token}", ""))
buttons, button_errors = parse_buttons(raw_buttons)
errors.extend(f"Сообщение {position + 1}: {error}" for error in button_errors)
enabled = form.get(f"enabled_{token}") is not None
image_name = current["image_name"] if current else None
has_image = bool(image_name)
upload = form.get(f"image_{token}")
if isinstance(upload, UploadFile) and upload.filename:
try:
data, _, extension = await read_image(upload)
pending_images[token] = (data, extension)
has_image = True
except ValueError as exc:
errors.append(f"Сообщение {position + 1}: {exc}")
elif form.get(f"remove_image_{token}") is not None:
image_name = None
has_image = False
unknown = validate(text)
if unknown:
errors.append(f"Сообщение {position + 1}: неизвестные теги " + ", ".join(unknown))
limit = MAX_CAPTION if has_image else MAX_MESSAGE_LENGTH
if len(text) > limit:
errors.append(
f"Сообщение {position + 1}: с фото — не больше {MAX_CAPTION} символов, "
f"без фото — {MAX_MESSAGE_LENGTH}"
)
if not text.strip() and not has_image:
errors.append(f"Сообщение {position + 1}: нужен текст или картинка")
submitted.append(
{
"id": item_id,
"token": token,
"text": text,
"enabled": enabled,
"image_name": image_name,
"buttons": buttons,
"buttons_text": raw_buttons,
}
)
if errors:
return await _editor_response(
request,
key,
errors=errors,
items_override=submitted,
status_code=400,
)
settings = request.app.state.settings
saved_files: list[str] = []
for item in submitted:
pending = pending_images.get(item["token"])
if pending is None:
continue
data, extension = pending
name = f"{key}_{item['token']}_{uuid.uuid4().hex}{extension}"
path = media_path(settings, name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
item["image_name"] = name
saved_files.append(name)
removed = await sequences_repo.save_items(
key,
submitted,
editor_id=_editor_id(request),
)
if removed is None:
for name in saved_files:
_delete_image(settings, name)
raise HTTPException(status_code=404, detail="Шаблон не найден")
for name in removed:
_delete_image(settings, name)
await sequences_repo.update_sequence(
key,
title,
description,
editor_id=_editor_id(request),
)
return RedirectResponse(url=f"/sequences/{key}?saved=all", status_code=303)
@router.post("/{key}")
async def update_sequence(request: Request, key: str) -> Response:
form = await request.form()

View File

@ -86,6 +86,61 @@
<footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer>
<script>
var tags = {
b: ["<b>", "</b>"],
i: ["<i>", "</i>"],
u: ["<u>", "</u>"],
s: ["<s>", "</s>"],
code: ["<code>", "</code>"],
pre: ["<pre>", "</pre>"],
quote: ["<blockquote>", "</blockquote>"],
spoiler: ["<tg-spoiler>", "</tg-spoiler>"],
a: ['<a href="">', "</a>"],
};
function initEditor(root) {
if (!root || root.dataset.editorReady) return;
var textarea = root.querySelector("textarea");
if (!textarea) return;
root.dataset.editorReady = "1";
var counter = root.querySelector("[data-counter]");
var max = parseInt(root.dataset.max || "0", 10);
function updateCounter() {
if (counter) counter.textContent = textarea.value.length + (max ? " / " + max : "");
}
textarea.addEventListener("input", updateCounter);
updateCounter();
root.querySelectorAll("button[data-tag]").forEach(function (button) {
button.addEventListener("click", function () {
var pair = tags[button.dataset.tag];
if (!pair) return;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
var inserted = pair[0] + selected + pair[1];
textarea.value = textarea.value.slice(0, start) + inserted + textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + pair[0].length;
textarea.selectionEnd = start + pair[0].length + selected.length;
updateCounter();
});
});
textarea.addEventListener("keydown", function (event) {
if (!(event.ctrlKey || event.metaKey)) return;
var key = event.key.toLowerCase();
var tag = key === "b" ? "b" : key === "i" ? "i" : key === "u" ? "u" : null;
if (!tag) return;
event.preventDefault();
var button = root.querySelector('button[data-tag="' + tag + '"]');
if (button) button.click();
});
}
window.initEditor = initEditor;
document.addEventListener("DOMContentLoaded", function () {
var navToggle = document.getElementById("nav-toggle");
var navMenu = document.getElementById("nav-menu");
@ -153,55 +208,7 @@
groups.forEach(hideGroup);
});
var tags = {
b: ["<b>", "</b>"],
i: ["<i>", "</i>"],
u: ["<u>", "</u>"],
s: ["<s>", "</s>"],
code: ["<code>", "</code>"],
pre: ["<pre>", "</pre>"],
quote: ["<blockquote>", "</blockquote>"],
spoiler: ["<tg-spoiler>", "</tg-spoiler>"],
a: ['<a href="">', "</a>"],
};
document.querySelectorAll("[data-editor]").forEach(function (root) {
var textarea = root.querySelector("textarea");
var counter = root.querySelector("[data-counter]");
var max = parseInt(root.dataset.max || "0", 10);
function updateCounter() {
if (counter) counter.textContent = textarea.value.length + (max ? " / " + max : "");
}
textarea.addEventListener("input", updateCounter);
updateCounter();
root.querySelectorAll("button[data-tag]").forEach(function (button) {
button.addEventListener("click", function () {
var pair = tags[button.dataset.tag];
if (!pair) return;
var start = textarea.selectionStart;
var end = textarea.selectionEnd;
var selected = textarea.value.slice(start, end);
var inserted = pair[0] + selected + pair[1];
textarea.value = textarea.value.slice(0, start) + inserted + textarea.value.slice(end);
textarea.focus();
textarea.selectionStart = start + pair[0].length;
textarea.selectionEnd = start + pair[0].length + selected.length;
updateCounter();
});
});
textarea.addEventListener("keydown", function (event) {
if (!(event.ctrlKey || event.metaKey)) return;
var key = event.key.toLowerCase();
var tag = key === "b" ? "b" : key === "i" ? "i" : key === "u" ? "u" : null;
if (!tag) return;
event.preventDefault();
var button = root.querySelector('button[data-tag="' + tag + '"]');
if (button) button.click();
});
});
document.querySelectorAll("[data-editor]").forEach(initEditor);
});
</script>
</body>

View File

@ -13,7 +13,8 @@
{% if saved %}
<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 == 'all' %}Шаблон сохранён целиком.
{% elif saved == 'meta' %}Параметры шаблона сохранены.
{% elif saved == 'order' %}Порядок сообщений обновлён.
{% else %}Сообщение сохранено.{% endif %}
</div>
@ -25,75 +26,128 @@
</ul>
{% endif %}
<form method="post" action="/sequences/{{ sequence.key }}" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
<form
id="template-form"
method="post"
action="/sequences/{{ sequence.key }}/save"
enctype="multipart/form-data"
class="mt-6"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="text-xs text-slate-500">Название</label>
<input name="title" value="{{ sequence.title }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div class="sm:col-span-2">
<label class="text-xs text-slate-500">Описание</label>
<input name="description" value="{{ sequence.description }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
<input type="hidden" name="item_order" id="item-order" value="{{ item_order }}" />
<div class="rounded-lg border border-slate-200 bg-white p-4">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<label class="text-xs text-slate-500">Название</label>
<input name="title" value="{{ sequence.title }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
<div class="sm:col-span-2">
<label class="text-xs text-slate-500">Описание</label>
<input name="description" value="{{ sequence.description }}"
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
</div>
</div>
</div>
<button type="submit" class="mt-3 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить параметры
</button>
</form>
<div class="mt-8 flex items-center justify-between">
<h2 class="text-lg font-medium">Сообщения ({{ items | length }})</h2>
<form method="post" action="/sequences/{{ sequence.key }}/items">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
<div class="mt-6 flex items-center justify-between">
<h2 class="text-lg font-medium">Сообщения ({{ items | length }})</h2>
<button
type="button"
id="add-item"
class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Добавить сообщение
</button>
</form>
</div>
</div>
<div class="mt-4 space-y-5">
{% for item in items %}
<section class="rounded-lg border border-slate-200 bg-white">
<div id="items" class="mt-4 space-y-5">
{% for item in items %}
<section class="rounded-lg border border-slate-200 bg-white" data-token="{{ item.token }}">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
<div class="text-sm text-slate-600">
<span class="font-medium" data-position>№ {{ loop.index }}</span>
{% if item.id %}<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>{% endif %}
{% if item.enabled %}
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
{% else %}
<span class="ml-2 rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
{% endif %}
</div>
<div class="flex items-center gap-3 text-sm">
<button type="button" data-action="up" class="text-slate-600 hover:underline">Выше</button>
<button type="button" data-action="down" class="text-slate-600 hover:underline">Ниже</button>
<button type="button" data-action="delete" class="text-red-600 hover:underline">Удалить</button>
</div>
</div>
<div class="p-4">
{% with editor_id="text-" ~ item.token, editor_name="text_" ~ item.token, editor_value=item.text, editor_max=(max_caption if item.image_name else max_message), editor_rows=12 %}
{% include "partials/editor.html" %}
{% endwith %}
<div class="mt-3 grid gap-3 lg:grid-cols-2">
<div>
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
<textarea
name="buttons_{{ item.token }}"
rows="3"
placeholder="Канал | https://t.me/…"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
>{{ item.buttons_text }}</textarea>
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок; каждая на своей строке.</p>
</div>
<div>
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
{% if item.image_name %}
<div class="mt-1 flex items-center gap-3">
<img src="/sequences/media/{{ item.image_name }}" alt="" class="h-20 w-20 rounded object-cover" />
<label class="flex items-center gap-2 text-xs text-slate-600">
<input type="checkbox" name="remove_image_{{ item.token }}" /> удалить
</label>
</div>
{% endif %}
<input type="file" name="image_{{ item.token }}" accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full text-xs text-slate-600" />
<p class="mt-1 text-xs text-slate-500">
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
</p>
</div>
</div>
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled_{{ item.token }}" {% if item.enabled %}checked{% endif %} /> включено
</label>
</div>
</section>
{% endfor %}
</div>
<div class="mt-6 flex flex-wrap 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>
<span class="text-xs text-slate-500">Сохраняет параметры и все сообщения сразу.</span>
</div>
</form>
<template id="item-template">
<section class="rounded-lg border border-slate-200 bg-white" data-token="__TOKEN__">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
<div class="text-sm text-slate-600">
<span class="font-medium">№ {{ loop.index }}</span>
<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>
{% if item.enabled %}
<span class="font-medium" data-position>№</span>
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
{% else %}
<span class="ml-2 rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">выключено</span>
{% endif %}
</div>
<div class="flex items-center gap-2 text-sm">
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/move">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<input type="hidden" name="direction" value="up" />
<button type="submit" class="text-slate-600 hover:underline" {% if loop.first %}disabled{% endif %}>Выше</button>
</form>
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/move">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<input type="hidden" name="direction" value="down" />
<button type="submit" class="text-slate-600 hover:underline" {% if loop.last %}disabled{% endif %}>Ниже</button>
</form>
<form method="post" action="/sequences/{{ sequence.key }}/items/{{ item.id }}/delete" onsubmit="return confirm('Удалить сообщение?');">
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
</form>
<div class="flex items-center gap-3 text-sm">
<button type="button" data-action="up" class="text-slate-600 hover:underline">Выше</button>
<button type="button" data-action="down" class="text-slate-600 hover:underline">Ниже</button>
<button type="button" data-action="delete" class="text-red-600 hover:underline">Удалить</button>
</div>
</div>
<form
method="post"
action="/sequences/{{ sequence.key }}/items/{{ item.id }}"
enctype="multipart/form-data"
class="p-4"
>
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
{% with editor_id="text-" ~ item.id, editor_name="text", editor_value=item.text, editor_max=(max_caption if item.image_name else max_message), editor_rows=12 %}
<div class="p-4">
{% with editor_id="text-__TOKEN__", editor_name="text___TOKEN__", editor_value="", editor_max=max_message, editor_rows=12 %}
{% include "partials/editor.html" %}
{% endwith %}
@ -101,24 +155,16 @@
<div>
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
<textarea
name="buttons"
name="buttons___TOKEN__"
rows="3"
placeholder="Канал | https://t.me/…"
class="mt-1 w-full rounded-lg border border-slate-300 p-2 font-mono text-xs focus:border-sky-500 focus:outline-none"
>{{ item.buttons_text }}</textarea>
></textarea>
<p class="mt-1 text-xs text-slate-500">До {{ max_buttons }} кнопок; каждая на своей строке.</p>
</div>
<div>
<label class="text-xs text-slate-500">Картинка (JPEG/PNG/WebP, до 5 МБ)</label>
{% if item.image_name %}
<div class="mt-1 flex items-center gap-3">
<img src="/sequences/media/{{ item.image_name }}" alt="" class="h-20 w-20 rounded object-cover" />
<label class="flex items-center gap-2 text-xs text-slate-600">
<input type="checkbox" name="remove_image" /> удалить
</label>
</div>
{% endif %}
<input type="file" name="image" accept="image/jpeg,image/png,image/webp"
<input type="file" name="image___TOKEN__" accept="image/jpeg,image/png,image/webp"
class="mt-2 block w-full text-xs text-slate-600" />
<p class="mt-1 text-xs text-slate-500">
С картинкой текст уходит подписью — не больше {{ max_caption }} символов.
@ -126,20 +172,12 @@
</div>
</div>
<div class="mt-3 flex items-center justify-between">
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled" {% if item.enabled %}checked{% endif %} /> включено
</label>
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
Сохранить сообщение
</button>
</div>
</form>
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="enabled___TOKEN__" checked /> включено
</label>
</div>
</section>
{% else %}
<p class="text-sm text-slate-500">Сообщений пока нет — добавьте первое.</p>
{% endfor %}
</div>
</template>
<div class="mt-10">
<h2 class="text-lg font-medium">История изменений</h2>
@ -162,4 +200,56 @@
<p class="mt-2 text-sm text-slate-500">Изменений ещё не было.</p>
{% endif %}
</div>
<script>
(function () {
var items = document.getElementById("items");
var orderInput = document.getElementById("item-order");
var template = document.getElementById("item-template");
var addButton = document.getElementById("add-item");
var counter = 0;
function updateOrder() {
var cards = items.querySelectorAll("[data-token]");
var tokens = [];
cards.forEach(function (card, index) {
tokens.push(card.dataset.token);
var position = card.querySelector("[data-position]");
if (position) position.textContent = "№ " + (index + 1);
});
orderInput.value = tokens.join(",");
}
addButton.addEventListener("click", function () {
counter += 1;
var token = "new_" + counter;
var wrapper = document.createElement("div");
wrapper.innerHTML = template.innerHTML.replace(/__TOKEN__/g, token).trim();
var card = wrapper.firstElementChild;
items.appendChild(card);
updateOrder();
if (window.initEditor) window.initEditor(card);
var textarea = card.querySelector("textarea");
if (textarea) textarea.focus();
});
items.addEventListener("click", function (event) {
var button = event.target.closest("button[data-action]");
if (!button) return;
var card = button.closest("[data-token]");
if (!card) return;
var action = button.dataset.action;
if (action === "delete") {
if (confirm("Удалить сообщение?")) card.remove();
} else if (action === "up" && card.previousElementSibling) {
items.insertBefore(card, card.previousElementSibling);
} else if (action === "down" && card.nextElementSibling) {
items.insertBefore(card.nextElementSibling, card);
}
updateOrder();
});
updateOrder();
})();
</script>
{% endblock %}

View File

@ -252,6 +252,88 @@ async def update_item(
return key, old_image
async def save_items(
key: str,
items: list[dict[str, Any]],
*,
editor_id: int | None = None,
) -> list[str] | None:
"""Сохранить весь набор сообщений одной транзакцией.
``items`` — по порядку: ``id`` (None для нового), ``text``, ``enabled``,
``image_name``, ``buttons`` (список пар текст/URL). Возвращает имена
картинок, которые больше не используются (их можно удалить с диска).
"""
async with get_pool().connection() as conn:
cur = await conn.execute(
"SELECT 1 FROM bot.message_sequences WHERE key = %s",
(key,),
)
if await cur.fetchone() is None:
return None
cur = await conn.execute(
"SELECT id, image_name FROM bot.message_sequence_items WHERE sequence_key = %s",
(key,),
)
existing = {int(row["id"]): row["image_name"] for row in await cur.fetchall()}
submitted_ids = {int(item["id"]) for item in items if item.get("id")}
removed_images: list[str] = []
for item_id, image_name in existing.items():
if item_id not in submitted_ids:
await conn.execute("DELETE FROM bot.message_sequence_items WHERE id = %s", (item_id,))
if image_name:
removed_images.append(str(image_name))
# Освобождаем позиции, чтобы не нарушить UNIQUE(sequence_key, position).
await conn.execute(
"UPDATE bot.message_sequence_items SET position = -id WHERE sequence_key = %s",
(key,),
)
for position, item in enumerate(items):
item_id = int(item["id"]) if item.get("id") else None
if item_id is not None and item_id in existing:
old_image = existing[item_id]
if old_image and old_image != item.get("image_name"):
removed_images.append(str(old_image))
await conn.execute(
"""
UPDATE bot.message_sequence_items
SET position = %s, text = %s, enabled = %s, image_name = %s,
updated_at = now(), updated_by = %s
WHERE id = %s
""",
(position, item["text"], item["enabled"], item.get("image_name"), editor_id, item_id),
)
else:
cur = await conn.execute(
"""
INSERT INTO bot.message_sequence_items
(sequence_key, position, text, enabled, image_name, updated_by)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(key, position, item["text"], item["enabled"], item.get("image_name"), editor_id),
)
inserted = await cur.fetchone()
item_id = int(inserted["id"])
await conn.execute("DELETE FROM bot.message_sequence_buttons WHERE item_id = %s", (item_id,))
for button_position, (button_text, url) in enumerate(item.get("buttons") or []):
await conn.execute(
"""
INSERT INTO bot.message_sequence_buttons (item_id, position, text, url)
VALUES (%s, %s, %s, %s)
""",
(item_id, button_position, button_text, url),
)
await _snapshot(conn, key, editor_id)
return removed_images
async def delete_item(item_id: int, *, editor_id: int | None = None) -> str | None:
async with get_pool().connection() as conn:
cur = await conn.execute(

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.6.7"
__version__ = "0.6.8"

View File

@ -96,6 +96,10 @@ def fake_sequences(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
state["deleted"] = item_id
return "welcome"
async def save_items(key: str, items: list[dict[str, Any]], *, editor_id: int | None = None) -> list[str]:
state["saved_all"] = items
return []
monkeypatch.setattr(sequences_repo, "list_sequences", list_sequences)
monkeypatch.setattr(sequences_repo, "get_sequence", get_sequence)
monkeypatch.setattr(sequences_repo, "get_items", get_items)
@ -106,6 +110,7 @@ def fake_sequences(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
monkeypatch.setattr(sequences_repo, "update_item", update_item)
monkeypatch.setattr(sequences_repo, "move_item", move_item)
monkeypatch.setattr(sequences_repo, "delete_item", delete_item)
monkeypatch.setattr(sequences_repo, "save_items", save_items)
return state
@ -269,3 +274,51 @@ def test_move_and_delete_item(fake_sequences: dict[str, Any], client_factory, cs
assert deleted.status_code == 303
assert fake_sequences["move"] == (1, "down")
assert fake_sequences["deleted"] == 2
def test_save_all_items(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory(follow_redirects=False)
token = _csrf(client, csrf_token)
response = client.post(
"/sequences/welcome/save",
data={
"title": "Приветствие",
"description": "Описание",
"item_order": "1,2",
"text_1": "Первый",
"buttons_1": "",
"enabled_1": "on",
"text_2": "Второй",
"buttons_2": "Канал | https://t.me/x",
"enabled_2": "on",
"csrf_token": token,
},
)
assert response.status_code == 303
assert response.headers["location"] == "/sequences/welcome?saved=all"
saved = fake_sequences["saved_all"]
assert [item["text"] for item in saved] == ["Первый", "Второй"]
assert saved[1]["buttons"] == [("Канал", "https://t.me/x")]
def test_save_all_rejects_bad_item(fake_sequences: dict[str, Any], client_factory, csrf_token) -> None:
client: TestClient = client_factory()
token = _csrf(client, csrf_token)
response = client.post(
"/sequences/welcome/save",
data={
"title": "Приветствие",
"item_order": "1",
"text_1": "Текст",
"buttons_1": "без ссылки",
"enabled_1": "on",
"csrf_token": token,
},
)
assert response.status_code == 400
assert "Кнопка без разделителя" in response.text
assert "saved_all" not in fake_sequences

View File

@ -7,4 +7,4 @@ from common import __version__
def test_version_is_set():
assert __version__
assert __version__ == "0.6.7"
assert __version__ == "0.6.8"