Шаблон сохраняется целиком одной кнопкой
В редакторе шаблона теперь одна форма и одна кнопка «Сохранить всё»: название, описание и все сообщения. Добавление, удаление и перестановка сообщений происходят на странице без запросов к серверу и применяются при сохранении, поэтому несохранённые правки не теряются. Сервер сохраняет весь набор одной транзакцией, включая порядок, кнопки и картинки.
This commit is contained in:
parent
afeed5b67e
commit
1ab434d8b0
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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,19 +88,25 @@ 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="Шаблон не найден")
|
||||
|
||||
if items_override is not None:
|
||||
items = items_override
|
||||
else:
|
||||
items = await sequences_repo.get_items(key)
|
||||
_prepare_items(items)
|
||||
for item in items:
|
||||
override = (overrides or {}).get(int(item["id"]))
|
||||
if override:
|
||||
item.update(override)
|
||||
|
||||
_prepare_items(items)
|
||||
item_order = ",".join(str(item["token"]) for item in items)
|
||||
|
||||
return request.app.state.templates.TemplateResponse(
|
||||
request=request,
|
||||
name="sequences/edit.html",
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,8 +26,17 @@
|
|||
</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', '') }}" />
|
||||
<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>
|
||||
|
|
@ -39,61 +49,41 @@
|
|||
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>
|
||||
<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">
|
||||
Добавить сообщение
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-5">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div id="items" class="mt-4 space-y-5">
|
||||
{% for item in items %}
|
||||
<section class="rounded-lg border border-slate-200 bg-white">
|
||||
<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">№ {{ loop.index }}</span>
|
||||
<span class="ml-2 font-mono text-xs text-slate-400">id {{ item.id }}</span>
|
||||
<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-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-" ~ 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 %}
|
||||
|
||||
|
|
@ -101,7 +91,7 @@
|
|||
<div>
|
||||
<label class="text-xs text-slate-500">Кнопки — по одной в строке: <span class="font-mono">Текст | https://…</span></label>
|
||||
<textarea
|
||||
name="buttons"
|
||||
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"
|
||||
|
|
@ -114,11 +104,11 @@
|
|||
<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" /> удалить
|
||||
<input type="checkbox" name="remove_image_{{ item.token }}" /> удалить
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<input type="file" name="image" accept="image/jpeg,image/png,image/webp"
|
||||
<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 }} символов.
|
||||
|
|
@ -126,21 +116,69 @@
|
|||
</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 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>
|
||||
<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>
|
||||
</section>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">Сообщений пока нет — добавьте первое.</p>
|
||||
{% 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" data-position>№</span>
|
||||
<span class="ml-2 rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-800">включено</span>
|
||||
</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-__TOKEN__", editor_name="text___TOKEN__", editor_value="", editor_max=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___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"
|
||||
></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>
|
||||
<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 }} символов.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="enabled___TOKEN__" checked /> включено
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div class="mt-10">
|
||||
<h2 class="text-lg font-medium">История изменений</h2>
|
||||
{% if history %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.6.7"
|
||||
__version__ = "0.6.8"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue