Skip to content

Commit 5e2bf40

Browse files
committed
feat(game): add unscored game category (§6.10)
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.
1 parent bd3e036 commit 5e2bf40

12 files changed

Lines changed: 294 additions & 0 deletions

File tree

app/dump.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"cpus",
3232
"laptops",
3333
"monitors",
34+
"games",
3435
]
3536
# Collections with a /score sub-resource (§8) and a `scored` manifest count.
3637
SCORED = {"smartphones", "cpus", "gpus", "socs"}

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from app.routers import (
1818
brands,
1919
cpus,
20+
games,
2021
gpus,
2122
laptops,
2223
meta,
@@ -86,6 +87,7 @@ async def add_request_id(
8687
app.include_router(cpus.router, prefix=PREFIX)
8788
app.include_router(laptops.router, prefix=PREFIX)
8889
app.include_router(monitors.router, prefix=PREFIX)
90+
app.include_router(games.router, prefix=PREFIX)
8991

9092

9193
@app.get("/", include_in_schema=False)

app/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from app.models.brand import Brand
77
from app.models.cpu import CPU
8+
from app.models.game import Game
89
from app.models.gpu import DiscreteGPU
910
from app.models.laptop import Laptop
1011
from app.models.mobile_device import PDA, Tablet, Watch
@@ -23,4 +24,5 @@
2324
"CPU",
2425
"Laptop",
2526
"Monitor",
27+
"Game",
2628
]

app/models/game.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Game model (§6.10).
2+
3+
A video game. Unlike the hardware categories, a game references no Brand — its
4+
makers are recorded as free-text ``developers`` / ``publishers`` lists (a game's
5+
"brand" is its studio/publisher, which does not map onto the hardware Brand
6+
catalogue). Games are unscored.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import UTC, date, datetime
12+
13+
from sqlalchemy import JSON, Column
14+
from sqlmodel import Field, SQLModel
15+
16+
17+
def _utcnow() -> datetime:
18+
return datetime.now(UTC)
19+
20+
21+
class Game(SQLModel, table=True):
22+
"""A video game title (e.g. The Witcher 3: Wild Hunt)."""
23+
24+
__tablename__ = "games"
25+
26+
id: int | None = Field(default=None, primary_key=True)
27+
slug: str = Field(index=True, unique=True)
28+
name: str
29+
30+
release_date: date | None = None # None for TBA/unreleased titles
31+
32+
# Ratings / reception
33+
rating: float | None = None # 0-5 aggregate user rating
34+
rating_count: int | None = None
35+
metacritic: int | None = None # 0-100
36+
playtime_hours: int | None = None
37+
38+
# Classification — stored as JSON string lists
39+
platforms: list[str] = Field(default_factory=list, sa_column=Column(JSON))
40+
genres: list[str] = Field(default_factory=list, sa_column=Column(JSON))
41+
stores: list[str] = Field(default_factory=list, sa_column=Column(JSON))
42+
developers: list[str] = Field(default_factory=list, sa_column=Column(JSON))
43+
publishers: list[str] = Field(default_factory=list, sa_column=Column(JSON))
44+
tags: list[str] = Field(default_factory=list, sa_column=Column(JSON))
45+
esrb_rating: str | None = None
46+
47+
background_image: str | None = None
48+
49+
# Meta
50+
verified: bool = False
51+
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
52+
created_at: datetime = Field(default_factory=_utcnow)
53+
updated_at: datetime = Field(default_factory=_utcnow)

app/routers/games.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Game endpoints (§6.10). List + detail; games are unscored."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Annotated, Any
6+
7+
from fastapi import APIRouter, Query
8+
from sqlalchemy import func
9+
from sqlmodel import select
10+
from sqlmodel.sql.expression import SelectOfScalar
11+
12+
from app.dependencies import PaginationDep, SessionDep
13+
from app.errors import APIError, not_found
14+
from app.models.game import Game
15+
from app.routers.utils import build_ref_page
16+
from app.schemas.common import Page, ResourceRef
17+
from app.schemas.game import GameRead
18+
from app.schemas.serializers import game_read, resource_ref
19+
20+
router = APIRouter(prefix="/games", tags=["games"])
21+
22+
_SORT_FIELDS: dict[str, Any] = {
23+
"name": Game.name,
24+
"release_date": Game.release_date,
25+
"rating": Game.rating,
26+
"metacritic": Game.metacritic,
27+
}
28+
29+
30+
def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]:
31+
if not sort:
32+
return stmt.order_by(Game.name)
33+
descending = sort.startswith("-")
34+
field = sort[1:] if descending else sort
35+
column = _SORT_FIELDS.get(field)
36+
if column is None:
37+
raise APIError(400, "INVALID_REQUEST", f"Cannot sort by '{field}'")
38+
return stmt.order_by(column.desc() if descending else column.asc())
39+
40+
41+
@router.get("", summary="List games")
42+
def list_games(
43+
session: SessionDep,
44+
pagination: PaginationDep,
45+
sort: Annotated[str | None, Query()] = None,
46+
) -> Page[ResourceRef]:
47+
count = session.exec(select(func.count()).select_from(Game)).one()
48+
list_stmt = _apply_sort(select(Game), sort).offset(pagination.offset).limit(pagination.limit)
49+
rows = session.exec(list_stmt).all()
50+
51+
refs = [resource_ref("games", row.slug, row.name) for row in rows]
52+
applied = {k: v for k, v in (("sort", sort),) if v}
53+
return build_ref_page(
54+
refs, count=count, path="/v1/games", pagination=pagination, filters=applied
55+
)
56+
57+
58+
@router.get("/{slug}", summary="Get a game")
59+
def get_game(slug: str, session: SessionDep) -> GameRead:
60+
game = session.exec(select(Game).where(Game.slug == slug)).first()
61+
if game is None:
62+
raise not_found("Game", slug)
63+
return game_read(game)

