diff --git a/Dockerfile b/Dockerfile index 8dc3137..bf58edc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ COPY --from=frontend /app/frontend/dist /app/static RUN sed -i 's/\r$//' /app/entrypoint.sh \ && chmod +x /app/entrypoint.sh \ && useradd --create-home --uid 10001 appuser \ - && mkdir -p /data/uploads \ + && mkdir -p /data/uploads /data/achievements \ && chown -R appuser:appuser /app /data USER appuser diff --git a/backend/alembic/versions/0005_achievements.py b/backend/alembic/versions/0005_achievements.py new file mode 100644 index 0000000..cb97d89 --- /dev/null +++ b/backend/alembic/versions/0005_achievements.py @@ -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 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 59b8a45..e73c3aa 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -45,6 +45,10 @@ class Settings(BaseSettings): dev_upload_dir: str = "./data/dev/uploads" prod_upload_dir: str = "/data/uploads" + # Каталог ачивок (папка на ачивку: meta.json, condition.py, icon.*), раздельно. + dev_achievements_dir: str = "./data/dev/achievements" + prod_achievements_dir: str = "/data/achievements" + # JWT / cookie secret_key: str = "change-me-dev-secret-not-for-production" jwt_algorithm: str = "HS256" @@ -97,6 +101,10 @@ class Settings(BaseSettings): def upload_dir(self) -> str: return self.dev_upload_dir if self.is_development else self.prod_upload_dir + @property + def achievements_dir(self) -> str: + return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir + @property def cookie_secure(self) -> bool: """Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение — diff --git a/backend/app/main.py b/backend/app/main.py index 8c2f2cb..f250ed1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,7 +15,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.core import security from app.core.config import settings from app.core.errors import AppError, app_error_handler -from app.routers import admin, auth, groups, matches, reference, stats, users +from app.routers import achievements, admin, auth, groups, matches, reference, stats, users # Каталог со сборкой фронта (в Docker — backend/static; локально может отсутствовать). _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.parent / "static"))) @@ -107,7 +107,7 @@ def create_app() -> FastAPI: # API-роутеры под /api. api_routers = [auth.router, users.router, groups.router, matches.router, - reference.router, stats.router, admin.router] + reference.router, stats.router, achievements.router, admin.router] for r in api_routers: app.include_router(r, prefix="/api") diff --git a/backend/app/models.py b/backend/app/models.py index 30b964d..2b635c3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -98,6 +98,9 @@ class User(SQLModel, table=True): ) ) is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1")) + # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL + # (выдача ачивок игрокам — следующий этап). + title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True)) created_at: datetime = Field(default_factory=_utcnow, nullable=False) updated_at: datetime = Field( default_factory=_utcnow, @@ -106,6 +109,26 @@ class User(SQLModel, table=True): ) +class UserAchievement(SQLModel, table=True): + """Полученные игроком ачивки. Задел под будущую выдачу — сейчас не заполняется. + + Ссылается на ачивку по slug (источник правды — папки на диске, не таблица БД).""" + + __tablename__ = "user_achievements" + __table_args__ = ( + UniqueConstraint("user_id", "achievement_slug", name="uq_user_achievement"), + ) + + id: int | None = Field(default=None, primary_key=True) + user_id: int = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + ) + achievement_slug: str = Field(sa_column=Column(String(64), nullable=False)) + earned_at: datetime = Field(default_factory=_utcnow, nullable=False) + + class AuthIdentity(SQLModel, table=True): __tablename__ = "auth_identity" __table_args__ = ( diff --git a/backend/app/routers/achievements.py b/backend/app/routers/achievements.py new file mode 100644 index 0000000..b3331e7 --- /dev/null +++ b/backend/app/routers/achievements.py @@ -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"} + ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a615b1b..3d3cecf 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,7 +1,7 @@ """Админ-роутер: отдельный вход (логин+пароль) и управление сущностями.""" from __future__ import annotations -from fastapi import APIRouter, Depends, Query, Request, Response +from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile from sqlmodel import Session from app.auth.deps import get_current_admin @@ -11,7 +11,15 @@ from app.db.session import get_session from app.models import User from app.routers.matches import build_match_read from app.schemas import api as s -from app.services import admin_service, audit_service, faction_service, match_service +from app.services import ( + achievement_service, + admin_service, + audit_service, + faction_service, + match_service, +) + +_ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ from app.services.match_service import ParticipantInput router = APIRouter(prefix="/admin", tags=["admin"]) @@ -284,6 +292,82 @@ def delete_match( return s.OkResponse() +# ─── Ачивки (определения; выдача игрокам — на будущее) ──────────────────────── + +@router.get("/achievements", response_model=list[s.AchievementRead]) +def list_achievements( + _admin: User = Depends(get_current_admin), +) -> list[dict]: + return achievement_service.list_achievements() + + +@router.post("/achievements", response_model=s.AchievementRead) +def create_achievement( + body: s.AchievementCreate, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> dict: + ach = achievement_service.create(body.name, body.description, body.condition) + audit_service.record( + session, actor_id=admin.id, action="create", entity_type="achievement", + payload={"slug": ach["slug"], "name": ach["name"]}, + ip=request.client.host if request.client else None, + ) + session.commit() + return ach + + +@router.patch("/achievements/{slug}", response_model=s.AchievementRead) +def update_achievement( + slug: str, + body: s.AchievementUpdate, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> dict: + ach = achievement_service.update( + slug, name=body.name, description=body.description, condition=body.condition + ) + audit_service.record( + session, actor_id=admin.id, action="update", entity_type="achievement", + payload={"slug": slug}, ip=request.client.host if request.client else None, + ) + session.commit() + return ach + + +@router.put("/achievements/{slug}/icon", response_model=s.AchievementRead) +def upload_achievement_icon( + slug: str, + file: UploadFile = File(...), + _admin: User = Depends(get_current_admin), +) -> dict: + from app.core.errors import ValidationError + + content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1) + if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES: + raise ValidationError("Файл слишком большой (макс. 2 МБ).") + ext = achievement_service.validate_icon(content) + return achievement_service.set_icon(slug, content, ext) + + +@router.delete("/achievements/{slug}", response_model=s.OkResponse) +def delete_achievement( + slug: str, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + achievement_service.delete(slug) + audit_service.record( + session, actor_id=admin.id, action="delete", entity_type="achievement", + payload={"slug": slug}, ip=request.client.host if request.client else None, + ) + session.commit() + return s.OkResponse() + + # ─── Журнал аудита ─────────────────────────────────────────────────────────── @router.get("/audit-logs", response_model=s.AuditLogList) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 736e473..9e5f466 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -410,6 +410,27 @@ class AdminMatchRead(BaseModel): created_at: str +class AchievementRead(BaseModel): + slug: str + name: str + description: str = "" + condition: str = "" # текст condition.py (задел; не исполняется) + has_condition: bool = False + icon_url: str | None = None + + +class AchievementCreate(BaseModel): + name: str + description: str | None = None + condition: str | None = None + + +class AchievementUpdate(BaseModel): + name: str | None = None + description: str | None = None + condition: str | None = None + + class AuditLogItem(BaseModel): id: int actor_id: int | None = None diff --git a/backend/app/services/achievement_service.py b/backend/app/services/achievement_service.py new file mode 100644 index 0000000..c01ec48 --- /dev/null +++ b/backend/app/services/achievement_service.py @@ -0,0 +1,194 @@ +"""Ачивки: определения хранятся файлами на диске (settings.achievements_dir). + +Папка на ачивку: achievements// + meta.json — {"name", "description", "created_at"} + condition.py — скрипт-условие выдачи (ЗАДЕЛ: только хранится как текст, НЕ исполняется) + icon. — картинка-герб (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" + ) diff --git a/backend/tests/test_achievements.py b/backend/tests/test_achievements.py new file mode 100644 index 0000000..d940ded --- /dev/null +++ b/backend/tests/test_achievements.py @@ -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()) diff --git a/docker-compose.test.yml b/docker-compose.test.yml index a306d57..a894668 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -23,6 +23,7 @@ services: volumes: - db-data-test:/data # изолированные тестовые данные - uploads-data-test:/data/uploads + - achievements-data-test:/data/achievements # определения ачивок (файлы) healthcheck: test: - CMD @@ -58,3 +59,4 @@ services: volumes: db-data-test: uploads-data-test: + achievements-data-test: diff --git a/docker-compose.yml b/docker-compose.yml index db82efe..c16c27c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: volumes: - db-data:/data # БД SQLite + WAL-сайдкары - uploads-data:/data/uploads # задел под фото/видео + - achievements-data:/data/achievements # определения ачивок (файлы) healthcheck: test: - CMD @@ -56,3 +57,4 @@ services: volumes: db-data: uploads-data: + achievements-data: diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index a8a6d75..21c0625 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -20,4 +20,5 @@ export const qk = { adminGroups: ["adminGroups"] as const, adminMatches: ["adminMatches"] as const, adminLogs: ["adminLogs"] as const, + adminAchievements: ["adminAchievements"] as const, }; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index e0fd82e..6f6c422 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -509,6 +509,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/achievements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Achievements */ + get: operations["list_achievements_api_achievements_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/achievements/{slug}/icon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Icon */ + get: operations["get_icon_api_achievements__slug__icon_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/login": { parameters: { query?: never; @@ -698,6 +732,59 @@ export interface paths { patch: operations["rename_faction_api_admin_factions__faction_id__patch"]; trace?: never; }; + "/api/admin/achievements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Achievements */ + get: operations["list_achievements_api_admin_achievements_get"]; + put?: never; + /** Create Achievement */ + post: operations["create_achievement_api_admin_achievements_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/achievements/{slug}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Achievement */ + delete: operations["delete_achievement_api_admin_achievements__slug__delete"]; + options?: never; + head?: never; + /** Update Achievement */ + patch: operations["update_achievement_api_admin_achievements__slug__patch"]; + trace?: never; + }; + "/api/admin/achievements/{slug}/icon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Upload Achievement Icon */ + put: operations["upload_achievement_icon_api_admin_achievements__slug__icon_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/audit-logs": { parameters: { query?: never; @@ -788,6 +875,48 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** AchievementCreate */ + AchievementCreate: { + /** Name */ + name: string; + /** Description */ + description?: string | null; + /** Condition */ + condition?: string | null; + }; + /** AchievementRead */ + AchievementRead: { + /** Slug */ + slug: string; + /** Name */ + name: string; + /** + * Description + * @default + */ + description: string; + /** + * Condition + * @default + */ + condition: string; + /** + * Has Condition + * @default false + */ + has_condition: boolean; + /** Icon Url */ + icon_url?: string | null; + }; + /** AchievementUpdate */ + AchievementUpdate: { + /** Name */ + name?: string | null; + /** Description */ + description?: string | null; + /** Condition */ + condition?: string | null; + }; /** ActiveGroupUpdate */ ActiveGroupUpdate: { /** Group Id */ @@ -912,6 +1041,11 @@ export interface components { /** Telegram Bot Username */ telegram_bot_username?: string | null; }; + /** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */ + Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: { + /** File */ + file: string; + }; /** Body_upload_my_avatar_api_users_me_avatar_put */ Body_upload_my_avatar_api_users_me_avatar_put: { /** File */ @@ -2640,6 +2774,57 @@ export interface operations { }; }; }; + list_achievements_api_achievements_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"][]; + }; + }; + }; + }; + get_icon_api_achievements__slug__icon_get: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; admin_login_api_admin_auth_login_post: { parameters: { query?: never; @@ -3017,6 +3202,160 @@ export interface operations { }; }; }; + list_achievements_api_admin_achievements_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"][]; + }; + }; + }; + }; + create_achievement_api_admin_achievements_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AchievementCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_achievement_api_admin_achievements__slug__delete: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_achievement_api_admin_achievements__slug__patch: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AchievementUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_achievement_icon_api_admin_achievements__slug__icon_put: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_achievement_icon_api_admin_achievements__slug__icon_put"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AchievementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; audit_logs_api_admin_audit_logs_get: { parameters: { query?: { diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 4da43bc..99ab836 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -12,6 +12,7 @@ import { MatchDetailPage } from "../pages/MatchDetailPage"; import { OverallStatsPage } from "../pages/OverallStatsPage"; import { PublicProfilePage } from "../pages/PublicProfilePage"; import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; +import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage"; import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; import { AdminLayout } from "../pages/admin/AdminLayout"; import { AdminLogsPage } from "../pages/admin/AdminLogsPage"; @@ -65,6 +66,7 @@ export const router = createBrowserRouter([ { path: "records", element: }, { path: "accounts", element: }, { path: "factions", element: }, + { path: "achievements", element: }, { path: "logs", element: }, ], }, diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index 1cc8557..1372b52 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -26,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"]; export type AdminGroupRead = S["AdminGroupRead"]; export type AdminMatchRead = S["AdminMatchRead"]; export type AuditLogList = S["AuditLogList"]; +export type AchievementRead = S["AchievementRead"]; export type ParticipantInput = S["ParticipantInput"]; export type MatchUpdate = S["MatchUpdate"]; export type MatchCreate = S["MatchCreate"]; diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index ad9c882..24ad51f 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -1,9 +1,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, unwrap } from "../api/client"; +import { ApiError, api, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; import type { AdminMe, MatchUpdate } from "../domain/types"; +function readCsrfToken(): string | null { + const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); + return m ? decodeURIComponent(m[1]) : null; +} + export function useAdminMe() { return useQuery({ queryKey: qk.adminMe, @@ -163,3 +168,84 @@ export function useAdminUpdateUser() { onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }), }); } + +// ─── Ачивки (редактор; выдача игрокам — на будущее) ────────────────────────── + +interface AchievementInput { + name?: string; + description?: string; + condition?: string; +} + +export function useAdminAchievements() { + return useQuery({ + queryKey: qk.adminAchievements, + queryFn: async () => unwrap(await api.GET("/api/admin/achievements")), + }); +} + +export function useCreateAchievement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: AchievementInput & { name: string }) => + unwrap(await api.POST("/api/admin/achievements", { body })), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} + +export function useUpdateAchievement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (args: { slug: string; body: AchievementInput }) => + unwrap( + await api.PATCH("/api/admin/achievements/{slug}", { + params: { path: { slug: args.slug } }, + body: args.body, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} + +export function useDeleteAchievement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (slug: string) => + unwrap( + await api.DELETE("/api/admin/achievements/{slug}", { + params: { path: { slug } }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} + +// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie. +export function useUploadAchievementIcon() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ slug, file }: { slug: string; file: File }) => { + const form = new FormData(); + form.append("file", file, file.name); + const csrf = readCsrfToken(); + const base = import.meta.env.VITE_API_BASE_URL || ""; + const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, { + method: "PUT", + body: form, + credentials: "include", + headers: csrf ? { "X-CSRF-Token": csrf } : {}, + }); + if (!r.ok) { + let env: { code?: string; message?: string } | undefined; + try { + env = ((await r.json()) as { error?: typeof env }).error; + } catch { + /* тело без JSON */ + } + throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); + } + return await r.json(); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), + }); +} diff --git a/frontend/src/pages/admin/AdminAchievementsPage.tsx b/frontend/src/pages/admin/AdminAchievementsPage.tsx new file mode 100644 index 0000000..99016b6 --- /dev/null +++ b/frontend/src/pages/admin/AdminAchievementsPage.tsx @@ -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 ; + + 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 ( +
+
+

Новая ачивка

+
+ + setName(e.target.value)} placeholder="Например: Великий полководец" /> +
+
+ + setDescription(e.target.value)} + placeholder="За что выдаётся" + /> +
+
+ +