Localization stages 2-3: drafts, publishing, translation editor
- 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>
This commit is contained in:
1 parent
4e047f3aa0
commit
ac164d1782
20 files changed
+2039
-298
No files matched your search
+165
-158
@@ -1,158 +1,165 @@
|
||||
import io
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import 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
|
||||
|
||||
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 ones, plus their own in any state."""
|
||||
if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"):
|
||||
return true()
|
||||
return or_(GameVersion.status == "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": list(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]
|
||||
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]
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Translation editing API for a version."""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app import translation as tr
|
||||
from app.blobs import put_bytes
|
||||
from app.converter.locales import LANG_NAMES
|
||||
from app.deps import DB, has_perm, require
|
||||
from app.models import Game, GameVersion, User
|
||||
from app.routers.versions import can_manage, get_version, lock_version
|
||||
|
||||
router = APIRouter(prefix="/api/versions/{version_id}/i18n", tags=["i18n"])
|
||||
Player = Annotated[User, Depends(require("games.play"))]
|
||||
Translator = Annotated[User, Depends(require("games.play", "translations.edit"))]
|
||||
|
||||
TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"}
|
||||
LANG_RE = r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
|
||||
FONT_EXTS = (".ttf", ".otf", ".woff", ".woff2")
|
||||
MAX_FONT_BYTES = 30 * 1024 * 1024
|
||||
MAX_IMPORT_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
def can_edit(user: User, v: GameVersion) -> bool:
|
||||
"""Drafts are edited by their owner; publishers may also fix published versions."""
|
||||
if not has_perm(user, "translations.edit"):
|
||||
return False
|
||||
if has_perm(user, "versions.publish"):
|
||||
return True
|
||||
return v.visibility == "draft" and can_manage(user, v)
|
||||
|
||||
|
||||
async def ready_version(db: DB, user: User, version_id: int) -> GameVersion:
|
||||
v = await get_version(db, user, version_id)
|
||||
if v.status != "ready" or not v.manifest:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
|
||||
return v
|
||||
|
||||
|
||||
async def editable_version(db: DB, user: User, version_id: int) -> GameVersion:
|
||||
await ready_version(db, user, version_id)
|
||||
v = await lock_version(db, version_id)
|
||||
if not can_edit(user, v):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN,
|
||||
"Переводить можно в своём черновике (форке «перевод»)")
|
||||
return v
|
||||
|
||||
|
||||
def check_lang(lang: str) -> str:
|
||||
import re
|
||||
if not re.match(LANG_RE, lang):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Код языка в формате BCP-47, например ru")
|
||||
return lang
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def overview(version_id: int, user: Player, db: DB) -> dict[str, Any]:
|
||||
v = await ready_version(db, user, version_id)
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
game = tr.file_json(v.manifest, "game.json")
|
||||
idx = tr.text_index(v.manifest)
|
||||
langs = []
|
||||
for code, info in sorted((game.get("locales") or {}).items()):
|
||||
langs.append({"code": code, "name": info.get("name", code), "font": info.get("font"),
|
||||
**tr.stats(v.manifest, code)})
|
||||
return {
|
||||
"sourceLang": game.get("sourceLang"),
|
||||
"editable": can_edit(user, v),
|
||||
"canApprove": has_perm(user, "versions.publish"),
|
||||
"lines": len(idx.lines),
|
||||
"strings": len(idx.strings),
|
||||
"modules": [{"path": p, "lines": n} for p, n in idx.modules if n],
|
||||
"languages": langs,
|
||||
"knownLanguages": LANG_NAMES,
|
||||
}
|
||||
|
||||
return await run_in_threadpool(build)
|
||||
|
||||
|
||||
@router.get("/{lang}/entries")
|
||||
async def entries(version_id: int, lang: str, user: Player, db: DB, kind: str = "lines",
|
||||
state: str = "all", module: str = "", q: str = "", offset: int = 0,
|
||||
limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]:
|
||||
v = await ready_version(db, user, version_id)
|
||||
needle = q.strip().lower()
|
||||
|
||||
def matches(st: str, *texts: str) -> bool:
|
||||
if state == "todo" and st not in TODO_STATES:
|
||||
return False
|
||||
if state not in ("all", "todo") and st != state:
|
||||
return False
|
||||
return not needle or any(needle in t.lower() for t in texts if t)
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
idx = tr.text_index(v.manifest)
|
||||
lines, strings, _ = tr.locale_tables(v.manifest, lang)
|
||||
files = v.manifest["files"]
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
if kind == "strings":
|
||||
for src, usage in idx.strings.items():
|
||||
e = strings.get(src) or {}
|
||||
st = e.get("state", "translated") if e.get("t") else "untranslated"
|
||||
if not matches(st, src, e.get("t", "")):
|
||||
continue
|
||||
total += 1
|
||||
if offset < total <= offset + limit:
|
||||
items.append({"key": src, "usage": usage, "source": src,
|
||||
"translation": e.get("t", ""), "state": st})
|
||||
else:
|
||||
for info in idx.lines:
|
||||
if module and info.module != module:
|
||||
continue
|
||||
e = lines.get(info.tid)
|
||||
st = tr.line_state(e, info)
|
||||
if not matches(st, info.source, (e or {}).get("t", ""), info.who):
|
||||
continue
|
||||
total += 1
|
||||
if offset < total <= offset + limit:
|
||||
scene = files.get(info.scene) if info.scene else None
|
||||
items.append({
|
||||
"key": info.tid, "nodeId": info.node_id, "module": info.module,
|
||||
"label": info.label, "who": info.who, "source": info.source,
|
||||
"prev": info.prev, "next": info.next,
|
||||
"scene": f"/blobs/{scene['hash']}" if scene else None,
|
||||
"translation": (e or {}).get("t", ""), "state": st,
|
||||
})
|
||||
return {"total": total, "items": items}
|
||||
|
||||
return await run_in_threadpool(build)
|
||||
|
||||
|
||||
@router.get("/{lang}/meta")
|
||||
async def get_meta(version_id: int, lang: str, user: Player, db: DB) -> dict[str, Any]:
|
||||
v = await ready_version(db, user, version_id)
|
||||
_, _, meta = await run_in_threadpool(tr.locale_tables, v.manifest, lang)
|
||||
return {"glossary": meta.get("glossary", []), "font": meta.get("font")}
|
||||
|
||||
|
||||
class Changes(BaseModel):
|
||||
name: str | None = Field(default=None, max_length=64)
|
||||
lines: dict[str, dict[str, Any] | None] = {}
|
||||
strings: dict[str, dict[str, Any] | None] = {}
|
||||
glossary: list[dict[str, str]] | None = None
|
||||
|
||||
|
||||
@router.put("/{lang}")
|
||||
async def save(version_id: int, lang: str, body: Changes, user: Translator, db: DB
|
||||
) -> dict[str, Any]:
|
||||
"""Create a language or change entries. `null` removes an entry."""
|
||||
check_lang(lang)
|
||||
v = await editable_version(db, user, version_id)
|
||||
meta = None
|
||||
if body.glossary is not None:
|
||||
meta = {"glossary": [{"src": g.get("src", "").strip(), "t": g.get("t", "").strip(),
|
||||
"note": g.get("note", "").strip()}
|
||||
for g in body.glossary if g.get("src", "").strip()]}
|
||||
try:
|
||||
manifest = await run_in_threadpool(
|
||||
lambda: tr.apply_changes(v.manifest, lang, lines=body.lines, strings=body.strings,
|
||||
meta=meta, name=body.name,
|
||||
can_approve=has_perm(user, "versions.publish")))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from None
|
||||
v.manifest = manifest
|
||||
await db.commit()
|
||||
return await run_in_threadpool(tr.stats, manifest, lang)
|
||||
|
||||
|
||||
@router.delete("/{lang}", status_code=204)
|
||||
async def remove_language(version_id: int, lang: str, user: Translator, db: DB) -> None:
|
||||
v = await editable_version(db, user, version_id)
|
||||
files = {p: f for p, f in v.manifest["files"].items() if not p.startswith(f"locales/{lang}/")}
|
||||
game = dict(tr.file_json(v.manifest, "game.json"))
|
||||
game["locales"] = {k: i for k, i in (game.get("locales") or {}).items() if k != lang}
|
||||
tr.put_json(files, "game.json", game)
|
||||
v.manifest = {**v.manifest, "files": files}
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.put("/{lang}/font")
|
||||
async def upload_font(version_id: int, lang: str, filename: str, request: Request,
|
||||
user: Translator, db: DB) -> dict[str, Any]:
|
||||
"""A font for this language, for games whose fonts lack its glyphs."""
|
||||
check_lang(lang)
|
||||
ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
if ext not in FONT_EXTS:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Шрифт: {', '.join(FONT_EXTS)}")
|
||||
data = await request.body()
|
||||
if not data or len(data) > MAX_FONT_BYTES:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой или слишком большой файл")
|
||||
v = await editable_version(db, user, version_id)
|
||||
path = f"locales/{lang}/font{ext}"
|
||||
|
||||
def store() -> dict[str, Any]:
|
||||
blob_id, size = put_bytes(data, ext)
|
||||
manifest = {**v.manifest, "files": {**v.manifest["files"], path: {"hash": blob_id, "size": size}}}
|
||||
return tr.apply_changes(manifest, lang, meta={"font": path})
|
||||
|
||||
v.manifest = await run_in_threadpool(store)
|
||||
await db.commit()
|
||||
return {"font": path}
|
||||
|
||||
|
||||
@router.get("/{lang}/export")
|
||||
async def export(version_id: int, lang: str, user: Player, db: DB, format: str = "po") -> Response:
|
||||
v = await ready_version(db, user, version_id)
|
||||
game = await db.get(Game, v.game_id)
|
||||
if format == "csv":
|
||||
body = await run_in_threadpool(tr.export_csv, v.manifest, lang)
|
||||
media, ext = "text/csv", "csv"
|
||||
else:
|
||||
body = await run_in_threadpool(tr.export_po, v.manifest, lang, game.title)
|
||||
media, ext = "text/x-gettext-translation", "po"
|
||||
name = f"{game.id}-{v.id}-{lang}.{ext}"
|
||||
return Response("" + body if ext == "csv" else body, media_type=f"{media}; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{name}"'})
|
||||
|
||||
|
||||
@router.post("/{lang}/import")
|
||||
async def import_file(version_id: int, lang: str, request: Request, user: Translator, db: DB,
|
||||
format: str = "po", overwrite: bool = True) -> dict[str, Any]:
|
||||
check_lang(lang)
|
||||
data = await request.body()
|
||||
if len(data) > MAX_IMPORT_BYTES:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл")
|
||||
text = data.decode("utf-8-sig", errors="replace")
|
||||
lines, strings = tr.parse_csv(text) if format == "csv" else tr.parse_po(text)
|
||||
v = await editable_version(db, user, version_id)
|
||||
if not overwrite:
|
||||
have_lines, have_strings, _ = tr.locale_tables(v.manifest, lang)
|
||||
lines = {k: x for k, x in lines.items() if not (have_lines.get(k) or {}).get("t")}
|
||||
strings = {k: x for k, x in strings.items() if not (have_strings.get(k) or {}).get("t")}
|
||||
try:
|
||||
v.manifest = await run_in_threadpool(
|
||||
lambda: tr.apply_changes(v.manifest, lang, lines=lines, strings=strings,
|
||||
can_approve=has_perm(user, "versions.publish")))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from None
|
||||
await db.commit()
|
||||
return {"lines": len(lines), "strings": len(strings), **tr.stats(v.manifest, lang)}
|
||||
@@ -5,9 +5,13 @@ from sqlalchemy import select
|
||||
from starlette.requests import ClientDisconnect
|
||||
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.models import GameVersion, Save, User
|
||||
from app.models import Game, GameVersion, Save, User, VersionHistory
|
||||
from app.routers.games import get_game
|
||||
from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut
|
||||
from app.schemas import (
|
||||
ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn,
|
||||
VersionOut,
|
||||
)
|
||||
from app import translation
|
||||
from app.uploads import remove_upload, upload_path
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["versions"])
|
||||
@@ -18,7 +22,8 @@ ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".
|
||||
|
||||
|
||||
def can_see(user: User, v: GameVersion) -> bool:
|
||||
return v.status == "ready" or v.owner_id == user.id or can_manage(user, v)
|
||||
published = v.visibility == "published" or has_perm(user, "versions.publish")
|
||||
return (v.status == "ready" and published) or v.owner_id == user.id or can_manage(user, v)
|
||||
|
||||
|
||||
def can_manage(user: User, v: GameVersion) -> bool:
|
||||
@@ -93,9 +98,12 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
|
||||
async def fork_version(version_id: int, body: VersionIn,
|
||||
user: Annotated[User, Depends(require("games.play", "versions.fork"))],
|
||||
async def fork_version(version_id: int, body: ForkIn,
|
||||
user: Annotated[User, Depends(require("games.play"))],
|
||||
db: DB) -> GameVersion:
|
||||
needed = "translations.edit" if body.kind == "translation" else "versions.fork"
|
||||
if not has_perm(user, needed):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Нет прав: {needed}")
|
||||
parent = await get_version(db, user, version_id)
|
||||
if parent.status != "ready":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
|
||||
@@ -106,6 +114,8 @@ async def fork_version(version_id: int, body: VersionIn,
|
||||
name=body.name.strip(),
|
||||
description=body.description,
|
||||
status="ready",
|
||||
visibility="draft",
|
||||
kind=body.kind,
|
||||
manifest=parent.manifest,
|
||||
report=parent.report,
|
||||
size_bytes=parent.size_bytes,
|
||||
@@ -115,6 +125,78 @@ async def fork_version(version_id: int, body: VersionIn,
|
||||
return fork
|
||||
|
||||
|
||||
Publisher = Annotated[User, Depends(require("versions.publish"))]
|
||||
|
||||
|
||||
async def lock_version(db: DB, version_id: int) -> GameVersion:
|
||||
"""Row-locked version for read-modify-write of its manifest."""
|
||||
v = await db.scalar(select(GameVersion).where(GameVersion.id == version_id)
|
||||
.with_for_update(of=GameVersion)
|
||||
.execution_options(populate_existing=True))
|
||||
if v is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
|
||||
return v
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/publish", response_model=VersionOut)
|
||||
async def publish_version(version_id: int, body: PublishIn, user: Publisher, db: DB) -> GameVersion:
|
||||
"""Publish a draft. A translation is merged into the game's main version in place,
|
||||
so players keep their saves; the previous manifest goes to the history."""
|
||||
v = await get_version(db, user, version_id)
|
||||
if v.status != "ready":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
|
||||
game = await db.get(Game, v.game_id)
|
||||
if v.kind == "translation" and game.main_version_id and game.main_version_id != v.id:
|
||||
main = await lock_version(db, game.main_version_id)
|
||||
parent = await db.get(GameVersion, v.parent_id) if v.parent_id else None
|
||||
langs = translation.changed_languages(v.manifest, parent.manifest if parent else None)
|
||||
if not langs:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов")
|
||||
db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest,
|
||||
note=f"Перевод из «{v.name}»: {', '.join(langs)}"))
|
||||
main.manifest = translation.merge_languages(main.manifest, v.manifest, langs)
|
||||
elif body.make_main:
|
||||
game.main_version_id = v.id
|
||||
v.visibility = "published"
|
||||
await db.commit()
|
||||
await db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/make-main", response_model=VersionOut)
|
||||
async def make_main(version_id: int, user: Publisher, db: DB) -> GameVersion:
|
||||
v = await get_version(db, user, version_id)
|
||||
if v.status != "ready":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
|
||||
game = await db.get(Game, v.game_id)
|
||||
game.main_version_id = v.id
|
||||
v.visibility = "published"
|
||||
await db.commit()
|
||||
await db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.get("/versions/{version_id}/history", response_model=list[HistoryOut])
|
||||
async def version_history(version_id: int, user: Player, db: DB) -> list[VersionHistory]:
|
||||
await get_version(db, user, version_id)
|
||||
return list((await db.scalars(select(VersionHistory).where(VersionHistory.version_id == version_id)
|
||||
.order_by(VersionHistory.id.desc()))).all())
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/history/{history_id}/restore", response_model=VersionOut)
|
||||
async def restore_history(version_id: int, history_id: int, user: Publisher, db: DB) -> GameVersion:
|
||||
v = await lock_version(db, version_id)
|
||||
h = await db.get(VersionHistory, history_id)
|
||||
if h is None or h.version_id != v.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Запись истории не найдена")
|
||||
db.add(VersionHistory(version_id=v.id, user_id=user.id, manifest=v.manifest,
|
||||
note=f"До отката к записи от {h.created_at:%d.%m.%Y %H:%M}"))
|
||||
v.manifest = h.manifest
|
||||
await db.commit()
|
||||
await db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.patch("/versions/{version_id}", response_model=VersionOut)
|
||||
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
|
||||
v = await get_version(db, user, version_id)
|
||||
|
||||
Reference in new issue
Block a user