diff --git a/README.md b/README.md index 532a352..54da8f6 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в `bot.user_changes`: кто, когда, было → стало и причина. +Раздел «Звания» — справочник магазина: название, цена и видимость. Изменения сразу +видны боту (он читает `bot.titles`). Удалить можно только звание, которое ни у кого +не стоит; используемое — скрывается (`visible = false`). + Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username) и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON, ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом; @@ -167,7 +171,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.0`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.6.1`. - Схема — 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/app.py b/admin/app.py index 4cae463..6d94e3e 100644 --- a/admin/app.py +++ b/admin/app.py @@ -14,7 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware from admin.media import ensure_media from admin.middleware import AdminAuthMiddleware -from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, users +from admin.routes import admins, auth, health, logs, pages, preview, schedules, send, sequences, titles, users from common import __version__ from common.analytics import read as analytics_read from common.config import AdminSettings, get_admin_settings @@ -91,6 +91,7 @@ def create_app(settings: AdminSettings | None = None, *, init_db: bool = True) - app.include_router(pages.router) app.include_router(preview.router) app.include_router(sequences.router) + app.include_router(titles.router) app.include_router(schedules.router) app.include_router(users.router) app.include_router(admins.router) diff --git a/admin/routes/pages.py b/admin/routes/pages.py index df69fe1..27c6665 100644 --- a/admin/routes/pages.py +++ b/admin/routes/pages.py @@ -26,6 +26,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = ( "description": "Ники, звания, баланс и подписка", "url": "/users", }, + { + "title": "Звания", + "description": "Справочник магазина званий", + "url": "/titles", + }, { "title": "Админы", "description": "Доступ к веб-панели", diff --git a/admin/routes/titles.py b/admin/routes/titles.py new file mode 100644 index 0000000..5e5460d --- /dev/null +++ b/admin/routes/titles.py @@ -0,0 +1,124 @@ +"""Раздел «Звания»: справочник магазина званий.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, Response +from fastapi.responses import HTMLResponse, RedirectResponse + +from admin.security import check_csrf +from common import __version__ +from common.repositories import titles_repo + +router = APIRouter(prefix="/titles", tags=["titles"]) + +MAX_TITLE_LENGTH = 100 + + +def _parse(title: str, price: str, visible: bool) -> tuple[dict[str, object] | None, list[str]]: + errors: list[str] = [] + title = title.strip() + if not title: + errors.append("Название не может быть пустым") + elif len(title) > MAX_TITLE_LENGTH: + errors.append(f"Название длиннее {MAX_TITLE_LENGTH} символов") + + try: + price_value = int(price) + except (TypeError, ValueError): + price_value = -1 + errors.append("Цена должна быть целым числом") + if price_value < 0 and "Цена должна быть целым числом" not in errors: + errors.append("Цена не может быть отрицательной") + + if errors: + return None, errors + return {"title": title, "price": price_value, "visible": visible}, [] + + +async def _render( + 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="titles/list.html", + context={ + "version": __version__, + "titles": await titles_repo.list_titles(), + "errors": errors or [], + "form": form or {"title": "", "price": ""}, + "saved": saved, + "max_title_length": MAX_TITLE_LENGTH, + }, + status_code=status_code, + ) + + +@router.get("", response_class=HTMLResponse) +async def list_titles(request: Request, saved: str | None = None) -> HTMLResponse: + return await _render(request, saved=saved) + + +@router.post("") +async def create_title(request: Request) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + raw_title = str(form.get("title", "")) + raw_price = str(form.get("price", "")) + data, errors = _parse(raw_title, raw_price, True) + if errors: + return await _render( + request, + errors=errors, + form={"title": raw_title, "price": raw_price}, + status_code=400, + ) + + await titles_repo.create_title(str(data["title"]), int(data["price"]), True) + return RedirectResponse(url="/titles?saved=created", status_code=303) + + +@router.post("/{title_id}") +async def update_title(request: Request, title_id: int) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + data, errors = _parse( + str(form.get("title", "")), + str(form.get("price", "")), + form.get("visible") is not None, + ) + if errors: + return await _render(request, errors=errors, status_code=400) + + if not await titles_repo.update_title( + title_id, + str(data["title"]), + int(data["price"]), + bool(data["visible"]), + ): + raise HTTPException(status_code=404, detail="Звание не найдено") + return RedirectResponse(url="/titles?saved=updated", status_code=303) + + +@router.post("/{title_id}/delete") +async def delete_title(request: Request, title_id: int) -> Response: + form = await request.form() + check_csrf(request, str(form.get("csrf_token", ""))) + + used = await titles_repo.usage_count(title_id) + if used: + return await _render( + request, + errors=[f"Звание используется {used} игроками — скройте его вместо удаления"], + status_code=400, + ) + + if not await titles_repo.delete_title(title_id): + raise HTTPException(status_code=404, detail="Звание не найдено") + return RedirectResponse(url="/titles?saved=deleted", status_code=303) diff --git a/admin/templates/base.html b/admin/templates/base.html index 101fddd..bb533c5 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -28,6 +28,7 @@ Последовательности Расписания Пользователи + Звания Админы Логи Отправить diff --git a/admin/templates/titles/list.html b/admin/templates/titles/list.html new file mode 100644 index 0000000..f641be7 --- /dev/null +++ b/admin/templates/titles/list.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Звания{% endblock %} + +{% block content %} +
Справочник магазина: название, цена и видимость.
+ +{% if saved %} +Званий пока нет — добавьте первое.
+ {% endfor %} +