diff --git a/README.md b/README.md
index 5256fe1..1ead8b9 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/admin/routes/sequences.py b/admin/routes/sequences.py
index b8b6f65..5218570 100644
--- a/admin/routes/sequences.py
+++ b/admin/routes/sequences.py
@@ -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()
diff --git a/admin/templates/base.html b/admin/templates/base.html
index e65bb4b..2f0bdb4 100644
--- a/admin/templates/base.html
+++ b/admin/templates/base.html
@@ -86,6 +86,61 @@