From 5e2bf40b4c69bc1bf041abcc2792bdc64034a336 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 9 Jul 2026 17:08:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(game):=20add=20unscored=20game=20category?= =?UTF-8?q?=20(=C2=A76.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone `game` category (video games) — no brand FK; makers stored as free-text developers/publishers lists. Model + schema + serializer + router (list/detail, sort, unscored) + main registration + seed + dump COLLECTIONS + validate (GAME_REQUIRED) + integration tests. Enables a large real-data category (RAWG/IGDB) for the dataset toward 1M records. --- app/dump.py | 1 + app/main.py | 2 + app/models/__init__.py | 2 + app/models/game.py | 53 +++++++++++++++++++++++++ app/routers/games.py | 63 ++++++++++++++++++++++++++++++ app/schemas/__init__.py | 2 + app/schemas/game.py | 33 ++++++++++++++++ app/schemas/serializers.py | 29 ++++++++++++++ app/seed.py | 11 ++++++ app/validate.py | 20 ++++++++++ tests/integration/game_fixtures.py | 40 +++++++++++++++++++ tests/integration/test_games.py | 38 ++++++++++++++++++ 12 files changed, 294 insertions(+) create mode 100644 app/models/game.py create mode 100644 app/routers/games.py create mode 100644 app/schemas/game.py create mode 100644 tests/integration/game_fixtures.py create mode 100644 tests/integration/test_games.py diff --git a/app/dump.py b/app/dump.py index 4082d37..f949f1d 100644 --- a/app/dump.py +++ b/app/dump.py @@ -31,6 +31,7 @@ "cpus", "laptops", "monitors", + "games", ] # Collections with a /score sub-resource (§8) and a `scored` manifest count. SCORED = {"smartphones", "cpus", "gpus", "socs"} diff --git a/app/main.py b/app/main.py index c62fa7d..29d7965 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from app.routers import ( brands, cpus, + games, gpus, laptops, meta, @@ -86,6 +87,7 @@ async def add_request_id( app.include_router(cpus.router, prefix=PREFIX) app.include_router(laptops.router, prefix=PREFIX) app.include_router(monitors.router, prefix=PREFIX) +app.include_router(games.router, prefix=PREFIX) @app.get("/", include_in_schema=False) diff --git a/app/models/__init__.py b/app/models/__init__.py index 168ebbb..d9e59f3 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.brand import Brand from app.models.cpu import CPU +from app.models.game import Game from app.models.gpu import DiscreteGPU from app.models.laptop import Laptop from app.models.mobile_device import PDA, Tablet, Watch @@ -23,4 +24,5 @@ "CPU", "Laptop", "Monitor", + "Game", ] diff --git a/app/models/game.py b/app/models/game.py new file mode 100644 index 0000000..c01d586 --- /dev/null +++ b/app/models/game.py @@ -0,0 +1,53 @@ +"""Game model (§6.10). + +A video game. Unlike the hardware categories, a game references no Brand — its +makers are recorded as free-text ``developers`` / ``publishers`` lists (a game's +"brand" is its studio/publisher, which does not map onto the hardware Brand +catalogue). Games are unscored. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Game(SQLModel, table=True): + """A video game title (e.g. The Witcher 3: Wild Hunt).""" + + __tablename__ = "games" + + id: int | None = Field(default=None, primary_key=True) + slug: str = Field(index=True, unique=True) + name: str + + release_date: date | None = None # None for TBA/unreleased titles + + # Ratings / reception + rating: float | None = None # 0-5 aggregate user rating + rating_count: int | None = None + metacritic: int | None = None # 0-100 + playtime_hours: int | None = None + + # Classification — stored as JSON string lists + platforms: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + genres: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + stores: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + developers: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + publishers: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + tags: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + esrb_rating: str | None = None + + background_image: str | None = None + + # Meta + verified: bool = False + source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + created_at: datetime = Field(default_factory=_utcnow) + updated_at: datetime = Field(default_factory=_utcnow) diff --git a/app/routers/games.py b/app/routers/games.py new file mode 100644 index 0000000..cb69752 --- /dev/null +++ b/app/routers/games.py @@ -0,0 +1,63 @@ +"""Game endpoints (§6.10). List + detail; games are unscored.""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from sqlalchemy import func +from sqlmodel import select +from sqlmodel.sql.expression import SelectOfScalar + +from app.dependencies import PaginationDep, SessionDep +from app.errors import APIError, not_found +from app.models.game import Game +from app.routers.utils import build_ref_page +from app.schemas.common import Page, ResourceRef +from app.schemas.game import GameRead +from app.schemas.serializers import game_read, resource_ref + +router = APIRouter(prefix="/games", tags=["games"]) + +_SORT_FIELDS: dict[str, Any] = { + "name": Game.name, + "release_date": Game.release_date, + "rating": Game.rating, + "metacritic": Game.metacritic, +} + + +def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]: + if not sort: + return stmt.order_by(Game.name) + descending = sort.startswith("-") + field = sort[1:] if descending else sort + column = _SORT_FIELDS.get(field) + if column is None: + raise APIError(400, "INVALID_REQUEST", f"Cannot sort by '{field}'") + return stmt.order_by(column.desc() if descending else column.asc()) + + +@router.get("", summary="List games") +def list_games( + session: SessionDep, + pagination: PaginationDep, + sort: Annotated[str | None, Query()] = None, +) -> Page[ResourceRef]: + count = session.exec(select(func.count()).select_from(Game)).one() + list_stmt = _apply_sort(select(Game), sort).offset(pagination.offset).limit(pagination.limit) + rows = session.exec(list_stmt).all() + + refs = [resource_ref("games", row.slug, row.name) for row in rows] + applied = {k: v for k, v in (("sort", sort),) if v} + return build_ref_page( + refs, count=count, path="/v1/games", pagination=pagination, filters=applied + ) + + +@router.get("/{slug}", summary="Get a game") +def get_game(slug: str, session: SessionDep) -> GameRead: + game = session.exec(select(Game).where(Game.slug == slug)).first() + if game is None: + raise not_found("Game", slug) + return game_read(game) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index bf50e82..5a5beb6 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -2,6 +2,7 @@ from app.schemas.brand import BrandRead, BrandSummary from app.schemas.common import ErrorBody, ErrorResponse, Page, ResourceRef +from app.schemas.game import GameRead from app.schemas.laptop import LaptopRead from app.schemas.monitor import MonitorRead from app.schemas.smartphone import ScoreRead, SmartphoneRead @@ -21,4 +22,5 @@ "ScoreRead", "LaptopRead", "MonitorRead", + "GameRead", ] diff --git a/app/schemas/game.py b/app/schemas/game.py new file mode 100644 index 0000000..3ecf035 --- /dev/null +++ b/app/schemas/game.py @@ -0,0 +1,33 @@ +"""Game response schema (§6.10). Games are unscored (no ``score`` field).""" + +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel + + +class GameRead(BaseModel): + """Full game detail response.""" + + id: int + slug: str + name: str + release_date: date | None = None + rating: float | None = None + rating_count: int | None = None + metacritic: int | None = None + playtime_hours: int | None = None + platforms: list[str] + genres: list[str] + stores: list[str] + developers: list[str] + publishers: list[str] + tags: list[str] + esrb_rating: str | None = None + background_image: str | None = None + verified: bool + source_urls: list[str] + created_at: datetime + updated_at: datetime + url: str diff --git a/app/schemas/serializers.py b/app/schemas/serializers.py index c560d10..be8c999 100644 --- a/app/schemas/serializers.py +++ b/app/schemas/serializers.py @@ -5,6 +5,7 @@ from app.config import settings from app.models.brand import Brand from app.models.cpu import CPU +from app.models.game import Game from app.models.gpu import DiscreteGPU from app.models.laptop import Laptop from app.models.mobile_device import MobileDeviceFields @@ -14,6 +15,7 @@ from app.schemas.brand import BrandRead, BrandSummary from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef from app.schemas.cpu import CPURead, CPUScoreRead +from app.schemas.game import GameRead from app.schemas.gpu import GPURead, GPUScoreRead from app.schemas.laptop import LaptopRead from app.schemas.mobile_device import MobileDeviceRead @@ -376,3 +378,30 @@ def monitor_read(monitor: Monitor, brand: Brand) -> MonitorRead: updated_at=monitor.updated_at, url=url_for("monitors", monitor.slug), ) + + +def game_read(game: Game) -> GameRead: + assert game.id is not None + return GameRead( + id=game.id, + slug=game.slug, + name=game.name, + release_date=game.release_date, + rating=game.rating, + rating_count=game.rating_count, + metacritic=game.metacritic, + playtime_hours=game.playtime_hours, + platforms=game.platforms, + genres=game.genres, + stores=game.stores, + developers=game.developers, + publishers=game.publishers, + tags=game.tags, + esrb_rating=game.esrb_rating, + background_image=game.background_image, + verified=game.verified, + source_urls=game.source_urls, + created_at=game.created_at, + updated_at=game.updated_at, + url=url_for("games", game.slug), + ) diff --git a/app/seed.py b/app/seed.py index c6ae161..00d5309 100644 --- a/app/seed.py +++ b/app/seed.py @@ -26,6 +26,7 @@ from app.database import create_db_and_tables, engine from app.models.brand import Brand from app.models.cpu import CPU +from app.models.game import Game from app.models.gpu import DiscreteGPU from app.models.laptop import Laptop from app.models.mobile_device import PDA, Tablet, Watch @@ -67,6 +68,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]: "cpus": 0, "laptops": 0, "monitors": 0, + "games": 0, } # --- Brands --- @@ -222,6 +224,15 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N counts["monitors"] += 1 session.commit() + # --- Games (standalone; no brand FK) --- + game_slugs = _existing_slugs(session, Game) + for record in _load_dir(data_dir / "game"): + if record["slug"] in game_slugs: + continue + session.add(Game(**record)) + counts["games"] += 1 + session.commit() + return counts diff --git a/app/validate.py b/app/validate.py index e81342f..4397f08 100644 --- a/app/validate.py +++ b/app/validate.py @@ -110,6 +110,13 @@ "verified", } +GAME_REQUIRED = { + "slug", + "name", + "source_urls", + "verified", +} + DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -222,6 +229,7 @@ def validate() -> list[str]: cpus = _load("cpu") laptops = _load("laptop") monitors = _load("monitor") + games = _load("game") brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec} soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec} @@ -239,6 +247,7 @@ def validate() -> list[str]: ("cpu", cpus), ("laptop", laptops), ("monitor", monitors), + ("game", games), ): _check_unique_slugs(category, records, errors) @@ -389,6 +398,17 @@ def validate() -> list[str]: errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") _check_variant_path(fname, rec, "monitor", errors, allow_flat=True) + for fname, rec in games: + _check_required(fname, rec, GAME_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if rec.get("release_date") is not None: + _check_date(fname, rec["release_date"], errors) + if rec.get("rating") is not None: + _check_range(fname, "rating", rec.get("rating"), 0, 5, errors) + if rec.get("metacritic") is not None: + _check_range(fname, "metacritic", rec.get("metacritic"), 0, 100, errors) + return errors diff --git a/tests/integration/game_fixtures.py b/tests/integration/game_fixtures.py new file mode 100644 index 0000000..fdbbb96 --- /dev/null +++ b/tests/integration/game_fixtures.py @@ -0,0 +1,40 @@ +"""Small database fixtures for game endpoint tests.""" + +from __future__ import annotations + +from datetime import date + +from sqlmodel import Session, select + +from app.database import engine +from app.models.game import Game + + +def ensure_game_fixtures() -> None: + """Insert a compact game when the data checkout lacks it.""" + + with Session(engine) as session: + game = session.exec( + select(Game).where(Game.slug == "the-witcher-3-test") + ).first() + if game is None: + session.add( + Game( + slug="the-witcher-3-test", + name="The Witcher 3: Wild Hunt (test)", + release_date=date(2015, 5, 19), + rating=4.7, + rating_count=6000, + metacritic=92, + playtime_hours=46, + platforms=["PC", "PlayStation 5"], + genres=["zzz-test-genre", "RPG"], + stores=["Steam", "GOG"], + developers=["CD Projekt Red"], + publishers=["CD Projekt"], + tags=["singleplayer", "open-world"], + esrb_rating="Mature", + source_urls=["https://example.com"], + ) + ) + session.commit() diff --git a/tests/integration/test_games.py b/tests/integration/test_games.py new file mode 100644 index 0000000..ed0736e --- /dev/null +++ b/tests/integration/test_games.py @@ -0,0 +1,38 @@ +"""Integration tests for game endpoints (unscored category).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.integration.game_fixtures import ensure_game_fixtures + + +def test_list_games(client: TestClient) -> None: + ensure_game_fixtures() + body = client.get("/v1/games").json() + # The fixture may be buried under real game data in pagination, so only the + # count is asserted here; the detail test verifies the fixture itself. + assert body["count"] >= 1 + assert "results" in body + + +def test_list_games_sorted(client: TestClient) -> None: + ensure_game_fixtures() + body = client.get("/v1/games?sort=-metacritic").json() + assert body["count"] >= 1 + + +def test_game_detail(client: TestClient) -> None: + ensure_game_fixtures() + body = client.get("/v1/games/the-witcher-3-test").json() + assert body["slug"] == "the-witcher-3-test" + assert body["metacritic"] == 92 + assert "RPG" in body["genres"] + assert "CD Projekt Red" in body["developers"] + # Games are unscored — no score field. + assert "score" not in body + + +def test_game_not_found(client: TestClient) -> None: + ensure_game_fixtures() + assert client.get("/v1/games/nonexistent-game").status_code == 404