Skip to content

Commit d1c3b0b

Browse files
committed
feat(laptop): add laptop category (model, schema, router, seed, dump, validate)
Introduces a new `laptop` device category end-to-end: - `app/models/laptop.py` — Laptop table; brand FK required, cpu/gpu FK optional (raw cpu_name/gpu_name always kept so a record is meaningful without a resolved component). Unscored. - `app/schemas/laptop.py` + serializer `laptop_read` — detail response embeds brand + optional cpu/gpu refs; no score field. - `app/routers/laptops.py` — list + detail with brand/cpu/gpu filters. - `app/main.py` — register the router. - `app/seed.py` — seed data/laptop/, resolve brand (required) + cpu/gpu (optional) FKs from slugs. - `app/dump.py` — add "laptops" to COLLECTIONS (not SCORED). - `app/validate.py` — LAPTOP_REQUIRED + ranges (ram 1-256, weight 300-6000, msrp 50-50000) + brand/cpu/gpu FK checks + variant path. - tests: laptop fixtures + integration tests (list, detail embeds, brand filter, 404, unscored). Paired with the TechAPI data PR that seeds data/laptop/ and mirrors the validate rule.
1 parent 6a90842 commit d1c3b0b

12 files changed

Lines changed: 498 additions & 3 deletions

File tree

app/dump.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,17 @@
2020
OUTPUT_DIR = Path(__file__).resolve().parent.parent / "dump"
2121

2222
# Collections that expose list + detail endpoints.
23-
COLLECTIONS = ["brands", "socs", "smartphones", "tablets", "watches", "pdas", "gpus", "cpus"]
23+
COLLECTIONS = [
24+
"brands",
25+
"socs",
26+
"smartphones",
27+
"tablets",
28+
"watches",
29+
"pdas",
30+
"gpus",
31+
"cpus",
32+
"laptops",
33+
]
2434
# Collections with a /score sub-resource (§8) and a `scored` manifest count.
2535
SCORED = {"smartphones", "cpus", "gpus", "socs"}
2636
PAGE_LIMIT = 100 # API max page size (§7.3)

app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from app.config import settings
1515
from app.database import create_db_and_tables
1616
from app.errors import register_error_handlers
17-
from app.routers import brands, cpus, gpus, meta, mobile_devices, smartphones, socs
17+
from app.routers import brands, cpus, gpus, laptops, meta, mobile_devices, smartphones, socs
1818

1919
PREFIX = settings.api_version_prefix
2020

