Files
pgames-manager/backend/app/routers/versions.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

344 lines
15 KiB
Python

import asyncio
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from starlette.requests import ClientDisconnect
from app import translation
from app.deps import DB, CurrentUser, has_perm, require
from app.gallery import available as gallery_available
from app.models import Game, GameVersion, Revision, Save, User, VersionHistory
from app.routers.games import get_game
from app.schemas import (
ForkIn,
GallerySettingsIn,
HistoryOut,
PublishIn,
SaveDetailOut,
SaveIn,
SaveOut,
VersionDetailOut,
VersionIn,
VersionOut,
)
from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"])
Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))]
ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
def can_see(user: User, v: GameVersion) -> bool:
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:
return (
v.owner_id == user.id
or has_perm(user, "versions.manage_any")
or has_perm(user, "games.delete")
)
async def get_version(db: DB, user: User, version_id: int) -> GameVersion:
v = await db.get(GameVersion, version_id)
if not v or not can_see(user, v):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
return v
@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201)
async def upload_version(
game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал"
) -> GameVersion:
"""Stream a raw archive body to disk and queue it for conversion."""
game = await get_game(db, game_id)
if not filename.lower().endswith(ARCHIVE_EXTS):
raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}")
version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал",
upload_name=filename, status="uploading")
db.add(version)
await db.commit()
path = upload_path(version.id)
path.parent.mkdir(parents=True, exist_ok=True)
size = 0
try:
with path.open("wb") as f:
async for chunk in request.stream():
f.write(chunk)
size += len(chunk)
except Exception as e:
remove_upload(version.id)
await db.delete(version)
await db.commit()
if isinstance(e, ClientDisconnect):
# usually a proxy in front cut the body off (e.g. its own 413)
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Загрузка прервана") from None
raise
if size == 0:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
from app.converter.archive import ArchiveError
from app.converter.compatibility import CompatibilityError
from app.converter.preflight import probe_archive
try:
probe = await asyncio.to_thread(probe_archive, path, filename)
except (CompatibilityError, ArchiveError, OSError, ValueError) as exc:
remove_upload(version.id)
await db.delete(version)
await db.commit()
raise HTTPException(422, str(exc)) from None
version.status = "queued"
version.progress = "В очереди на проверку совместимости"
version.report = {"engine": probe["engine"], "compatible": None}
if getattr(game, "main_version_id", None) is None:
game.engine = probe["engine"]
version.size_bytes = size
await db.commit()
return version
@router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id)
return {**VersionDetailOut.model_validate(v).model_dump(),
"gallery_available": await run_in_threadpool(gallery_available, v.manifest)}
@router.get("/versions/{version_id}/manifest")
async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return {**v.manifest, "revision": v.revision_id, "versionId": v.id, "galleryUnlocked": v.gallery_unlocked}
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
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, "Форкнуть можно только готовую версию")
fork = GameVersion(
game_id=parent.game_id,
parent_id=parent.id,
owner_id=user.id,
name=body.name.strip(),
description=body.description,
status="ready",
visibility="draft",
kind=body.kind,
gallery_unlocked=parent.gallery_unlocked,
manifest=parent.manifest,
base_revision_id=parent.revision_id,
report=parent.report,
size_bytes=parent.size_bytes,
)
fork._revision_parents = [parent.revision_id] if parent.revision_id else []
db.add(fork)
await db.commit()
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:
locked = {key: await lock_version(db, key) for key in sorted((v.id, game.main_version_id))}
v, main = locked[v.id], locked[game.main_version_id]
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
if base is None:
raise HTTPException(409, "Историческая база перевода неизвестна. Создайте новый форк перевода")
langs = translation.changed_languages(v.manifest, base.manifest)
if not langs:
raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов")
merged, conflicts = await run_in_threadpool(translation.merge_translation, base.manifest, v.manifest, main.manifest)
if conflicts:
raise HTTPException(409, "Конфликт перевода; согласуйте изменения строк: " + ", ".join(c["path"] for c in conflicts[:10]))
db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest,
note=f"Перевод из «{v.name}»: {', '.join(langs)}"))
main._revision_parents = [main.revision_id, v.revision_id]
main.manifest = merged
main.size_bytes = sum(f["size"] for f in merged["files"].values())
else:
v = await lock_version(db, v.id)
if 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)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
v.name = body.name.strip()
v.description = body.description
await db.commit()
return v
@router.patch("/versions/{version_id}/gallery", response_model=VersionOut)
async def update_gallery(version_id: int, body: GallerySettingsIn, user: Player, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if v.owner_id != user.id:
raise HTTPException(403, "Открыть галерею можно только в своей версии")
if v.status != "ready":
raise HTTPException(409, "Версия ещё не готова")
if body.gallery_unlocked and not await run_in_threadpool(gallery_available, v.manifest):
raise HTTPException(422, "Галерея этой игры пока не поддерживается")
v.gallery_unlocked = body.gallery_unlocked
await db.commit()
return v
@router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status != "failed" or not upload_path(v.id).exists():
raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна")
v.status = "queued"
v.progress = "В очереди на конвертацию"
await db.commit()
return v
@router.delete("/versions/{version_id}", status_code=204)
async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
v = await get_version(db, user, version_id)
if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status in ("converting", "importing"):
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
await db.delete(v)
await db.commit()
remove_upload(version_id)
# --- cloud saves -----------------------------------------------------------
# Saves live in the browser by default; these endpoints are the opt-in cloud copy.
CloudSaves = Annotated[User, Depends(require("games.play", "saves.cloud"))]
def _check_slot(slot: str) -> str:
if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот")
return slot
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
async def list_saves(version_id: int, user: CloudSaves, db: DB) -> list[Save]:
await get_version(db, user, version_id)
return list((await db.scalars(
select(Save).where(Save.user_id == user.id, Save.version_id == version_id)
.order_by(Save.updated_at.desc())
)).all())
async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None:
return await db.scalar(select(Save).where(
Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot)))
@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
async def get_save(version_id: int, slot: str, user: CloudSaves, db: DB) -> Save:
save = await _find_save(db, user, version_id, slot)
if not save:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
return save
@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut)
async def put_save(version_id: int, slot: str, body: SaveIn, user: CloudSaves, db: DB) -> Save:
await get_version(db, user, version_id)
save = await _find_save(db, user, version_id, slot)
if save is None:
save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255],
data=body.data)
db.add(save)
else:
save.title = body.title[:255]
save.data = body.data
await db.commit()
await db.refresh(save)
return save
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
async def delete_save(version_id: int, slot: str, user: CloudSaves, db: DB) -> None:
save = await _find_save(db, user, version_id, slot)
if save:
await db.delete(save)
await db.commit()