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 @@ diff --git a/admin/templates/sequences/edit.html b/admin/templates/sequences/edit.html index d971967..0c2e4ea 100644 --- a/admin/templates/sequences/edit.html +++ b/admin/templates/sequences/edit.html @@ -13,7 +13,8 @@ {% if saved %}
- {% if saved == 'meta' %}Параметры шаблона сохранены. + {% if saved == 'all' %}Шаблон сохранён целиком. + {% elif saved == 'meta' %}Параметры шаблона сохранены. {% elif saved == 'order' %}Порядок сообщений обновлён. {% else %}Сообщение сохранено.{% endif %}
@@ -25,75 +26,128 @@ {% endif %} -
+ -
-
- - -
-
- - + + +
+
+
+ + +
+
+ + +
- - -
-

Сообщения ({{ items | length }})

-
- - -
-
+
-
- {% for item in items %} -
+
+ {% for item in items %} +
+
+
+ № {{ loop.index }} + {% if item.id %}id {{ item.id }}{% endif %} + {% if item.enabled %} + включено + {% else %} + выключено + {% endif %} +
+
+ + + +
+
+ +
+ {% 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 %} + +
+
+ + +

До {{ max_buttons }} кнопок; каждая на своей строке.

+
+
+ + {% if item.image_name %} +
+ + +
+ {% endif %} + +

+ С картинкой текст уходит подписью — не больше {{ max_caption }} символов. +

+
+
+ + +
+
+ {% endfor %} +
+ +
+ + Сохраняет параметры и все сообщения сразу. +
+ + +

История изменений

@@ -162,4 +200,56 @@

Изменений ещё не было.

{% endif %}
+ + {% endblock %} diff --git a/common/repositories/sequences_repo.py b/common/repositories/sequences_repo.py index ad7efef..6d38d28 100644 --- a/common/repositories/sequences_repo.py +++ b/common/repositories/sequences_repo.py @@ -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( diff --git a/common/version.py b/common/version.py index cf906d2..3a5bc8a 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.6.7" +__version__ = "0.6.8" diff --git a/tests/test_admin_sequences.py b/tests/test_admin_sequences.py index 620d61d..ca5a628 100644 --- a/tests/test_admin_sequences.py +++ b/tests/test_admin_sequences.py @@ -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 diff --git a/tests/test_version.py b/tests/test_version.py index 9d0620d..dedad10 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -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"