From 3347dacbb5490eefbe497df1b510b6e9cfd1b3c2 Mon Sep 17 00:00:00 2001 From: MpakobecEx Date: Sun, 25 Oct 2026 22:03:07 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=20=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D1=83=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Появился раздел «Звания»: название, цена и видимость; изменения сразу видит бот, потому что читает bot.titles напрямую. Удалить можно только звание, которое ни у кого не стоит, иначе предлагается скрыть — данные игроков не ломаются. --- README.md | 6 +- admin/app.py | 3 +- admin/routes/pages.py | 5 ++ admin/routes/titles.py | 124 +++++++++++++++++++++++++++++ admin/templates/base.html | 1 + admin/templates/titles/list.html | 83 +++++++++++++++++++ common/repositories/titles_repo.py | 56 +++++++++++++ common/version.py | 2 +- tests/test_admin_titles.py | 105 ++++++++++++++++++++++++ tests/test_version.py | 2 +- 10 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 admin/routes/titles.py create mode 100644 admin/templates/titles/list.html create mode 100644 common/repositories/titles_repo.py create mode 100644 tests/test_admin_titles.py 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 %} +
+ {% if saved == 'created' %}Звание добавлено. + {% elif saved == 'updated' %}Звание обновлено. + {% elif saved == 'deleted' %}Звание удалено. + {% else %}Изменения сохранены.{% endif %} +
+{% endif %} + +{% if errors %} + +{% endif %} + +
+ +

Новое звание

+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ {% for title in titles %} +
+
+ +
+ + +
+
+ + +
+ +
+ +
+
+ +
+ id {{ title.id_title }} · используется: {{ title.used }} +
+ + +
+
+
+ {% else %} +

Званий пока нет — добавьте первое.

+ {% endfor %} +
+{% endblock %} diff --git a/common/repositories/titles_repo.py b/common/repositories/titles_repo.py new file mode 100644 index 0000000..31dcf99 --- /dev/null +++ b/common/repositories/titles_repo.py @@ -0,0 +1,56 @@ +"""Справочник званий: чтение, создание, изменение, удаление.""" + +from __future__ import annotations + +from typing import Any + +from common.db.pool import get_pool + + +async def list_titles() -> list[dict[str, Any]]: + """Все звания с количеством игроков, у которых они стоят.""" + async with get_pool().connection() as conn: + cur = await conn.execute( + """ + SELECT t.id_title, t.title, t.price, t.visible, + (SELECT count(*) FROM bot.de_users AS d WHERE d.id_title = t.id_title) AS used + FROM bot.titles AS t + ORDER BY t.price DESC, t.id_title + """ + ) + return await cur.fetchall() + + +async def create_title(title: str, price: int, visible: bool) -> int: + async with get_pool().connection() as conn: + cur = await conn.execute( + "INSERT INTO bot.titles (title, price, visible) VALUES (%s, %s, %s) RETURNING id_title", + (title, price, visible), + ) + row = await cur.fetchone() + return int(row["id_title"]) + + +async def update_title(title_id: int, title: str, price: int, visible: bool) -> bool: + async with get_pool().connection() as conn: + cur = await conn.execute( + "UPDATE bot.titles SET title = %s, price = %s, visible = %s WHERE id_title = %s", + (title, price, visible, title_id), + ) + return bool(cur.rowcount) + + +async def delete_title(title_id: int) -> bool: + async with get_pool().connection() as conn: + cur = await conn.execute("DELETE FROM bot.titles WHERE id_title = %s", (title_id,)) + return bool(cur.rowcount) + + +async def usage_count(title_id: int) -> int: + async with get_pool().connection() as conn: + cur = await conn.execute( + "SELECT count(*) AS total FROM bot.de_users WHERE id_title = %s", + (title_id,), + ) + row = await cur.fetchone() + return int(row["total"]) if row else 0 diff --git a/common/version.py b/common/version.py index 8fe7efe..991c6ca 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.6.0" +__version__ = "0.6.1" diff --git a/tests/test_admin_titles.py b/tests/test_admin_titles.py new file mode 100644 index 0000000..d4e634e --- /dev/null +++ b/tests/test_admin_titles.py @@ -0,0 +1,105 @@ +"""Тесты справочника званий.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from common.repositories import titles_repo + +TITLE: dict[str, Any] = { + "id_title": 1, + "title": "Ветеран", + "price": 500, + "visible": True, + "used": 2, +} + + +@pytest.fixture +def fake_titles(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + state: dict[str, Any] = {"used": 2} + + async def list_titles() -> list[dict[str, Any]]: + return [TITLE] + + async def create_title(title: str, price: int, visible: bool) -> int: + state["created"] = (title, price, visible) + return 2 + + async def update_title(title_id: int, title: str, price: int, visible: bool) -> bool: + state["updated"] = (title_id, title, price, visible) + return True + + async def delete_title(title_id: int) -> bool: + state["deleted"] = title_id + return True + + async def usage_count(title_id: int) -> int: + return state["used"] + + monkeypatch.setattr(titles_repo, "list_titles", list_titles) + monkeypatch.setattr(titles_repo, "create_title", create_title) + monkeypatch.setattr(titles_repo, "update_title", update_title) + monkeypatch.setattr(titles_repo, "delete_title", delete_title) + monkeypatch.setattr(titles_repo, "usage_count", usage_count) + return state + + +def _client(client_factory) -> TestClient: + return client_factory(follow_redirects=False) + + +def test_titles_list(fake_titles: dict[str, Any], client_factory) -> None: + response = _client(client_factory).get("/titles") + + assert response.status_code == 200 + assert "Ветеран" in response.text + assert "используется: 2" in response.text + + +def test_create_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None: + client = _client(client_factory) + token = csrf_token(client.get("/titles").text) + + response = client.post("/titles", data={"title": "Новичок", "price": "100", "csrf_token": token}) + + assert response.status_code == 303 + assert fake_titles["created"] == ("Новичок", 100, True) + + +def test_update_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None: + client = _client(client_factory) + token = csrf_token(client.get("/titles").text) + + response = client.post( + "/titles/1", + data={"title": "Ветеран+", "price": "700", "visible": "on", "csrf_token": token}, + ) + + assert response.status_code == 303 + assert fake_titles["updated"] == (1, "Ветеран+", 700, True) + + +def test_delete_used_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None: + client = _client(client_factory) + token = csrf_token(client.get("/titles").text) + + response = client.post("/titles/1/delete", data={"csrf_token": token}) + + assert response.status_code == 400 + assert "используется 2 игроками" in response.text + assert "deleted" not in fake_titles + + +def test_delete_unused_title(fake_titles: dict[str, Any], client_factory, csrf_token) -> None: + fake_titles["used"] = 0 + client = _client(client_factory) + token = csrf_token(client.get("/titles").text) + + response = client.post("/titles/1/delete", data={"csrf_token": token}) + + assert response.status_code == 303 + assert fake_titles["deleted"] == 1 diff --git a/tests/test_version.py b/tests/test_version.py index 3feb73e..38b730d 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.0" + assert __version__ == "0.6.1"