Files
pgames-manager/backend/app/routers/versions.py
T
mrleo1nidandClaude Opus 5.5 51a7e77e56 Add .gitattributes: LF in the repository, CRLF for batch files
Normalize the files that had been committed with CRLF (line endings only,
no content changes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 03:41:24 +03:00

206 lines
8.1 KiB
Python

from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
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.routers.games import get_game
from app.schemas import 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:
return v.status == "ready" 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, "Пустой файл")
version.status = "queued"
version.progress = "В очереди на конвертацию"
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) -> GameVersion:
return await get_version(db, user, version_id)
@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
@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"))],
db: DB) -> GameVersion:
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",
manifest=parent.manifest,
report=parent.report,
size_bytes=parent.size_bytes,
)
db.add(fork)
await db.commit()
return fork
@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.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)
# --- saves -----------------------------------------------------------------
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: Player, 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: Player, 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: Player, 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: Player, db: DB) -> None:
save = await _find_save(db, user, version_id, slot)
if save:
await db.delete(save)
await db.commit()