app/schemas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from app.schemas.brand import BrandRead, BrandSummary
44
from app.schemas.common import ErrorBody, ErrorResponse, Page, ResourceRef
5+
from app.schemas.game import GameRead
56
from app.schemas.laptop import LaptopRead
67
from app.schemas.monitor import MonitorRead
78
from app.schemas.smartphone import ScoreRead, SmartphoneRead
@@ -21,4 +22,5 @@
2122
"ScoreRead",
2223
"LaptopRead",
2324
"MonitorRead",
25+
"GameRead",
2426
]

app/schemas/game.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Game response schema (§6.10). Games are unscored (no ``score`` field)."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date, datetime
6+
7+
from pydantic import BaseModel
8+
9+
10+
class GameRead(BaseModel):
11+
"""Full game detail response."""
12+
13+
id: int
14+
slug: str
15+
name: str
16+
release_date: date | None = None
17+
rating: float | None = None
18+
rating_count: int | None = None
19+
metacritic: int | None = None
20+
playtime_hours: int | None = None
21+
platforms: list[str]
22+
genres: list[str]
23+
stores: list[str]
24+
developers: list[str]
25+
publishers: list[str]
26+
tags: list[str]
27+
esrb_rating: str | None = None
28+
background_image: str | None = None
29+
verified: bool
30+
source_urls: list[str]
31+
created_at: datetime
32+
updated_at: datetime
33+
url: str

