import io from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Request, status from PIL import Image, UnidentifiedImageError from sqlalchemy import and_, exists, func, or_, select, true from starlette.concurrency import run_in_threadpool from app.blobs import put_bytes from app.uploads import remove_upload from app.deps import DB, CurrentUser, has_perm, require from app.models import Game, GameVersion, User from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut from app.translation import version_languages router = APIRouter(prefix="/api/games", tags=["games"]) Player = Annotated[User, Depends(require("games.play"))] Uploader = Annotated[User, Depends(require("games.upload"))] MAX_COVER_BYTES = 20 * 1024 * 1024 def visible_versions_filter(user: User): """Versions a user can see: ready published ones (reviewers also see drafts), plus their own in any state.""" if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"): return true() ready = GameVersion.status == "ready" if not has_perm(user, "versions.publish"): ready = and_(ready, GameVersion.visibility == "published") return or_(ready, GameVersion.owner_id == user.id) async def get_game(db: DB, game_id: int) -> Game: game = await db.get(Game, game_id) if not game: raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена") return game def can_edit_game(user: User, game: Game) -> bool: return has_perm(user, "games.edit") or ( has_perm(user, "games.upload") and game.created_by_id == user.id ) @router.get("", response_model=list[GameOut]) async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]: stmt = select(Game).order_by(Game.title) if q.strip(): pattern = f"%{q.strip()}%" stmt = stmt.where( or_( Game.title.ilike(pattern), Game.description.ilike(pattern), Game.tags.any(q.strip().lower()), ) ) if tag: stmt = stmt.where(Game.tags.any(tag.lower())) stmt = stmt.where( exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user)) | (Game.created_by_id == user.id) ) games = list((await db.scalars(stmt.limit(500))).all()) counts: dict[int, dict[str, int]] = {} if games: rows = await db.execute( select(GameVersion.game_id, GameVersion.status, func.count()) .where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user)) .group_by(GameVersion.game_id, GameVersion.status) ) for game_id, st, n in rows: key = "ready" if st == "ready" else "failed" if st == "failed" else "pending" counts.setdefault(game_id, {}).setdefault(key, 0) counts[game_id][key] += n return [ { **GameOut.model_validate(g).model_dump(), "ready_versions": counts.get(g.id, {}).get("ready", 0), "pending_versions": counts.get(g.id, {}).get("pending", 0), "failed_versions": counts.get(g.id, {}).get("failed", 0), } for g in games ] @router.post("", response_model=GameOut, status_code=201) async def create_game(body: GameIn, user: Uploader, db: DB) -> Game: game = Game( title=body.title.strip(), description=body.description, tags=sorted({t.strip().lower() for t in body.tags if t.strip()}), created_by_id=user.id, ) db.add(game) await db.commit() return game @router.get("/{game_id}", response_model=GameDetailOut) async def game_detail(game_id: int, user: Player, db: DB) -> dict: game = await get_game(db, game_id) versions = ( await db.scalars( select(GameVersion) .where(GameVersion.game_id == game.id, visible_versions_filter(user)) .order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at) ) ).all() return {**GameOut.model_validate(game).model_dump(), "versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest)} for v in versions]} @router.patch("/{game_id}", response_model=GameOut) async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game: game = await get_game(db, game_id) if not can_edit_game(user, game): raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") if body.title is not None: game.title = body.title.strip() if body.description is not None: game.description = body.description if body.tags is not None: game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) await db.commit() return game @router.put("/{game_id}/cover", response_model=GameOut) async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game: game = await get_game(db, game_id) if not can_edit_game(user, game): raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") data = await request.body() if len(data) > MAX_COVER_BYTES: raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл") try: game.cover_hash = await run_in_threadpool(make_cover, data) except (UnidentifiedImageError, OSError): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение") await db.commit() return game @router.delete("/{game_id}", status_code=204) async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))], db: DB) -> None: game = await get_game(db, game_id) version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all() await db.delete(game) await db.commit() for vid in version_ids: remove_upload(vid) def make_cover(data: bytes) -> str: img = Image.open(io.BytesIO(data)) img.thumbnail((640, 640)) if img.mode not in ("RGB", "RGBA"): img = img.convert("RGBA") out = io.BytesIO() img.save(out, "WEBP", quality=85) return put_bytes(out.getvalue(), ".webp")[0]