Редактор ачивок в админке + файловое хранилище ачивок (задел под титулы)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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
|
||||||
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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__ = (
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+339
@@ -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?: {
|
||||||
|
|||||||
@@ -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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user