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
12 changes: 11 additions & 1 deletion app/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
68 changes: 68 additions & 0 deletions app/models/laptop.py
Original file line number Diff line number Diff line change
@@ -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)
114 changes: 114 additions & 0 deletions app/routers/laptops.py
Original file line number Diff line number Diff line change
@@ -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)
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.laptop import LaptopRead
from app.schemas.smartphone import ScoreRead, SmartphoneRead
from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary

Expand All @@ -17,4 +18,5 @@
"SoCManufacturer",
"SmartphoneRead",
"ScoreRead",
"LaptopRead",
]
47 changes: 47 additions & 0 deletions app/schemas/laptop.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions app/schemas/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
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
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.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
Expand Down Expand Up @@ -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),
)
Loading
Loading