@@ -74,6 +74,7 @@ async def add_request_id(
7474
app.include_router(mobile_devices.pdas_router, prefix=PREFIX)
7575
app.include_router(gpus.router, prefix=PREFIX)
7676
app.include_router(cpus.router, prefix=PREFIX)
77+
app.include_router(laptops.router, prefix=PREFIX)
7778

7879

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

app/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from app.models.brand import Brand
77
from app.models.cpu import CPU
88
from app.models.gpu import DiscreteGPU
9+
from app.models.laptop import Laptop
910
from app.models.mobile_device import PDA, Tablet, Watch
1011
from app.models.smartphone import Smartphone
1112
from app.models.soc import SoC
1213

13-
__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU"]
14+
__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU", "Laptop"]

app/models/laptop.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Laptop model (§6.8).
2+
3+
A laptop references a Brand (required) and, when resolvable, a CPU and/or a
4+
discrete GPU by foreign key. The raw ``cpu_name`` / ``gpu_name`` strings are
5+
always kept so a record is meaningful even when the component is not (yet) in
6+
the catalog. Laptops are currently unscored.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import UTC, date, datetime
12+
from typing import Any
13+
14+
from sqlalchemy import JSON, Column
15+
from sqlmodel import Field, SQLModel
16+
17+
18+
def _utcnow() -> datetime:
19+
return datetime.now(UTC)
20+
21+
22+
class Laptop(SQLModel, table=True):
23+
"""A laptop model/variant (e.g. Lenovo Legion Pro 5 16IAX10)."""
24+
25+
__tablename__ = "laptops"
26+
27+
id: int | None = Field(default=None, primary_key=True)
28+
slug: str = Field(index=True, unique=True)
29+
base_model_slug: str | None = Field(default=None, index=True)
30+
name: str
31+
brand_id: int = Field(foreign_key="brands.id", index=True)
32+
cpu_id: int | None = Field(default=None, foreign_key="cpus.id", index=True)
33+
gpu_id: int | None = Field(default=None, foreign_key="gpus.id", index=True)
34+
35+
release_date: date
36+
msrp_usd: int | None = None
37+
device_category: str | None = None # e.g. "Gaming", "Thin & Light", "2-in-1"
38+
39+
# Component descriptors (raw, always present even when the FK is unresolved)
40+
cpu_name: str | None = None
41+
gpu_name: str | None = None
42+
gpu_type: str | None = None # "Dedicated" | "Integrated"
43+
44+
# Memory / storage
45+
ram_gb: int
46+
storage_gb: int | None = None
47+
48+
# Display — {size_inch, resolution, refresh_hz, panel, ...}
49+
display: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
50+
51+
# Physical
52+
weight_g: float | None = None
53+
54+
# Software
55+
os: str
56+
os_version: str | None = None
57+
58+
# Source tracking (dataset row provenance)
59+
variant: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
60+
61+
# Assets
62+
image_url: str | None = None
63+
64+
# Meta
65+
verified: bool = False
66+
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
67+
created_at: datetime = Field(default_factory=_utcnow)
68+
updated_at: datetime = Field(default_factory=_utcnow)

app/routers/laptops.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Laptop endpoints (§6.8). List + detail; laptops 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 Session, 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.brand import Brand
15+
from app.models.cpu import CPU
16+
from app.models.gpu import DiscreteGPU
17+
from app.models.laptop import Laptop
18+
from app.routers.utils import build_ref_page
19+
from app.schemas.common import Page, ResourceRef
20+
from app.schemas.laptop import LaptopRead
21+
from app.schemas.serializers import laptop_read, resource_ref
22+
23+
router = APIRouter(prefix="/laptops", tags=["laptops"])
24+
25+
_SORT_FIELDS: dict[str, Any] = {
26+
"name": Laptop.name,
27+
"release_date": Laptop.release_date,
28+
"msrp_usd": Laptop.msrp_usd,
29+
}
30+
31+
32+
def _resolve_id(session: Session, model: Any, slug: str | None) -> int | None | str:
33+
if slug is None:
34+
return None
35+
row = session.exec(select(model).where(model.slug == slug)).first()
36+
return row.id if row is not None else "MISSING"
37+
38+
39+
def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]:
40+
if not sort:
41+
return stmt.order_by(Laptop.name)
42+
descending = sort.startswith("-")
43+
field = sort[1:] if descending else sort
44+
column = _SORT_FIELDS.get(field)
45+
if column is None:
46+
raise APIError(400, "INVALID_REQUEST", f"Cannot sort by '{field}'")
47+
return stmt.order_by(column.desc() if descending else column.asc())
48+
49+
50+
@router.get("", summary="List laptops")
51+
def list_laptops(
52+
session: SessionDep,
53+
pagination: PaginationDep,
54+
brand: Annotated[str | None, Query()] = None,
55+
cpu: Annotated[str | None, Query()] = None,
56+
gpu: Annotated[str | None, Query()] = None,
57+
base_model: Annotated[str | None, Query(alias="base_model_slug")] = None,
58+
sort: Annotated[str | None, Query()] = None,
59+
) -> Page[ResourceRef]:
60+
brand_id = _resolve_id(session, Brand, brand)
61+
cpu_id = _resolve_id(session, CPU, cpu)
62+
gpu_id = _resolve_id(session, DiscreteGPU, gpu)
63+
64+
if "MISSING" in (brand_id, cpu_id, gpu_id):
65+
return build_ref_page([], count=0, path="/v1/laptops", pagination=pagination)
66+
67+
filters = []
68+
if brand_id is not None:
69+
filters.append(Laptop.brand_id == brand_id)
70+
if cpu_id is not None:
71+
filters.append(Laptop.cpu_id == cpu_id)
72+
if gpu_id is not None:
73+
filters.append(Laptop.gpu_id == gpu_id)
74+
if base_model is not None:
75+
filters.append(Laptop.base_model_slug == base_model)
76+
77+
count_stmt = select(func.count()).select_from(Laptop)
78+
list_stmt = select(Laptop)
79+
for clause in filters:
80+
count_stmt = count_stmt.where(clause)
81+
list_stmt = list_stmt.where(clause)
82+
83+
count = session.exec(count_stmt).one()
84+
list_stmt = _apply_sort(list_stmt, sort).offset(pagination.offset).limit(pagination.limit)
85+
rows = session.exec(list_stmt).all()
86+
87+
refs = [resource_ref("laptops", row.slug, row.name) for row in rows]
88+
applied = {
89+
k: v
90+
for k, v in (
91+
("brand", brand),
92+
("cpu", cpu),
93+
("gpu", gpu),
94+
("base_model_slug", base_model),
95+
("sort", sort),
96+
)
97+
if v
98+
}
99+
return build_ref_page(
100+
refs, count=count, path="/v1/laptops", pagination=pagination, filters=applied
101+
)
102+
103+
104+
@router.get("/{slug}", summary="Get a laptop")
105+
def get_laptop(slug: str, session: SessionDep) -> LaptopRead:
106+
laptop = session.exec(select(Laptop).where(Laptop.slug == slug)).first()
107+
if laptop is None:
108+
raise not_found("Laptop", slug)
109+
brand = session.get(Brand, laptop.brand_id)
110+
if brand is None: # pragma: no cover - guarded by FK + validation
111+
raise not_found("Brand", str(laptop.brand_id))
112+
cpu = session.get(CPU, laptop.cpu_id) if laptop.cpu_id is not None else None
113+
gpu = session.get(DiscreteGPU, laptop.gpu_id) if laptop.gpu_id is not None else None
114+
return laptop_read(laptop, brand, cpu, gpu)

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.laptop import LaptopRead
56
from app.schemas.smartphone import ScoreRead, SmartphoneRead
67
from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary
78