app/schemas/serializers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from app.config import settings
66
from app.models.brand import Brand
77
from app.models.cpu import CPU
8+
from app.models.game import Game
89
from app.models.gpu import DiscreteGPU
910
from app.models.laptop import Laptop
1011
from app.models.mobile_device import MobileDeviceFields
@@ -14,6 +15,7 @@
1415
from app.schemas.brand import BrandRead, BrandSummary
1516
from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef
1617
from app.schemas.cpu import CPURead, CPUScoreRead
18+
from app.schemas.game import GameRead
1719
from app.schemas.gpu import GPURead, GPUScoreRead
1820
from app.schemas.laptop import LaptopRead
1921
from app.schemas.mobile_device import MobileDeviceRead
@@ -376,3 +378,30 @@ def monitor_read(monitor: Monitor, brand: Brand) -> MonitorRead:
376378
updated_at=monitor.updated_at,
377379
url=url_for("monitors", monitor.slug),
378380
)
381+
382+
383+
def game_read(game: Game) -> GameRead:
384+
assert game.id is not None
385+
return GameRead(
386+
id=game.id,
387+
slug=game.slug,
388+
name=game.name,
389+
release_date=game.release_date,
390+
rating=game.rating,
391+
rating_count=game.rating_count,
392+
metacritic=game.metacritic,
393+
playtime_hours=game.playtime_hours,
394+
platforms=game.platforms,
395+
genres=game.genres,
396+
stores=game.stores,
397+
developers=game.developers,
398+
publishers=game.publishers,
399+
tags=game.tags,
400+
esrb_rating=game.esrb_rating,
401+
background_image=game.background_image,
402+
verified=game.verified,
403+
source_urls=game.source_urls,
404+
created_at=game.created_at,
405+
updated_at=game.updated_at,
406+
url=url_for("games", game.slug),
407+
)

app/seed.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from app.database import create_db_and_tables, engine
2727
from app.models.brand import Brand
2828
from app.models.cpu import CPU
29+
from app.models.game import Game
2930
from app.models.gpu import DiscreteGPU
3031
from app.models.laptop import Laptop
3132
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]:
6768
"cpus": 0,
6869
"laptops": 0,
6970
"monitors": 0,
71+
"games": 0,
7072
}
7173

7274
# --- Brands ---
@@ -222,6 +224,15 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N
222224
counts["monitors"] += 1
223225
session.commit()
224226

227+
# --- Games (standalone; no brand FK) ---
228+
game_slugs = _existing_slugs(session, Game)
229+
for record in _load_dir(data_dir / "game"):
230+
if record["slug"] in game_slugs:
231+
continue
232+
session.add(Game(**record))
233+
counts["games"] += 1
234+
session.commit()
235+
225236
return counts
226237

227238

app/validate.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@
110110
"verified",
111111
}
112112

113+
GAME_REQUIRED = {
114+
"slug",
115+
"name",
116+
"source_urls",
117+
"verified",
118+
}
119+
113120
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
114121

115122

@@ -222,6 +229,7 @@ def validate() -> list[str]:
222229
cpus = _load("cpu")
223230
laptops = _load("laptop")
224231
monitors = _load("monitor")
232+
games = _load("game")
225233

226234
brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec}
227235
soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec}
@@ -239,6 +247,7 @@ def validate() -> list[str]:
239247
("cpu", cpus),
240248
("laptop", laptops),
241249
("monitor", monitors),
250+
("game", games),
242251
):
243252
_check_unique_slugs(category, records, errors)
244253

@@ -389,6 +398,17 @@ def validate() -> list[str]:
389398
errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand")
390399
_check_variant_path(fname, rec, "monitor", errors, allow_flat=True)
391400

401+
for fname, rec in games:
402+
_check_required(fname, rec, GAME_REQUIRED, errors)
403+
_check_source_urls(fname, rec, errors)
404+
_check_slug(fname, rec.get("slug"), errors)
405+
if rec.get("release_date") is not None:
406+
_check_date(fname, rec["release_date"], errors)
407+
if rec.get("rating") is not None:
408+
_check_range(fname, "rating", rec.get("rating"), 0, 5, errors)
409+
if rec.get("metacritic") is not None:
410+
_check_range(fname, "metacritic", rec.get("metacritic"), 0, 100, errors)
411+
392412
return errors
393413

394414

0 commit comments

Comments
 (0)