Редактор ачивок в админке + файловое хранилище ачивок (задел под титулы)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:41:20 +03:00
co-authored by Claude Opus 4.8
parent 96daddae05
commit c4065962e2
19 changed files with 1165 additions and 6 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ COPY --from=frontend /app/frontend/dist /app/static
RUN sed -i 's/\r$//' /app/entrypoint.sh \
&& chmod +x /app/entrypoint.sh \
&& useradd --create-home --uid 10001 appuser \
&& mkdir -p /data/uploads \
&& mkdir -p /data/uploads /data/achievements \
&& chown -R appuser:appuser /app /data
USER appuser
@@ -0,0 +1,61 @@
"""Задел под титулы/ачивки: users.title_achievement_slug + таблица user_achievements.
Сами определения ачивок хранятся файлами (settings.achievements_dir), в БД — только
будущая выдача игрокам. Идемпотентна (как 0003/0004): на свежей БД (0001 = create_all из
актуальных моделей) столбец/таблица уже есть → no-op; на существующей — добавляет.
Revision ID: 0005_achievements
Revises: 0004_user_profile
Create Date: 2026-06-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0005_achievements"
down_revision: Union[str, None] = "0004_user_profile"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
if "title_achievement_slug" not in cols:
with op.batch_alter_table("users") as b:
b.add_column(sa.Column("title_achievement_slug", sa.String(64), nullable=True))
if "user_achievements" not in insp.get_table_names():
op.create_table(
"user_achievements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("achievement_slug", sa.String(64), nullable=False),
sa.Column("earned_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
)
op.create_index(
"ix_user_achievements_user_id", "user_achievements", ["user_id"]
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "user_achievements" in insp.get_table_names():
op.drop_table("user_achievements")
with op.batch_alter_table("users") as b:
try:
b.drop_column("title_achievement_slug")
except Exception: # noqa: BLE001
pass
+8
View File
@@ -45,6 +45,10 @@ class Settings(BaseSettings):
dev_upload_dir: str = "./data/dev/uploads"
prod_upload_dir: str = "/data/uploads"
# Каталог ачивок (папка на ачивку: meta.json, condition.py, icon.*), раздельно.
dev_achievements_dir: str = "./data/dev/achievements"
prod_achievements_dir: str = "/data/achievements"
# JWT / cookie
secret_key: str = "change-me-dev-secret-not-for-production"
jwt_algorithm: str = "HS256"
@@ -97,6 +101,10 @@ class Settings(BaseSettings):
def upload_dir(self) -> str:
return self.dev_upload_dir if self.is_development else self.prod_upload_dir
@property
def achievements_dir(self) -> str:
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
@property
def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
+2 -2
View File
@@ -15,7 +15,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.core import security
from app.core.config import settings
from app.core.errors import AppError, app_error_handler
from app.routers import admin, auth, groups, matches, reference, stats, users
from app.routers import achievements, admin, auth, groups, matches, reference, stats, users
# Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static")))
@@ -107,7 +107,7 @@ def create_app() -> FastAPI:
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, matches.router,
reference.router, stats.router, admin.router]
reference.router, stats.router, achievements.router, admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")
+23
View File
@@ -98,6 +98,9 @@ class User(SQLModel, table=True):
)
)
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап).
title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=_utcnow,
@@ -106,6 +109,26 @@ class User(SQLModel, table=True):
)
class UserAchievement(SQLModel, table=True):
"""Полученные игроком ачивки. Задел под будущую выдачу — сейчас не заполняется.
Ссылается на ачивку по slug (источник правды — папки на диске, не таблица БД)."""
__tablename__ = "user_achievements"
__table_args__ = (
UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
)
achievement_slug: str = Field(sa_column=Column(String(64), nullable=False))
earned_at: datetime = Field(default_factory=_utcnow, nullable=False)
class AuthIdentity(SQLModel, table=True):
__tablename__ = "auth_identity"
__table_args__ = (
+27
View File
@@ -0,0 +1,27 @@
"""Публичное чтение ачивок: список и иконки (для отображения; выдача — на будущее)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
from app.auth.deps import get_current_user
from app.models import User
from app.schemas import api as s
from app.services import achievement_service
router = APIRouter(prefix="/achievements", tags=["achievements"])
@router.get("", response_model=list[s.AchievementRead])
def list_achievements(_user: User = Depends(get_current_user)) -> list[dict]:
return achievement_service.list_achievements()
# Иконка — без auth: косметический ассет, нужен и игроку, и в админ-панели (cookie
# админа scoped на /api/admin и сюда не отправляется). Отдаётся по известному slug.
@router.get("/{slug}/icon")
def get_icon(slug: str) -> FileResponse:
path, media_type = achievement_service.icon_file(slug)
return FileResponse(
path, media_type=media_type, headers={"Cache-Control": "public, max-age=3600"}
)
+86 -2
View File
@@ -1,7 +1,7 @@
"""Админ-роутер: отдельный вход (логин+пароль) и управление сущностями."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
from sqlmodel import Session
from app.auth.deps import get_current_admin
@@ -11,7 +11,15 @@ from app.db.session import get_session
from app.models import User
from app.routers.matches import build_match_read
from app.schemas import api as s
from app.services import admin_service, audit_service, faction_service, match_service
from app.services import (
achievement_service,
admin_service,
audit_service,
faction_service,
match_service,
)
_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
from app.services.match_service import ParticipantInput
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -284,6 +292,82 @@ def delete_match(
return s.OkResponse()
# ─── Ачивки (определения; выдача игрокам — на будущее) ────────────────────────
@router.get("/achievements", response_model=list[s.AchievementRead])
def list_achievements(
_admin: User = Depends(get_current_admin),
) -> list[dict]:
return achievement_service.list_achievements()
@router.post("/achievements", response_model=s.AchievementRead)
def create_achievement(
body: s.AchievementCreate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> dict:
ach = achievement_service.create(body.name, body.description, body.condition)
audit_service.record(
session, actor_id=admin.id, action="create", entity_type="achievement",
payload={"slug": ach["slug"], "name": ach["name"]},
ip=request.client.host if request.client else None,
)
session.commit()
return ach
@router.patch("/achievements/{slug}", response_model=s.AchievementRead)
def update_achievement(
slug: str,
body: s.AchievementUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> dict:
ach = achievement_service.update(
slug, name=body.name, description=body.description, condition=body.condition
)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="achievement",
payload={"slug": slug}, ip=request.client.host if request.client else None,
)
session.commit()
return ach
@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead)
def upload_achievement_icon(
slug: str,
file: UploadFile = File(...),
_admin: User = Depends(get_current_admin),
) -> dict:
from app.core.errors import ValidationError
content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1)
if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES:
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
ext = achievement_service.validate_icon(content)
return achievement_service.set_icon(slug, content, ext)
@router.delete("/achievements/{slug}", response_model=s.OkResponse)
def delete_achievement(
slug: str,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
achievement_service.delete(slug)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="achievement",
payload={"slug": slug}, ip=request.client.host if request.client else None,
)
session.commit()
return s.OkResponse()
# ─── Журнал аудита ───────────────────────────────────────────────────────────
@router.get("/audit-logs", response_model=s.AuditLogList)
+21
View File
@@ -410,6 +410,27 @@ class AdminMatchRead(BaseModel):
created_at: str
class AchievementRead(BaseModel):
slug: str
name: str
description: str = ""
condition: str = "" # текст condition.py (задел; не исполняется)
has_condition: bool = False
icon_url: str | None = None
class AchievementCreate(BaseModel):
name: str
description: str | None = None
condition: str | None = None
class AchievementUpdate(BaseModel):
name: str | None = None
description: str | None = None
condition: str | None = None
class AuditLogItem(BaseModel):
id: int
actor_id: int | None = None
+194
View File
@@ -0,0 +1,194 @@
"""Ачивки: определения хранятся файлами на диске (settings.achievements_dir).
Папка на ачивку: achievements/<slug>/
meta.json — {"name", "description", "created_at"}
condition.py — скрипт-условие выдачи (ЗАДЕЛ: только хранится как текст, НЕ исполняется)
icon.<ext> — картинка-герб (png/jpg/webp), опционально
Источник правды — файлы. В БД (user_achievements / users.title_achievement_slug) — только
будущая выдача игрокам (сейчас не используется). Выдача и исполнение condition.py — следующий
этап; здесь админ лишь создаёт/именует/описывает ачивки.
"""
from __future__ import annotations
import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.services.user_service import avatar_media_type, sniff_image_ext
_CONDITION_FILE = "condition.py"
_META_FILE = "meta.json"
_CONDITION_PLACEHOLDER = (
"# Условие выдачи ачивки (ЗАДЕЛ на будущее — пока не исполняется).\n"
"# Здесь будет описана логика автоматической выдачи этого титула игроку.\n"
)
# Транслитерация для slug из русского названия.
_TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh",
"з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o",
"п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "ts",
"ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu",
"я": "ya",
}
def _root() -> Path:
return Path(settings.achievements_dir)
def _slugify(name: str) -> str:
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
return slug[:48]
def _dir(slug: str) -> Path:
return _root() / slug
def _icon_path(slug: str) -> Path | None:
folder = _dir(slug)
if not folder.is_dir():
return None
for f in sorted(folder.glob("icon.*")):
if f.is_file():
return f
return None
def _read(slug: str) -> dict:
folder = _dir(slug)
meta_file = folder / _META_FILE
if not meta_file.is_file():
raise NotFoundError("Ачивка не найдена.")
meta = json.loads(meta_file.read_text(encoding="utf-8"))
condition = ""
cond_file = folder / _CONDITION_FILE
if cond_file.is_file():
condition = cond_file.read_text(encoding="utf-8")
icon = _icon_path(slug)
icon_url = None
if icon is not None:
version = int(icon.stat().st_mtime)
icon_url = f"/api/achievements/{slug}/icon?v={version}"
return {
"slug": slug,
"name": meta.get("name", slug),
"description": meta.get("description", ""),
"condition": condition,
"has_condition": bool(condition.strip()),
"icon_url": icon_url,
}
def list_achievements() -> list[dict]:
root = _root()
if not root.is_dir():
return []
out = []
for folder in sorted(root.iterdir()):
if folder.is_dir() and (folder / _META_FILE).is_file():
out.append(_read(folder.name))
return out
def get(slug: str) -> dict:
return _read(slug)
def _validate_name(name: str) -> str:
name = (name or "").strip()
if not (1 <= len(name) <= 64):
raise ValidationError("Название ачивки: 1–64 символа.")
return name
def create(name: str, description: str | None, condition: str | None) -> dict:
name = _validate_name(name)
slug = _slugify(name)
if not slug:
raise ValidationError("Не удалось сформировать идентификатор из названия.")
folder = _dir(slug)
if folder.exists():
raise ConflictError("Ачивка с таким названием уже существует.")
folder.mkdir(parents=True, exist_ok=True)
_write_meta(slug, name, (description or "").strip())
(folder / _CONDITION_FILE).write_text(
condition if condition else _CONDITION_PLACEHOLDER, encoding="utf-8"
)
return _read(slug)
def update(
slug: str,
*,
name: str | None = None,
description: str | None = None,
condition: str | None = None,
) -> dict:
folder = _dir(slug)
meta_file = folder / _META_FILE
if not meta_file.is_file():
raise NotFoundError("Ачивка не найдена.")
meta = json.loads(meta_file.read_text(encoding="utf-8"))
# slug не меняем (он стабильный идентификатор/путь), даже если правят название.
new_name = _validate_name(name) if name is not None else meta.get("name", slug)
new_desc = description.strip() if description is not None else meta.get("description", "")
_write_meta(slug, new_name, new_desc, created_at=meta.get("created_at"))
if condition is not None:
(folder / _CONDITION_FILE).write_text(condition, encoding="utf-8")
return _read(slug)
def set_icon(slug: str, content: bytes, ext: str) -> dict:
folder = _dir(slug)
if not (folder / _META_FILE).is_file():
raise NotFoundError("Ачивка не найдена.")
old = _icon_path(slug)
if old is not None:
try:
old.unlink()
except OSError:
pass
(folder / f"icon.{ext}").write_bytes(content)
return _read(slug)
def icon_file(slug: str) -> tuple[Path, str]:
"""Путь к файлу иконки и его media_type; NotFound, если иконки нет."""
icon = _icon_path(slug)
if icon is None:
raise NotFoundError("Иконка не найдена.")
return icon, avatar_media_type(icon.suffix.lstrip("."))
def delete(slug: str) -> None:
folder = _dir(slug)
if not folder.is_dir():
raise NotFoundError("Ачивка не найдена.")
shutil.rmtree(folder)
def validate_icon(content: bytes) -> str:
"""Проверяет, что это поддерживаемая картинка, и возвращает расширение."""
ext = sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
return ext
def _write_meta(slug: str, name: str, description: str, created_at: str | None = None) -> None:
meta = {
"name": name,
"description": description,
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
}
(_dir(slug) / _META_FILE).write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
)
+113
View File
@@ -0,0 +1,113 @@
"""Ачивки: админ создаёт/правит/удаляет, грузит иконку; condition хранится как текст."""
from __future__ import annotations
import json
from pathlib import Path
from fastapi.testclient import TestClient
from tests.conftest import csrf_headers
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def _admin(client: TestClient, make_admin) -> None:
make_admin("admin", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
def _use_tmp_achievements(monkeypatch, tmp_path) -> Path:
from app.core.config import settings
monkeypatch.setattr(settings, "dev_achievements_dir", str(tmp_path))
return tmp_path
def test_create_list_and_files(client: TestClient, make_admin, monkeypatch, tmp_path):
root = _use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
r = client.post(
"/api/admin/achievements",
json={"name": "Великий полководец", "description": "10 побед подряд"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
ach = r.json()
slug = ach["slug"]
assert slug and ach["name"] == "Великий полководец"
# На диске создана папка с meta.json и condition.py (задел, не исполняется).
folder = root / slug
assert (folder / "meta.json").is_file()
assert (folder / "condition.py").is_file()
meta = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
assert meta["name"] == "Великий полководец"
# Виден в админском списке (редактор использует именно его).
assert any(a["slug"] == slug for a in client.get("/api/admin/achievements").json())
def test_duplicate_name_conflicts(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
body = {"name": "Берсерк"}
assert client.post("/api/admin/achievements", json=body, headers=csrf_headers(client)).status_code == 200
r = client.post("/api/admin/achievements", json=body, headers=csrf_headers(client))
assert r.status_code == 409, r.text
def test_icon_upload_and_serve(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
slug = client.post(
"/api/admin/achievements", json={"name": "Герой"}, headers=csrf_headers(client)
).json()["slug"]
# Не картинка → 422.
bad = client.put(
f"/api/admin/achievements/{slug}/icon",
files={"file": ("x.txt", b"nope", "text/plain")},
headers=csrf_headers(client),
)
assert bad.status_code == 422, bad.text
# Валидный PNG → icon_url, файл отдаётся.
ok = client.put(
f"/api/admin/achievements/{slug}/icon",
files={"file": ("h.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert ok.status_code == 200 and ok.json()["icon_url"], ok.text
g = client.get(f"/api/achievements/{slug}/icon")
assert g.status_code == 200 and g.content == PNG
def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path):
_use_tmp_achievements(monkeypatch, tmp_path)
_admin(client, make_admin)
slug = client.post(
"/api/admin/achievements", json={"name": "Стратег"}, headers=csrf_headers(client)
).json()["slug"]
# Правка описания и текста условия (condition хранится как текст).
r = client.patch(
f"/api/admin/achievements/{slug}",
json={"description": "Победа по целям", "condition": "wins_by_objectives >= 5"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["description"] == "Победа по целям"
assert r.json()["has_condition"] is True
assert "wins_by_objectives" in r.json()["condition"]
# Удаление.
assert client.delete(
f"/api/admin/achievements/{slug}", headers=csrf_headers(client)
).status_code == 200
assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json())
+2
View File
@@ -23,6 +23,7 @@ services:
volumes:
- db-data-test:/data # изолированные тестовые данные
- uploads-data-test:/data/uploads
- achievements-data-test:/data/achievements # определения ачивок (файлы)
healthcheck:
test:
- CMD
@@ -58,3 +59,4 @@ services:
volumes:
db-data-test:
uploads-data-test:
achievements-data-test:
+2
View File
@@ -15,6 +15,7 @@ services:
volumes:
- db-data:/data # БД SQLite + WAL-сайдкары
- uploads-data:/data/uploads # задел под фото/видео
- achievements-data:/data/achievements # определения ачивок (файлы)
healthcheck:
test:
- CMD
@@ -56,3 +57,4 @@ services:
volumes:
db-data:
uploads-data:
achievements-data:
+1
View File
@@ -20,4 +20,5 @@ export const qk = {
adminGroups: ["adminGroups"] as const,
adminMatches: ["adminMatches"] as const,
adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const,
};
+339
View File
@@ -509,6 +509,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/achievements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Achievements */
get: operations["list_achievements_api_achievements_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/achievements/{slug}/icon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Icon */
get: operations["get_icon_api_achievements__slug__icon_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/login": {
parameters: {
query?: never;
@@ -698,6 +732,59 @@ export interface paths {
patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
trace?: never;
};
"/api/admin/achievements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Achievements */
get: operations["list_achievements_api_admin_achievements_get"];
put?: never;
/** Create Achievement */
post: operations["create_achievement_api_admin_achievements_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/achievements/{slug}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Achievement */
delete: operations["delete_achievement_api_admin_achievements__slug__delete"];
options?: never;
head?: never;
/** Update Achievement */
patch: operations["update_achievement_api_admin_achievements__slug__patch"];
trace?: never;
};
"/api/admin/achievements/{slug}/icon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Upload Achievement Icon */
put: operations["upload_achievement_icon_api_admin_achievements__slug__icon_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/audit-logs": {
parameters: {
query?: never;
@@ -788,6 +875,48 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AchievementCreate */
AchievementCreate: {
/** Name */
name: string;
/** Description */
description?: string | null;
/** Condition */
condition?: string | null;
};
/** AchievementRead */
AchievementRead: {
/** Slug */
slug: string;
/** Name */
name: string;
/**
* Description
* @default
*/
description: string;
/**
* Condition
* @default
*/
condition: string;
/**
* Has Condition
* @default false
*/
has_condition: boolean;
/** Icon Url */
icon_url?: string | null;
};
/** AchievementUpdate */
AchievementUpdate: {
/** Name */
name?: string | null;
/** Description */
description?: string | null;
/** Condition */
condition?: string | null;
};
/** ActiveGroupUpdate */
ActiveGroupUpdate: {
/** Group Id */
@@ -912,6 +1041,11 @@ export interface components {
/** Telegram Bot Username */
telegram_bot_username?: string | null;
};
/** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
/** File */
file: string;
};
/** Body_upload_my_avatar_api_users_me_avatar_put */
Body_upload_my_avatar_api_users_me_avatar_put: {
/** File */
@@ -2640,6 +2774,57 @@ export interface operations {
};
};
};
list_achievements_api_achievements_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"][];
};
};
};
};
get_icon_api_achievements__slug__icon_get: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_login_api_admin_auth_login_post: {
parameters: {
query?: never;
@@ -3017,6 +3202,160 @@ export interface operations {
};
};
};
list_achievements_api_admin_achievements_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"][];
};
};
};
};
create_achievement_api_admin_achievements_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AchievementCreate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_achievement_api_admin_achievements__slug__delete: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_achievement_api_admin_achievements__slug__patch: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AchievementUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_achievement_icon_api_admin_achievements__slug__icon_put: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_achievement_icon_api_admin_achievements__slug__icon_put"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
audit_logs_api_admin_audit_logs_get: {
parameters: {
query?: {
+2
View File
@@ -12,6 +12,7 @@ import { MatchDetailPage } from "../pages/MatchDetailPage";
import { OverallStatsPage } from "../pages/OverallStatsPage";
import { PublicProfilePage } from "../pages/PublicProfilePage";
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
import { AdminLayout } from "../pages/admin/AdminLayout";
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
@@ -65,6 +66,7 @@ export const router = createBrowserRouter([
{ path: "records", element: <AdminRecordsPage /> },
{ path: "accounts", element: <AdminAccountsPage /> },
{ path: "factions", element: <AdminFactionsPage /> },
{ path: "achievements", element: <AdminAchievementsPage /> },
{ path: "logs", element: <AdminLogsPage /> },
],
},
+1
View File
@@ -26,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"];
export type AdminGroupRead = S["AdminGroupRead"];
export type AdminMatchRead = S["AdminMatchRead"];
export type AuditLogList = S["AuditLogList"];
export type AchievementRead = S["AchievementRead"];
export type ParticipantInput = S["ParticipantInput"];
export type MatchUpdate = S["MatchUpdate"];
export type MatchCreate = S["MatchCreate"];
+87 -1
View File
@@ -1,9 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useAdminMe() {
return useQuery({
queryKey: qk.adminMe,
@@ -163,3 +168,84 @@ export function useAdminUpdateUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
});
}
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
interface AchievementInput {
name?: string;
description?: string;
condition?: string;
}
export function useAdminAchievements() {
return useQuery({
queryKey: qk.adminAchievements,
queryFn: async () => unwrap(await api.GET("/api/admin/achievements")),
});
}
export function useCreateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: AchievementInput & { name: string }) =>
unwrap(await api.POST("/api/admin/achievements", { body })),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useUpdateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { slug: string; body: AchievementInput }) =>
unwrap(
await api.PATCH("/api/admin/achievements/{slug}", {
params: { path: { slug: args.slug } },
body: args.body,
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useDeleteAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (slug: string) =>
unwrap(
await api.DELETE("/api/admin/achievements/{slug}", {
params: { path: { slug } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie.
export function useUploadAchievementIcon() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ slug, file }: { slug: string; file: File }) => {
const form = new FormData();
form.append("file", file, file.name);
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
method: "PUT",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
@@ -0,0 +1,192 @@
import { useRef, useState } from "react";
import { ApiError } from "../../api/client";
import { Avatar } from "../../components/Avatar";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import {
useAdminAchievements,
useCreateAchievement,
useDeleteAchievement,
useUpdateAchievement,
useUploadAchievementIcon,
} from "../../hooks/admin";
import type { AchievementRead } from "../../domain/types";
export function AdminAchievementsPage() {
const { data: items, isLoading } = useAdminAchievements();
const create = useCreateAchievement();
const toast = useToast();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [condition, setCondition] = useState("");
if (isLoading) return <Spinner />;
const onCreate = async () => {
if (!name.trim()) return;
try {
await create.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
condition: condition.trim() || undefined,
});
setName("");
setDescription("");
setCondition("");
toast.show("Ачивка создана");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div>
<div className="card">
<h3>Новая ачивка</h3>
<div className="field">
<label className="label">Название</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Великий полководец" />
</div>
<div className="field">
<label className="label">Краткое описание</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="За что выдаётся"
/>
</div>
<div className="field">
<label className="label">Скрипт-условие (задел на будущее, пока не исполняется)</label>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="# условие выдачи — добавится позже"
/>
</div>
<button className="btn btn-primary" onClick={onCreate} disabled={create.isPending || !name.trim()}>
Создать
</button>
</div>
<div className="stack">
{(items ?? []).map((a) => (
<AchievementCard key={a.slug} ach={a} />
))}
{(items ?? []).length === 0 && <div className="muted small">Ачивок пока нет.</div>}
</div>
</div>
);
}
function AchievementCard({ ach }: { ach: AchievementRead }) {
const update = useUpdateAchievement();
const del = useDeleteAchievement();
const uploadIcon = useUploadAchievementIcon();
const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(ach.name);
const [description, setDescription] = useState(ach.description);
const [condition, setCondition] = useState(ach.condition);
const save = async () => {
try {
await update.mutateAsync({
slug: ach.slug,
body: { name: name.trim(), description, condition },
});
setEditing(false);
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onIcon = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await uploadIcon.mutateAsync({ slug: ach.slug, file });
toast.show("Иконка обновлена");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const remove = async () => {
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
try {
await del.mutateAsync(ach.slug);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div className="card" style={{ margin: 0 }}>
<div className="row" style={{ gap: 12, alignItems: "flex-start" }}>
<Avatar url={ach.icon_url} nickname={ach.name} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<div className="stack" style={{ gap: 8 }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Название" />
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание"
/>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="Скрипт-условие (задел)"
/>
<div className="row" style={{ gap: 8 }}>
<button className="btn btn-primary small" onClick={save} disabled={update.isPending || !name.trim()}>
Сохранить
</button>
<button className="btn btn-ghost small" onClick={() => setEditing(false)}>
Отмена
</button>
</div>
</div>
) : (
<>
<b>{ach.name}</b>
<div className="muted small">{ach.slug}</div>
{ach.description && <div style={{ marginTop: 4 }}>{ach.description}</div>}
<div className="muted small" style={{ marginTop: 4 }}>
{ach.has_condition ? "условие задано (не исполняется)" : "условие не задано"}
</div>
</>
)}
</div>
</div>
{!editing && (
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="btn small" onClick={() => fileRef.current?.click()} disabled={uploadIcon.isPending}>
Иконка
</button>
<button className="btn small" onClick={() => setEditing(true)}>
Изменить
</button>
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}>
Удалить
</button>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: "none" }}
onChange={onIcon}
/>
</div>
)}
</div>
);
}
+3
View File
@@ -32,6 +32,9 @@ export function AdminLayout() {
<NavLink to="/admin/factions" className={tab}>
Фракции
</NavLink>
<NavLink to="/admin/achievements" className={tab}>
Ачивки
</NavLink>
<NavLink to="/admin/logs" className={tab}>
Логи
</NavLink>