@@ -17,4 +18,5 @@
1718
"SoCManufacturer",
1819
"SmartphoneRead",
1920
"ScoreRead",
21+
"LaptopRead",
2022
]

app/schemas/laptop.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Laptop response schema (§6.8).
2+
3+
Laptops are unscored (no ``score`` field). CPU and GPU are embedded as
4+
lightweight references when resolved to the catalog; otherwise only the raw
5+
``cpu_name`` / ``gpu_name`` strings are present.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from datetime import date, datetime
11+
from typing import Any
12+
13+
from pydantic import BaseModel
14+
15+
from app.schemas.brand import BrandSummary
16+
from app.schemas.common import ResourceRef
17+
18+
19+
class LaptopRead(BaseModel):
20+
"""Full laptop detail response."""
21+
22+
id: int
23+
slug: str
24+
base_model_slug: str | None = None
25+
name: str
26+
brand: BrandSummary
27+
cpu: ResourceRef | None = None
28+
gpu: ResourceRef | None = None
29+
release_date: date
30+
msrp_usd: int | None = None
31+
device_category: str | None = None
32+
cpu_name: str | None = None
33+
gpu_name: str | None = None
34+
gpu_type: str | None = None
35+
ram_gb: int
36+
storage_gb: int | None = None
37+
display: dict[str, Any]
38+
weight_g: float | None = None
39+
os: str
40+
os_version: str | None = None
41+
variant: dict[str, Any]
42+
image_url: str | None = None
43+
verified: bool
44+
source_urls: list[str]
45+
created_at: datetime
46+
updated_at: datetime
47+
url: str

app/schemas/serializers.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
from app.models.brand import Brand
77
from app.models.cpu import CPU
88
from app.models.gpu import DiscreteGPU
9+
from app.models.laptop import Laptop
910
from app.models.mobile_device import MobileDeviceFields
1011
from app.models.smartphone import Smartphone
1112
from app.models.soc import SoC
1213
from app.schemas.brand import BrandRead, BrandSummary
1314
from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef
1415
from app.schemas.cpu import CPURead, CPUScoreRead
1516
from app.schemas.gpu import GPURead, GPUScoreRead
17+
from app.schemas.laptop import LaptopRead
1618
from app.schemas.mobile_device import MobileDeviceRead
1719
from app.schemas.smartphone import ScoreRead, SmartphoneRead
1820
from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary
@@ -305,3 +307,40 @@ def mobile_device_read(
305307
updated_at=device.updated_at,
306308
url=url_for(resource, device.slug),
307309
)
310+
311+
312+
def laptop_read(
313+
laptop: Laptop,
314+
brand: Brand,
315+
cpu: CPU | None,
316+
gpu: DiscreteGPU | None,
317+
) -> LaptopRead:
318+
assert laptop.id is not None
319+
return LaptopRead(
320+
id=laptop.id,
321+
slug=laptop.slug,
322+
base_model_slug=laptop.base_model_slug,
323+
name=laptop.name,
324+
brand=brand_summary(brand),
325+
cpu=resource_ref("cpus", cpu.slug, cpu.name) if cpu else None,
326+
gpu=resource_ref("gpus", gpu.slug, gpu.name) if gpu else None,
327+
release_date=laptop.release_date,
328+
msrp_usd=laptop.msrp_usd,
329+
device_category=laptop.device_category,
330+
cpu_name=laptop.cpu_name,
331+
gpu_name=laptop.gpu_name,
332+
gpu_type=laptop.gpu_type,
333+
ram_gb=laptop.ram_gb,
334+
storage_gb=laptop.storage_gb,
335+
display=laptop.display,
336+
weight_g=laptop.weight_g,
337+
os=laptop.os,
338+
os_version=laptop.os_version,
339+
variant=laptop.variant,
340+
image_url=laptop.image_url,
341+
verified=laptop.verified,
342+
source_urls=laptop.source_urls,
343+
created_at=laptop.created_at,
344+
updated_at=laptop.updated_at,
345+
url=url_for("laptops", laptop.slug),
346+
)

0 commit comments

Comments
 (0)