DispelEvilBot/admin/routes/sequences.py

442 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Раздел «Последовательности»: сообщения по порядку, картинки и кнопки."""
from __future__ import annotations
import logging
import re
import uuid
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from starlette.datastructures import UploadFile
from admin.buttons import MAX_BUTTONS, parse_buttons
from admin.media import media_path, read_image
from admin.security import check_csrf
from common import __version__
from common.repositories import sequences_repo
from common.texts.render import MAX_MESSAGE_LENGTH, TAG_REGISTRY, validate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sequences", tags=["sequences"])
#: Формат ключа последовательности.
KEY_RE = re.compile(r"^[a-z][a-z0-9_.-]{1,31}$")
#: Лимит подписи к фото.
MAX_CAPTION = 1024
def _editor_id(request: Request) -> int:
return int(request.state.admin["telegram_id"])
def _prepare_items(items: list[dict[str, Any]]) -> None:
"""Добавить к каждому сообщению строку кнопок и токен для формы."""
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"])
async def _save_image(settings: Any, upload: UploadFile, key: str, item_id: int) -> str:
data, _, extension = await read_image(upload)
name = f"{key}_{item_id}_{uuid.uuid4().hex}{extension}"
path = media_path(settings, name)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return name
def _delete_image(settings: Any, name: str | None) -> None:
if not name:
return
try:
media_path(settings, name).unlink(missing_ok=True)
except OSError:
logger.warning("Не удалось удалить картинку %s", name, exc_info=True)
async def _list_response(
request: Request,
*,
errors: list[str] | None = None,
form: dict[str, str] | None = None,
saved: str | None = None,
status_code: int = 200,
) -> HTMLResponse:
return request.app.state.templates.TemplateResponse(
request=request,
name="sequences/list.html",
context={
"version": __version__,
"sequences": await sequences_repo.list_sequences(),
"errors": errors or [],
"form": form or {"key": "", "title": "", "description": ""},
"saved": saved,
},
status_code=status_code,
)
async def _editor_response(
request: Request,
key: str,
*,
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)
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",
context={
"version": __version__,
"sequence": sequence,
"items": items,
"item_order": item_order,
"history": await sequences_repo.history(key, limit=5),
"errors": errors or [],
"saved": saved,
"max_caption": MAX_CAPTION,
"max_message": MAX_MESSAGE_LENGTH,
"max_buttons": MAX_BUTTONS,
"tags": TAG_REGISTRY,
},
status_code=status_code,
)
@router.get("", response_class=HTMLResponse)
async def list_sequences(request: Request, saved: str | None = None) -> HTMLResponse:
return await _list_response(request, saved=saved)
@router.post("")
async def create_sequence(request: Request) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
key = str(form.get("key", "")).strip()
title = str(form.get("title", "")).strip()
description = str(form.get("description", "")).strip()
raw_form = {"key": key, "title": title, "description": description}
errors: list[str] = []
if not KEY_RE.match(key):
errors.append("Ключ: латиница в нижнем регистре, цифры, точка, подчёркивание или дефис (2–32)")
if not title:
errors.append("Название не может быть пустым")
if errors:
return await _list_response(request, errors=errors, form=raw_form, status_code=400)
if not await sequences_repo.create_sequence(
key,
title,
description,
editor_id=_editor_id(request),
):
return await _list_response(
request,
errors=["Шаблон с таким ключом уже существует"],
form=raw_form,
status_code=400,
)
return RedirectResponse(url=f"/sequences/{key}", status_code=303)
@router.get("/media/{filename}")
async def serve_media(request: Request, filename: str) -> FileResponse:
"""Отдать картинку из media-volume (доступ только после входа)."""
path = media_path(request.app.state.settings, filename)
if not path.is_file():
raise HTTPException(status_code=404, detail="Файл не найден")
return FileResponse(path)
@router.get("/{key}", response_class=HTMLResponse)
async def edit_sequence(request: Request, key: str, saved: str | None = None) -> HTMLResponse:
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()
check_csrf(request, str(form.get("csrf_token", "")))
title = str(form.get("title", "")).strip()
description = str(form.get("description", "")).strip()
if not title:
return await _editor_response(request, key, errors=["Название не может быть пустым"], status_code=400)
if not await sequences_repo.update_sequence(
key,
title,
description,
editor_id=_editor_id(request),
):
raise HTTPException(status_code=404, detail="Шаблон не найден")
return RedirectResponse(url=f"/sequences/{key}?saved=meta", status_code=303)
@router.post("/{key}/items")
async def add_item(request: Request, key: str) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
if await sequences_repo.add_item(key, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Шаблон не найден")
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
@router.post("/{key}/items/{item_id}")
async def update_item(request: Request, key: str, item_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
items = await sequences_repo.get_items(key)
item = next((row for row in items if int(row["id"]) == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
text = str(form.get("text", ""))
enabled = form.get("enabled") is not None
raw_buttons = str(form.get("buttons", ""))
buttons, errors = parse_buttons(raw_buttons)
unknown = validate(text)
if unknown:
errors.append("Неизвестные теги: " + ", ".join(unknown))
settings = request.app.state.settings
new_image: str | None = None
update_image = False
saved_file: str | None = None
upload = form.get("image")
if isinstance(upload, UploadFile) and upload.filename:
try:
saved_file = await _save_image(settings, upload, key, item_id)
new_image = saved_file
update_image = True
except ValueError as exc:
errors.append(str(exc))
elif form.get("remove_image") is not None and item["image_name"]:
update_image = True
new_image = None
effective_image = new_image if update_image else item["image_name"]
limit = MAX_CAPTION if effective_image else MAX_MESSAGE_LENGTH
if len(text) > limit:
errors.append(f"С подписью к фото — не больше {MAX_CAPTION} символов, без фото — {MAX_MESSAGE_LENGTH}")
if not text.strip() and not effective_image:
errors.append("Нужен текст или картинка")
if errors:
if saved_file:
_delete_image(settings, saved_file)
return await _editor_response(
request,
key,
errors=errors,
overrides={
item_id: {
"text": text,
"enabled": enabled,
"buttons_text": raw_buttons,
}
},
status_code=400,
)
updated = await sequences_repo.update_item(
item_id,
text,
enabled,
buttons,
image_name=new_image,
update_image=update_image,
editor_id=_editor_id(request),
)
if updated is None:
if saved_file:
_delete_image(settings, saved_file)
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
_, old_image = updated
if update_image and old_image and old_image != new_image:
_delete_image(settings, old_image)
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)
@router.post("/{key}/items/{item_id}/move")
async def move_item(request: Request, key: str, item_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
direction = str(form.get("direction", ""))
if direction not in {"up", "down"}:
raise HTTPException(status_code=400, detail="Неверное направление")
if await sequences_repo.move_item(item_id, direction, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
return RedirectResponse(url=f"/sequences/{key}?saved=order", status_code=303)
@router.post("/{key}/items/{item_id}/delete")
async def delete_item(request: Request, key: str, item_id: int) -> Response:
form = await request.form()
check_csrf(request, str(form.get("csrf_token", "")))
items = await sequences_repo.get_items(key)
item = next((row for row in items if int(row["id"]) == item_id), None)
if item is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
if await sequences_repo.delete_item(item_id, editor_id=_editor_id(request)) is None:
raise HTTPException(status_code=404, detail="Сообщение шаблона не найдено")
_delete_image(request.app.state.settings, item["image_name"])
return RedirectResponse(url=f"/sequences/{key}?saved=item", status_code=303)