diff --git a/app/dump.py b/app/dump.py index 9e748e8..ebd9ec2 100644 --- a/app/dump.py +++ b/app/dump.py @@ -20,7 +20,17 @@ OUTPUT_DIR = Path(__file__).resolve().parent.parent / "dump" # Collections that expose list + detail endpoints. -COLLECTIONS = ["brands", "socs", "smartphones", "tablets", "watches", "pdas", "gpus", "cpus"] +COLLECTIONS = [ + "brands", + "socs", + "smartphones", + "tablets", + "watches", + "pdas", + "gpus", + "cpus", + "laptops", +] # Collections with a /score sub-resource (§8) and a `scored` manifest count. SCORED = {"smartphones", "cpus", "gpus", "socs"} PAGE_LIMIT = 100 # API max page size (§7.3) diff --git a/app/main.py b/app/main.py index 48d5bb7..bb78e6c 100644 --- a/app/main.py +++ b/app/main.py @@ -14,7 +14,7 @@ from app.config import settings from app.database import create_db_and_tables from app.errors import register_error_handlers -from app.routers import brands, cpus, gpus, meta, mobile_devices, smartphones, socs +from app.routers import brands, cpus, gpus, laptops, meta, mobile_devices, smartphones, socs PREFIX = settings.api_version_prefix @@ -74,6 +74,7 @@ async def add_request_id( app.include_router(mobile_devices.pdas_router, prefix=PREFIX) app.include_router(gpus.router, prefix=PREFIX) app.include_router(cpus.router, prefix=PREFIX) +app.include_router(laptops.router, prefix=PREFIX) @app.get("/", include_in_schema=False) diff --git a/app/models/__init__.py b/app/models/__init__.py index db974f9..f461ef6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,8 +6,9 @@ from app.models.brand import Brand from app.models.cpu import CPU from app.models.gpu import DiscreteGPU +from app.models.laptop import Laptop from app.models.mobile_device import PDA, Tablet, Watch from app.models.smartphone import Smartphone from app.models.soc import SoC -__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU"] +__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU", "Laptop"] diff --git a/app/models/laptop.py b/app/models/laptop.py new file mode 100644 index 0000000..f43d2b8 --- /dev/null +++ b/app/models/laptop.py @@ -0,0 +1,68 @@ +"""Laptop model (§6.8). + +A laptop references a Brand (required) and, when resolvable, a CPU and/or a +discrete GPU by foreign key. The raw ``cpu_name`` / ``gpu_name`` strings are +always kept so a record is meaningful even when the component is not (yet) in +the catalog. Laptops are currently unscored. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from typing import Any + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Laptop(SQLModel, table=True): + """A laptop model/variant (e.g. Lenovo Legion Pro 5 16IAX10).""" + + __tablename__ = "laptops" + + id: int | None = Field(default=None, primary_key=True) + slug: str = Field(index=True, unique=True) + base_model_slug: str | None = Field(default=None, index=True) + name: str + brand_id: int = Field(foreign_key="brands.id", index=True) + cpu_id: int | None = Field(default=None, foreign_key="cpus.id", index=True) + gpu_id: int | None = Field(default=None, foreign_key="gpus.id", index=True) + + release_date: date + msrp_usd: int | None = None + device_category: str | None = None # e.g. "Gaming", "Thin & Light", "2-in-1" + + # Component descriptors (raw, always present even when the FK is unresolved) + cpu_name: str | None = None + gpu_name: str | None = None + gpu_type: str | None = None # "Dedicated" | "Integrated" + + # Memory / storage + ram_gb: int + storage_gb: int | None = None + + # Display — {size_inch, resolution, refresh_hz, panel, ...} + display: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + # Physical + weight_g: float | None = None + + # Software + os: str + os_version: str | None = None + + # Source tracking (dataset row provenance) + variant: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + # Assets + image_url: 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/laptops.py b/app/routers/laptops.py new file mode 100644 index 0000000..449e690 --- /dev/null +++ b/app/routers/laptops.py @@ -0,0 +1,114 @@ +"""Laptop endpoints (§6.8). List + detail; laptops are unscored.""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from sqlalchemy import func +from sqlmodel import Session, select +from sqlmodel.sql.expression import SelectOfScalar + +from app.dependencies import PaginationDep, SessionDep +from app.errors import APIError, not_found +from app.models.brand import Brand +from app.models.cpu import CPU +from app.models.gpu import DiscreteGPU +from app.models.laptop import Laptop +from app.routers.utils import build_ref_page +from app.schemas.common import Page, ResourceRef +from app.schemas.laptop import LaptopRead +from app.schemas.serializers import laptop_read, resource_ref + +router = APIRouter(prefix="/laptops", tags=["laptops"]) + +_SORT_FIELDS: dict[str, Any] = { + "name": Laptop.name, + "release_date": Laptop.release_date, + "msrp_usd": Laptop.msrp_usd, +} + + +def _resolve_id(session: Session, model: Any, slug: str | None) -> int | None | str: + if slug is None: + return None + row = session.exec(select(model).where(model.slug == slug)).first() + return row.id if row is not None else "MISSING" + + +def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]: + if not sort: + return stmt.order_by(Laptop.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 laptops") +def list_laptops( + session: SessionDep, + pagination: PaginationDep, + brand: Annotated[str | None, Query()] = None, + cpu: Annotated[str | None, Query()] = None, + gpu: Annotated[str | None, Query()] = None, + base_model: Annotated[str | None, Query(alias="base_model_slug")] = None, + sort: Annotated[str | None, Query()] = None, +) -> Page[ResourceRef]: + brand_id = _resolve_id(session, Brand, brand) + cpu_id = _resolve_id(session, CPU, cpu) + gpu_id = _resolve_id(session, DiscreteGPU, gpu) + + if "MISSING" in (brand_id, cpu_id, gpu_id): + return build_ref_page([], count=0, path="/v1/laptops", pagination=pagination) + + filters = [] + if brand_id is not None: + filters.append(Laptop.brand_id == brand_id) + if cpu_id is not None: + filters.append(Laptop.cpu_id == cpu_id) + if gpu_id is not None: + filters.append(Laptop.gpu_id == gpu_id) + if base_model is not None: + filters.append(Laptop.base_model_slug == base_model) + + count_stmt = select(func.count()).select_from(Laptop) + list_stmt = select(Laptop) + for clause in filters: + count_stmt = count_stmt.where(clause) + list_stmt = list_stmt.where(clause) + + count = session.exec(count_stmt).one() + list_stmt = _apply_sort(list_stmt, sort).offset(pagination.offset).limit(pagination.limit) + rows = session.exec(list_stmt).all() + + refs = [resource_ref("laptops", row.slug, row.name) for row in rows] + applied = { + k: v + for k, v in ( + ("brand", brand), + ("cpu", cpu), + ("gpu", gpu), + ("base_model_slug", base_model), + ("sort", sort), + ) + if v + } + return build_ref_page( + refs, count=count, path="/v1/laptops", pagination=pagination, filters=applied + ) + + +@router.get("/{slug}", summary="Get a laptop") +def get_laptop(slug: str, session: SessionDep) -> LaptopRead: + laptop = session.exec(select(Laptop).where(Laptop.slug == slug)).first() + if laptop is None: + raise not_found("Laptop", slug) + brand = session.get(Brand, laptop.brand_id) + if brand is None: # pragma: no cover - guarded by FK + validation + raise not_found("Brand", str(laptop.brand_id)) + cpu = session.get(CPU, laptop.cpu_id) if laptop.cpu_id is not None else None + gpu = session.get(DiscreteGPU, laptop.gpu_id) if laptop.gpu_id is not None else None + return laptop_read(laptop, brand, cpu, gpu) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index b7fd08e..8c037a9 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.laptop import LaptopRead from app.schemas.smartphone import ScoreRead, SmartphoneRead from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary @@ -17,4 +18,5 @@ "SoCManufacturer", "SmartphoneRead", "ScoreRead", + "LaptopRead", ] diff --git a/app/schemas/laptop.py b/app/schemas/laptop.py new file mode 100644 index 0000000..5b78f4a --- /dev/null +++ b/app/schemas/laptop.py @@ -0,0 +1,47 @@ +"""Laptop response schema (§6.8). + +Laptops are unscored (no ``score`` field). CPU and GPU are embedded as +lightweight references when resolved to the catalog; otherwise only the raw +``cpu_name`` / ``gpu_name`` strings are present. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from pydantic import BaseModel + +from app.schemas.brand import BrandSummary +from app.schemas.common import ResourceRef + + +class LaptopRead(BaseModel): + """Full laptop detail response.""" + + id: int + slug: str + base_model_slug: str | None = None + name: str + brand: BrandSummary + cpu: ResourceRef | None = None + gpu: ResourceRef | None = None + release_date: date + msrp_usd: int | None = None + device_category: str | None = None + cpu_name: str | None = None + gpu_name: str | None = None + gpu_type: str | None = None + ram_gb: int + storage_gb: int | None = None + display: dict[str, Any] + weight_g: float | None = None + os: str + os_version: str | None = None + variant: dict[str, Any] + image_url: 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 c74d52f..7466de5 100644 --- a/app/schemas/serializers.py +++ b/app/schemas/serializers.py @@ -6,6 +6,7 @@ from app.models.brand import Brand from app.models.cpu import CPU from app.models.gpu import DiscreteGPU +from app.models.laptop import Laptop from app.models.mobile_device import MobileDeviceFields from app.models.smartphone import Smartphone from app.models.soc import SoC @@ -13,6 +14,7 @@ from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef from app.schemas.cpu import CPURead, CPUScoreRead from app.schemas.gpu import GPURead, GPUScoreRead +from app.schemas.laptop import LaptopRead from app.schemas.mobile_device import MobileDeviceRead from app.schemas.smartphone import ScoreRead, SmartphoneRead from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary @@ -305,3 +307,40 @@ def mobile_device_read( updated_at=device.updated_at, url=url_for(resource, device.slug), ) + + +def laptop_read( + laptop: Laptop, + brand: Brand, + cpu: CPU | None, + gpu: DiscreteGPU | None, +) -> LaptopRead: + assert laptop.id is not None + return LaptopRead( + id=laptop.id, + slug=laptop.slug, + base_model_slug=laptop.base_model_slug, + name=laptop.name, + brand=brand_summary(brand), + cpu=resource_ref("cpus", cpu.slug, cpu.name) if cpu else None, + gpu=resource_ref("gpus", gpu.slug, gpu.name) if gpu else None, + release_date=laptop.release_date, + msrp_usd=laptop.msrp_usd, + device_category=laptop.device_category, + cpu_name=laptop.cpu_name, + gpu_name=laptop.gpu_name, + gpu_type=laptop.gpu_type, + ram_gb=laptop.ram_gb, + storage_gb=laptop.storage_gb, + display=laptop.display, + weight_g=laptop.weight_g, + os=laptop.os, + os_version=laptop.os_version, + variant=laptop.variant, + image_url=laptop.image_url, + verified=laptop.verified, + source_urls=laptop.source_urls, + created_at=laptop.created_at, + updated_at=laptop.updated_at, + url=url_for("laptops", laptop.slug), + ) diff --git a/app/seed.py b/app/seed.py index c0da906..11fe460 100644 --- a/app/seed.py +++ b/app/seed.py @@ -27,6 +27,7 @@ from app.models.brand import Brand from app.models.cpu import CPU from app.models.gpu import DiscreteGPU +from app.models.laptop import Laptop from app.models.mobile_device import PDA, Tablet, Watch from app.models.smartphone import Smartphone from app.models.soc import SoC @@ -63,6 +64,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]: "pdas": 0, "gpus": 0, "cpus": 0, + "laptops": 0, } # --- Brands --- @@ -174,6 +176,35 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N counts["cpus"] += 1 session.commit() + # --- Laptops (reference brand [required], cpu + gpu [optional]) --- + cpu_id_by_slug = {c.slug: c.id for c in session.exec(select(CPU)).all()} + gpu_id_by_slug = {g.slug: g.id for g in session.exec(select(DiscreteGPU)).all()} + laptop_slugs = _existing_slugs(session, Laptop) + for record in _load_dir(data_dir / "laptop"): + if record["slug"] in laptop_slugs: + continue + brand_slug = record.pop("brand") + cpu_slug = record.pop("cpu", None) + gpu_slug = record.pop("gpu", None) + brand_id = brand_id_by_slug.get(brand_slug) + if brand_id is None: + raise ValueError( + f"Laptop '{record['slug']}' references unknown brand '{brand_slug}'" + ) + cpu_id = cpu_id_by_slug.get(cpu_slug) if cpu_slug else None + gpu_id = gpu_id_by_slug.get(gpu_slug) if gpu_slug else None + if cpu_slug and cpu_id is None: + raise ValueError( + f"Laptop '{record['slug']}' references unknown CPU '{cpu_slug}'" + ) + if gpu_slug and gpu_id is None: + raise ValueError( + f"Laptop '{record['slug']}' references unknown GPU '{gpu_slug}'" + ) + session.add(Laptop(brand_id=brand_id, cpu_id=cpu_id, gpu_id=gpu_id, **record)) + counts["laptops"] += 1 + session.commit() + return counts diff --git a/app/validate.py b/app/validate.py index b425328..9612c2b 100644 --- a/app/validate.py +++ b/app/validate.py @@ -88,6 +88,17 @@ "threads", } +LAPTOP_REQUIRED = { + "slug", + "name", + "brand", + "release_date", + "ram_gb", + "os", + "source_urls", + "verified", +} + DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -198,9 +209,12 @@ def validate() -> list[str]: pdas = _load("pda") gpus = _load("gpu") cpus = _load("cpu") + laptops = _load("laptop") brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec} soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec} + cpu_slugs = {rec["slug"] for _, rec in cpus if "slug" in rec} + gpu_slugs = {rec["slug"] for _, rec in gpus if "slug" in rec} for category, records in ( ("brand", brands), @@ -211,6 +225,7 @@ def validate() -> list[str]: ("pda", pdas), ("gpu", gpus), ("cpu", cpus), + ("laptop", laptops), ): _check_unique_slugs(category, records, errors) @@ -322,6 +337,27 @@ def validate() -> list[str]: if rec.get("manufacturer") not in brand_slugs: errors.append(f"{fname}: manufacturer '{rec.get('manufacturer')}' not a known brand") + for fname, rec in laptops: + _check_required(fname, rec, LAPTOP_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "ram_gb", rec.get("ram_gb"), 1, 256, errors) + if rec.get("storage_gb") is not None: + _check_range(fname, "storage_gb", rec.get("storage_gb"), 1, 65536, errors) + if rec.get("weight_g") is not None: + _check_range(fname, "weight_g", rec.get("weight_g"), 300, 6000, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 50, 50000, errors) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + if rec.get("cpu") is not None and rec.get("cpu") not in cpu_slugs: + errors.append(f"{fname}: cpu '{rec.get('cpu')}' not a known CPU") + if rec.get("gpu") is not None and rec.get("gpu") not in gpu_slugs: + errors.append(f"{fname}: gpu '{rec.get('gpu')}' not a known GPU") + _check_variant_path(fname, rec, "laptop", errors, allow_flat=True) + return errors diff --git a/tests/integration/laptop_fixtures.py b/tests/integration/laptop_fixtures.py new file mode 100644 index 0000000..b70ba0f --- /dev/null +++ b/tests/integration/laptop_fixtures.py @@ -0,0 +1,101 @@ +"""Small database fixtures for laptop endpoint tests.""" + +from __future__ import annotations + +from datetime import date + +from sqlmodel import Session, select + +from app.database import engine +from app.models.brand import Brand +from app.models.cpu import CPU +from app.models.gpu import DiscreteGPU +from app.models.laptop import Laptop + + +def _brand_id(session: Session, slug: str, name: str, country: str) -> int: + brand = session.exec(select(Brand).where(Brand.slug == slug)).first() + if brand is None: + brand = Brand(slug=slug, name=name, country=country, source_urls=["https://example.com"]) + session.add(brand) + session.commit() + session.refresh(brand) + assert brand.id is not None + return brand.id + + +def ensure_laptop_fixtures() -> None: + """Insert a compact laptop (plus its brand/CPU/GPU) when the data checkout lacks it.""" + + with Session(engine) as session: + lenovo_id = _brand_id(session, "lenovo", "Lenovo", "CN") + intel_id = _brand_id(session, "intel", "Intel", "US") + nvidia_id = _brand_id(session, "nvidia", "NVIDIA", "US") + + cpu = session.exec(select(CPU).where(CPU.slug == "test-core-ultra-7-255hx")).first() + if cpu is None: + cpu = CPU( + slug="test-core-ultra-7-255hx", + name="Intel Core Ultra 7 255HX", + manufacturer_id=intel_id, + release_date=date(2024, 1, 1), + segment="laptop", + architecture="Arrow Lake", + cores=20, + threads=20, + source_urls=["https://example.com"], + ) + session.add(cpu) + session.commit() + session.refresh(cpu) + + gpu = session.exec( + select(DiscreteGPU).where(DiscreteGPU.slug == "test-rtx-5060-laptop") + ).first() + if gpu is None: + gpu = DiscreteGPU( + slug="test-rtx-5060-laptop", + name="NVIDIA GeForce RTX 5060 Laptop", + manufacturer_id=nvidia_id, + architecture="Blackwell", + release_date=date(2025, 1, 1), + memory_gb=8.0, + memory_type="GDDR7", + memory_bus_bit=128, + base_clock_mhz=1500, + boost_clock_mhz=2200, + tdp_w=115, + pcie_version="5.0", + source_urls=["https://example.com"], + ) + session.add(gpu) + session.commit() + session.refresh(gpu) + + laptop = session.exec( + select(Laptop).where(Laptop.slug == "lenovo-legion-pro-5-test") + ).first() + if laptop is None: + session.add( + Laptop( + slug="lenovo-legion-pro-5-test", + base_model_slug="legion-pro-5", + name="Lenovo Legion Pro 5 (test)", + brand_id=lenovo_id, + cpu_id=cpu.id, + gpu_id=gpu.id, + release_date=date(2024, 1, 1), + msrp_usd=1899, + device_category="Gaming", + cpu_name="Intel Core Ultra 7 255HX", + gpu_name="NVIDIA GeForce RTX 5060", + gpu_type="Dedicated", + ram_gb=32, + storage_gb=1024, + display={"size_inch": 16, "resolution": "2560x1600", "refresh_hz": 165}, + weight_g=2500.0, + os="Windows", + source_urls=["https://example.com"], + ) + ) + session.commit() diff --git a/tests/integration/test_laptops.py b/tests/integration/test_laptops.py new file mode 100644 index 0000000..d87ede5 --- /dev/null +++ b/tests/integration/test_laptops.py @@ -0,0 +1,45 @@ +"""Integration tests for laptop endpoints (unscored category).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.integration.laptop_fixtures import ensure_laptop_fixtures + + +def test_list_laptops(client: TestClient) -> None: + ensure_laptop_fixtures() + body = client.get("/v1/laptops").json() + assert body["count"] >= 1 + slugs = {r["slug"] for r in body["results"]} + assert "lenovo-legion-pro-5-test" in slugs + + +def test_laptop_detail_embeds_cpu_and_gpu(client: TestClient) -> None: + ensure_laptop_fixtures() + body = client.get("/v1/laptops/lenovo-legion-pro-5-test").json() + assert body["slug"] == "lenovo-legion-pro-5-test" + assert body["base_model_slug"] == "legion-pro-5" + assert body["brand"]["slug"] == "lenovo" + assert body["cpu"]["slug"] == "test-core-ultra-7-255hx" + assert body["gpu"]["slug"] == "test-rtx-5060-laptop" + assert body["cpu_name"] == "Intel Core Ultra 7 255HX" + assert body["ram_gb"] == 32 + assert body["device_category"] == "Gaming" + # Laptops are unscored — no score field is present. + assert "score" not in body + + +def test_laptop_filter_by_brand(client: TestClient) -> None: + ensure_laptop_fixtures() + body = client.get("/v1/laptops?brand=lenovo").json() + assert body["count"] >= 1 + # Unknown brand yields an empty page, not an error. + empty = client.get("/v1/laptops?brand=does-not-exist").json() + assert empty["count"] == 0 + + +def test_laptop_not_found(client: TestClient) -> None: + ensure_laptop_fixtures() + response = client.get("/v1/laptops/nonexistent-laptop") + assert response.status_code == 404