Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.routers import (
brands,
cpus,
games,
gpus,
laptops,
meta,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,4 +24,5 @@
"CPU",
"Laptop",
"Monitor",
"Game",
]
53 changes: 53 additions & 0 deletions app/models/game.py
Original file line number Diff line number Diff line change
@@ -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)
63 changes: 63 additions & 0 deletions app/routers/games.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,4 +22,5 @@
"ScoreRead",
"LaptopRead",
"MonitorRead",
"GameRead",
]
33 changes: 33 additions & 0 deletions app/schemas/game.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions app/schemas/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
)
11 changes: 11 additions & 0 deletions app/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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


Expand Down
20 changes: 20 additions & 0 deletions app/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@
"verified",
}

GAME_REQUIRED = {
"slug",
"name",
"source_urls",
"verified",
}

DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


Expand Down Expand Up @@ -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}
Expand All @@ -239,6 +247,7 @@ def validate() -> list[str]:
("cpu", cpus),
("laptop", laptops),
("monitor", monitors),
("game", games),
):
_check_unique_slugs(category, records, errors)

Expand Down Expand Up @@ -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


Expand Down
40 changes: 40 additions & 0 deletions tests/integration/game_fixtures.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading