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

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 \ RUN sed -i 's/\r$//' /app/entrypoint.sh \
&& chmod +x /app/entrypoint.sh \ && chmod +x /app/entrypoint.sh \
&& useradd --create-home --uid 10001 appuser \ && useradd --create-home --uid 10001 appuser \
&& mkdir -p /data/uploads \ && mkdir -p /data/uploads /data/achievements \
&& chown -R appuser:appuser /app /data && chown -R appuser:appuser /app /data
USER appuser 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" dev_upload_dir: str = "./data/dev/uploads"
prod_upload_dir: str = "/data/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 # JWT / cookie
secret_key: str = "change-me-dev-secret-not-for-production" secret_key: str = "change-me-dev-secret-not-for-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
@@ -97,6 +101,10 @@ class Settings(BaseSettings):
def upload_dir(self) -> str: def upload_dir(self) -> str:
return self.dev_upload_dir if self.is_development else self.prod_upload_dir 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 @property
def cookie_secure(self) -> bool: def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение — """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 import security
from app.core.config import settings from app.core.config import settings
from app.core.errors import AppError, app_error_handler 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; локально может отсутствовать). # Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать).
_STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "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-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, matches.router, 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: for r in api_routers:
app.include_router(r, prefix="/api") 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")) 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) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
updated_at: datetime = Field( updated_at: datetime = Field(
default_factory=_utcnow, 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): class AuthIdentity(SQLModel, table=True):
__tablename__ = "auth_identity" __tablename__ = "auth_identity"
__table_args__ = ( __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 __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 sqlmodel import Session
from app.auth.deps import get_current_admin 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.models import User
from app.routers.matches import build_match_read from app.routers.matches import build_match_read
from app.schemas import api as s 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 from app.services.match_service import ParticipantInput
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -284,6 +292,82 @@ def delete_match(
return s.OkResponse() 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) @router.get("/audit-logs", response_model=s.AuditLogList)
+21
View File
@@ -410,6 +410,27 @@ class AdminMatchRead(BaseModel):
created_at: str 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): class AuditLogItem(BaseModel):
id: int id: int
actor_id: int | None = None 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: volumes:
- db-data-test:/data # изолированные тестовые данные - db-data-test:/data # изолированные тестовые данные
- uploads-data-test:/data/uploads - uploads-data-test:/data/uploads
- achievements-data-test:/data/achievements # определения ачивок (файлы)
healthcheck: healthcheck:
test: test:
- CMD - CMD
@@ -58,3 +59,4 @@ services:
volumes: volumes:
db-data-test: db-data-test:
uploads-data-test: uploads-data-test:
achievements-data-test:
+2
View File
@@ -15,6 +15,7 @@ services:
volumes: volumes:
- db-data:/data # БД SQLite + WAL-сайдкары - db-data:/data # БД SQLite + WAL-сайдкары
- uploads-data:/data/uploads # задел под фото/видео - uploads-data:/data/uploads # задел под фото/видео
- achievements-data:/data/achievements # определения ачивок (файлы)
healthcheck: healthcheck:
test: test:
- CMD - CMD
@@ -56,3 +57,4 @@ services:
volumes: volumes:
db-data: db-data:
uploads-data: uploads-data:
achievements-data:
+1
View File
@@ -20,4 +20,5 @@ export const qk = {
adminGroups: ["adminGroups"] as const, adminGroups: ["adminGroups"] as const,
adminMatches: ["adminMatches"] as const, adminMatches: ["adminMatches"] as const,
adminLogs: ["adminLogs"] as const, adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const,
}; };
+339
View File
@@ -509,6 +509,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/admin/auth/login": {
parameters: { parameters: {
query?: never; query?: never;
@@ -698,6 +732,59 @@ export interface paths {
patch: operations["rename_faction_api_admin_factions__faction_id__patch"]; patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
trace?: never; 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": { "/api/admin/audit-logs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -788,6 +875,48 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { 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 */
ActiveGroupUpdate: { ActiveGroupUpdate: {
/** Group Id */ /** Group Id */
@@ -912,6 +1041,11 @@ export interface components {
/** Telegram Bot Username */ /** Telegram Bot Username */
telegram_bot_username?: string | null; 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 */
Body_upload_my_avatar_api_users_me_avatar_put: { Body_upload_my_avatar_api_users_me_avatar_put: {
/** File */ /** 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: { admin_login_api_admin_auth_login_post: {
parameters: { parameters: {
query?: never; 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: { audit_logs_api_admin_audit_logs_get: {
parameters: { parameters: {
query?: { query?: {
+2
View File
@@ -12,6 +12,7 @@ import { MatchDetailPage } from "../pages/MatchDetailPage";
import { OverallStatsPage } from "../pages/OverallStatsPage"; import { OverallStatsPage } from "../pages/OverallStatsPage";
import { PublicProfilePage } from "../pages/PublicProfilePage"; import { PublicProfilePage } from "../pages/PublicProfilePage";
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
import { AdminLayout } from "../pages/admin/AdminLayout"; import { AdminLayout } from "../pages/admin/AdminLayout";
import { AdminLogsPage } from "../pages/admin/AdminLogsPage"; import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
@@ -65,6 +66,7 @@ export const router = createBrowserRouter([
{ path: "records", element: <AdminRecordsPage /> }, { path: "records", element: <AdminRecordsPage /> },
{ path: "accounts", element: <AdminAccountsPage /> }, { path: "accounts", element: <AdminAccountsPage /> },
{ path: "factions", element: <AdminFactionsPage /> }, { path: "factions", element: <AdminFactionsPage /> },
{ path: "achievements", element: <AdminAchievementsPage /> },
{ path: "logs", element: <AdminLogsPage /> }, { path: "logs", element: <AdminLogsPage /> },
], ],
}, },
+1
View File
@@ -26,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"];
export type AdminGroupRead = S["AdminGroupRead"]; export type AdminGroupRead = S["AdminGroupRead"];
export type AdminMatchRead = S["AdminMatchRead"]; export type AdminMatchRead = S["AdminMatchRead"];
export type AuditLogList = S["AuditLogList"]; export type AuditLogList = S["AuditLogList"];
export type AchievementRead = S["AchievementRead"];
export type ParticipantInput = S["ParticipantInput"]; export type ParticipantInput = S["ParticipantInput"];
export type MatchUpdate = S["MatchUpdate"]; export type MatchUpdate = S["MatchUpdate"];
export type MatchCreate = S["MatchCreate"]; export type MatchCreate = S["MatchCreate"];
+87 -1
View File
@@ -1,9 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types"; 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() { export function useAdminMe() {
return useQuery({ return useQuery({
queryKey: qk.adminMe, queryKey: qk.adminMe,
@@ -163,3 +168,84 @@ export function useAdminUpdateUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }), 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 to="/admin/factions" className={tab}>
Фракции Фракции
</NavLink> </NavLink>
<NavLink to="/admin/achievements" className={tab}>
Ачивки
</NavLink>
<NavLink to="/admin/logs" className={tab}> <NavLink to="/admin/logs" className={tab}>
Логи Логи
</NavLink> </NavLink>