feat: unlock in-game galleries per owned version
CI / frontend (push) Failing after 22s
CI / backend (push) Failing after 42s

This commit is contained in:
mrleo1nid committed 2026-10-27 15:45:22 +03:00
1 parent e14184435c
commit 19924e1c5a
21 files changed
+977 -395

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
"""Gallery support for converted games; never changes scripts or story progress."""
from app.translation import file_json
def available(manifest: dict | None) -> bool:
if not manifest or "game.json" not in manifest.get("files", {}):
return False
game = file_json(manifest, "game.json")
if game.get("engine") not in (None, "renpy") or game.get("runtime"):
return False
screens = game.get("screens", {})
if game.get("build", {}).get("name") == "MIST" and "rewatchgirlselectscreen" in screens:
return True
def uses_gallery(value):
if isinstance(value, dict):
if value.get("k") == "call" and value.get("f", {}).get("k") == "name" and value["f"].get("id") in {"Gallery", "Replay"}:
return True
return any(uses_gallery(item) for item in value.values())
return isinstance(value, list) and any(uses_gallery(item) for item in value)
return uses_gallery(game.get("init", [])) or uses_gallery(screens) or uses_gallery(game.get("defaults", []))
+1
View File
@@ -82,6 +82,7 @@ class GameVersion(Base):
# draft: visible to the owner and reviewers only
visibility: Mapped[str] = mapped_column(String(16), default="published")
kind: Mapped[str] = mapped_column(String(16), default="original") # original|translation|mod
gallery_unlocked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
+3 -1
View File
@@ -8,6 +8,7 @@ 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
@@ -114,7 +115,8 @@ async def game_detail(game_id: int, user: Player, db: DB) -> dict:
)
).all()
return {**GameOut.model_validate(game).model_dump(),
"versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest)}
"versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest),
"gallery_available": gallery_available(v.manifest)}
for v in versions]}
+22 -3
View File
@@ -8,10 +8,12 @@ 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,
@@ -108,8 +110,10 @@ async def upload_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)
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")
@@ -117,7 +121,7 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A
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}
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)
@@ -139,6 +143,7 @@ async def fork_version(version_id: int, body: ForkIn,
status="ready",
visibility="draft",
kind=body.kind,
gallery_unlocked=parent.gallery_unlocked,
manifest=parent.manifest,
base_revision_id=parent.revision_id,
report=parent.report,
@@ -243,6 +248,20 @@ async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db
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)
+6
View File
@@ -68,6 +68,8 @@ class OwnerOut(ORM):
class VersionOut(ORM):
gallery_unlocked: bool = False
gallery_available: bool = False
revision_id: str | None = None
base_revision_id: str | None = None
id: int
@@ -141,6 +143,10 @@ class PublishIn(BaseModel):
make_main: bool = False
class GallerySettingsIn(BaseModel):
gallery_unlocked: bool
class HistoryOut(ORM):
id: int
note: str
@@ -0,0 +1,15 @@
"""Opt-in unlocked in-game galleries, scoped to one version."""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = depends_on = None
def upgrade():
op.add_column("game_versions", sa.Column("gallery_unlocked", sa.Boolean(), nullable=False, server_default=sa.false()))
def downgrade():
op.drop_column("game_versions", "gallery_unlocked")
+82
View File
@@ -0,0 +1,82 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from app import gallery
from app.routers import versions
from app.schemas import ForkIn, GallerySettingsIn
@pytest.mark.parametrize(("game", "expected"), [
({"build": {"name": "MIST"}, "screens": {"rewatchgirlselectscreen": {}}}, True),
({"init": [{"k": "call", "f": {"k": "name", "id": "Gallery"}}]}, True),
({"screens": {"replay": {"k": "call", "f": {"k": "name", "id": "Replay"}}}}, True),
({"init": [], "screens": {}}, False),
({"engine": "naninovel", "init": [{"k": "call", "f": {"k": "name", "id": "Gallery"}}]}, False),
])
def test_supported_gallery_detection(monkeypatch, game, expected):
monkeypatch.setattr(gallery, "file_json", lambda *_: game)
assert gallery.available({"files": {"game.json": {}}}) is expected
assert not gallery.available(None)
def test_only_owner_can_toggle_gallery_and_scripts_are_not_modified(monkeypatch):
manifest = {"files": {"game.json": {"hash": "unchanged"}}}
v = SimpleNamespace(owner_id=7, status="ready", manifest=manifest, gallery_unlocked=False)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
monkeypatch.setattr(versions, "gallery_available", lambda _: True)
db = SimpleNamespace(commit=AsyncMock())
async def scenario():
body = GallerySettingsIn(gallery_unlocked=True)
with pytest.raises(HTTPException) as error:
await versions.update_gallery(1, body, SimpleNamespace(id=8), db)
assert error.value.status_code == 403
assert not v.gallery_unlocked
result = await versions.update_gallery(1, body, SimpleNamespace(id=7), db)
assert result.gallery_unlocked
assert result.manifest is manifest
await versions.update_gallery(1, GallerySettingsIn(gallery_unlocked=False), SimpleNamespace(id=7), db)
assert not v.gallery_unlocked
assert db.commit.await_count == 2
asyncio.run(scenario())
@pytest.mark.parametrize(("state", "supported", "code"), [("converting", True, 409), ("ready", False, 422)])
def test_gallery_requires_ready_supported_game(monkeypatch, state, supported, code):
v = SimpleNamespace(owner_id=7, status=state, manifest={}, gallery_unlocked=False)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
monkeypatch.setattr(versions, "gallery_available", lambda _: supported)
db = SimpleNamespace(commit=AsyncMock())
with pytest.raises(HTTPException) as error:
asyncio.run(versions.update_gallery(1, GallerySettingsIn(gallery_unlocked=True), SimpleNamespace(id=7), db))
assert error.value.status_code == code
db.commit.assert_not_awaited()
def test_player_manifest_uses_version_flag_instead_of_shared_manifest(monkeypatch):
shared = {"files": {"game.json": {"hash": "unchanged"}}}
v = SimpleNamespace(id=1, status="ready", manifest=shared, revision_id="revision", gallery_unlocked=True)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=v))
result = asyncio.run(versions.version_manifest(1, SimpleNamespace(id=7), None))
assert result["galleryUnlocked"] is True
assert "galleryUnlocked" not in shared
v.gallery_unlocked = False
assert asyncio.run(versions.version_manifest(1, SimpleNamespace(id=7), None))["galleryUnlocked"] is False
def test_fork_inherits_gallery_setting_but_can_change_it_independently(monkeypatch):
parent = SimpleNamespace(id=1, game_id=2, status="ready", gallery_unlocked=True,
manifest={"files": {}}, revision_id="base", report={}, size_bytes=1)
monkeypatch.setattr(versions, "get_version", AsyncMock(return_value=parent))
monkeypatch.setattr(versions, "has_perm", lambda *_: True)
added = []
db = SimpleNamespace(add=added.append, commit=AsyncMock())
fork = asyncio.run(versions.fork_version(1, ForkIn(name="My fork"), SimpleNamespace(id=7), db))
assert fork.gallery_unlocked is True
fork.gallery_unlocked = False
assert parent.gallery_unlocked is True
assert fork.manifest is parent.manifest