"""Ачивки: определения хранятся файлами на диске (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" )