- games.main_version_id, version visibility (draft/published) and kind
(original/translation/mod), version_history; migration 0002
- permissions translations.edit and versions.publish
- "Translate" creates a translation draft; publishing merges the changed
languages into the main version in place (players keep their saves), the
previous manifest goes to the history and can be restored
- translation API: overview with per-state stats, entries with filters,
search and scene context, save, glossary, locale font, PO/CSV export/import
- translation editor page: languages and progress, lines and strings,
filters, autosave, approve/outdated/fuzzy actions, glossary highlighting,
"play from here" with the scene restored
- player: ?lang= and ?at= from the editor, per-locale font
(models.py and permissions.py for this landed earlier in 9ee3e1f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
6.3 KiB
Python
166 lines
6.3 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 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]
|