Справочник званий редактируется из админки
Появился раздел «Звания»: название, цена и видимость; изменения сразу видит бот, потому что читает bot.titles напрямую. Удалить можно только звание, которое ни у кого не стоит, иначе предлагается скрыть — данные игроков не ломаются.
This commit is contained in:
parent
ae6faeee1e
commit
3347dacbb5
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
|||
"description": "Ники, звания, баланс и подписка",
|
||||
"url": "/users",
|
||||
},
|
||||
{
|
||||
"title": "Звания",
|
||||
"description": "Справочник магазина званий",
|
||||
"url": "/titles",
|
||||
},
|
||||
{
|
||||
"title": "Админы",
|
||||
"description": "Доступ к веб-панели",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
<a href="/sequences" class="py-1 text-slate-600 hover:text-slate-900">Последовательности</a>
|
||||
<a href="/schedules" class="py-1 text-slate-600 hover:text-slate-900">Расписания</a>
|
||||
<a href="/users" class="py-1 text-slate-600 hover:text-slate-900">Пользователи</a>
|
||||
<a href="/titles" class="py-1 text-slate-600 hover:text-slate-900">Звания</a>
|
||||
<a href="/admins" class="py-1 text-slate-600 hover:text-slate-900">Админы</a>
|
||||
<a href="/logs" class="py-1 text-slate-600 hover:text-slate-900">Логи</a>
|
||||
<a href="/send" class="py-1 text-slate-600 hover:text-slate-900">Отправить</a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Звания{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-semibold">Звания</h1>
|
||||
<p class="mt-1 text-sm text-slate-600">Справочник магазина: название, цена и видимость.</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
|
||||
{% if saved == 'created' %}Звание добавлено.
|
||||
{% elif saved == 'updated' %}Звание обновлено.
|
||||
{% elif saved == 'deleted' %}Звание удалено.
|
||||
{% else %}Изменения сохранены.{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<ul class="mt-4 list-disc rounded-lg border border-red-200 bg-red-50 p-3 pl-8 text-sm text-red-700">
|
||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/titles" class="mt-6 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<h2 class="text-lg font-medium">Новое звание</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="title" class="text-xs text-slate-500">Название</label>
|
||||
<input id="title" name="title" value="{{ form.title }}" maxlength="{{ max_title_length }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="price" class="text-xs text-slate-500">Цена, DE</label>
|
||||
<input id="price" name="price" type="number" min="0" value="{{ form.price }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 space-y-3">
|
||||
{% for title in titles %}
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<form method="post" action="/titles/{{ title.id_title }}" class="grid gap-3 sm:grid-cols-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<div class="sm:col-span-2">
|
||||
<label class="text-xs text-slate-500">Название</label>
|
||||
<input name="title" value="{{ title.title }}" maxlength="{{ max_title_length }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500">Цена, DE</label>
|
||||
<input name="price" type="number" min="0" value="{{ title.price }}"
|
||||
class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none" />
|
||||
</div>
|
||||
<label class="flex items-end gap-2 pb-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="visible" {% if title.visible %}checked{% endif %} /> видно
|
||||
</label>
|
||||
<div class="flex items-end">
|
||||
<button type="submit" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between text-xs text-slate-500">
|
||||
<span>id {{ title.id_title }} · используется: {{ title.used }}</span>
|
||||
<form method="post" action="/titles/{{ title.id_title }}/delete" onsubmit="return confirm('Удалить звание?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ request.session.get('csrf', '') }}" />
|
||||
<button type="submit" class="text-red-600 hover:underline">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">Званий пока нет — добавьте первое.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -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
|
||||
|
|
@ -7,4 +7,4 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.6.1"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue