Files
pgames-manager/backend/app/routers/games.py
T
mrleo1nid 19924e1c5a
CI / frontend (push) Failing after 22s
CI / backend (push) Failing after 42s
feat: unlock in-game galleries per owned version
2026-10-27 15:45:22 +03:00

189 lines
7.6 KiB
Python

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 blob_path, put_bytes
from app.deps import DB, CurrentUser, has_perm, require
from app.gallery import available as gallery_available
from app.models import Game, GameVersion, User
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut
from app.translation import version_languages
from app.uploads import remove_upload
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:
validate_gallery(body.gallery_hashes)
if body.cover_hash and not blob_path(body.cover_hash).is_file():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова")
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,
cover_hash=body.cover_hash,
gallery_hashes=list(dict.fromkeys(body.gallery_hashes)),
)
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),
"gallery_available": gallery_available(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.gallery_hashes is not None:
validate_gallery(body.gallery_hashes)
game.gallery_hashes = list(dict.fromkeys(body.gallery_hashes))
if body.cover_hash:
if not blob_path(body.cover_hash).is_file():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова")
game.cover_hash = body.cover_hash
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:
return put_bytes(encode_cover(data), ".webp")[0]
def validate_gallery(hashes: list[str]) -> None:
if any(not blob_path(value).is_file() for value in hashes):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Изображение галереи не найдено, повторите импорт")
def encode_cover(data: bytes, max_size: tuple[int, int] = (640, 640)) -> bytes:
with Image.open(io.BytesIO(data)) as image:
image.thumbnail(max_size)
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA")
out = io.BytesIO()
image.save(out, "WEBP", quality=85)
return out.getvalue()