Справочник званий редактируется из админки
Появился раздел «Звания»: название, цена и видимость; изменения сразу видит бот, потому что читает bot.titles напрямую. Удалить можно только звание, которое ни у кого не стоит, иначе предлагается скрыть — данные игроков не ломаются.
This commit is contained in:
parent
ae6faeee1e
commit
38ed030303
|
|
@ -82,6 +82,10 @@ HttpOnly-cookie (`ADMIN_SESSION_SECRET`), все формы защищены CSR
|
||||||
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
журнал `money` с действием «Корректировка админом»). Каждое ручное изменение пишется в
|
||||||
`bot.user_changes`: кто, когда, было → стало и причина.
|
`bot.user_changes`: кто, когда, было → стало и причина.
|
||||||
|
|
||||||
|
Раздел «Звания» — справочник магазина: название, цена и видимость. Изменения сразу
|
||||||
|
видны боту (он читает `bot.titles`). Удалить можно только звание, которое ни у кого
|
||||||
|
не стоит; используемое — скрывается (`visible = false`).
|
||||||
|
|
||||||
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username)
|
Раздел «Логи» — события из ClickHouse: фильтры по типу, пользователю (ID или username)
|
||||||
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON,
|
и датам, пагинация по 50. Время показывается в `TIMEZONE`, payload раскрывается как JSON,
|
||||||
ID пользователя ведёт в его карточку. ClickHouse читается отдельным ленивым клиентом;
|
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`).
|
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
|
||||||
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
|
||||||
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.
|
- Выпуск: изменить `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.media import ensure_media
|
||||||
from admin.middleware import AdminAuthMiddleware
|
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 import __version__
|
||||||
from common.analytics import read as analytics_read
|
from common.analytics import read as analytics_read
|
||||||
from common.config import AdminSettings, get_admin_settings
|
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(pages.router)
|
||||||
app.include_router(preview.router)
|
app.include_router(preview.router)
|
||||||
app.include_router(sequences.router)
|
app.include_router(sequences.router)
|
||||||
|
app.include_router(titles.router)
|
||||||
app.include_router(schedules.router)
|
app.include_router(schedules.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(admins.router)
|
app.include_router(admins.router)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ SECTIONS: tuple[dict[str, str | None], ...] = (
|
||||||
"description": "Ники, звания, баланс и подписка",
|
"description": "Ники, звания, баланс и подписка",
|
||||||
"url": "/users",
|
"url": "/users",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Звания",
|
||||||
|
"description": "Справочник магазина званий",
|
||||||
|
"url": "/titles",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "Админы",
|
"title": "Админы",
|
||||||
"description": "Доступ к веб-панели",
|
"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="/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="/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="/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="/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="/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>
|
<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
|
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():
|
def test_version_is_set():
|
||||||
assert __version__
|
assert __version__
|
||||||
assert __version__ == "0.6.0"
|
assert __version__ == "0.6.1"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue