Skip to content

Commit 36b1dc4

Browse files
committed
feat(api): add mobile device category endpoints
Refs #1
1 parent 7de3a67 commit 36b1dc4

11 files changed

Lines changed: 409 additions & 6 deletions

File tree

app/dump.py

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

2222
# Collections that expose list + detail endpoints.
23-
COLLECTIONS = ["brands", "socs", "smartphones", "gpus", "cpus"]
23+
COLLECTIONS = ["brands", "socs", "smartphones", "tablets", "watches", "pdas", "gpus", "cpus"]
2424
PAGE_LIMIT = 100 # API max page size (§7.3)
2525

2626

@@ -42,12 +42,16 @@ def _fetch_all(client: TestClient, resource: str) -> tuple[int, list[dict[str, A
4242
return count, items
4343

4444

45-
def generate(client: TestClient, output_dir: Path = OUTPUT_DIR) -> dict[str, int]:
45+
def generate(
46+
client: TestClient,
47+
output_dir: Path = OUTPUT_DIR,
48+
collections: list[str] | None = None,
49+
) -> dict[str, int]:
4650
"""Write the full static dump. Returns the number of detail files per collection."""
4751
counts: dict[str, int] = {}
4852
manifest: dict[str, object] = {"version": "v1", "collections": {}}
4953

50-
for resource in COLLECTIONS:
54+
for resource in collections or COLLECTIONS:
5155
count, items = _fetch_all(client, resource)
5256
# Combined list file (un-paginated, convenient for static consumers).
5357
_write_json(

app/main.py

Lines changed: 4 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, smartphones, socs
17+
from app.routers import brands, cpus, gpus, meta, mobile_devices, smartphones, socs
1818

1919
PREFIX = settings.api_version_prefix
2020

@@ -69,6 +69,9 @@ async def add_request_id(
6969
app.include_router(brands.router, prefix=PREFIX)
7070
app.include_router(socs.router, prefix=PREFIX)
7171
app.include_router(smartphones.router, prefix=PREFIX)
72+
app.include_router(mobile_devices.tablets_router, prefix=PREFIX)
73+
app.include_router(mobile_devices.watches_router, prefix=PREFIX)
74+
app.include_router(mobile_devices.pdas_router, prefix=PREFIX)
7275
app.include_router(gpus.router, prefix=PREFIX)
7376
app.include_router(cpus.router, prefix=PREFIX)
7477

app/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
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.mobile_device import PDA, Tablet, Watch
910
from app.models.smartphone import Smartphone
1011
from app.models.soc import SoC
1112

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

app/models/mobile_device.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Mobile device models for tablets, watches, and PDAs."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import UTC, date, datetime
6+
from typing import Any
7+
8+
from sqlalchemy import JSON
9+
from sqlmodel import Field, SQLModel
10+
11+
12+
def _utcnow() -> datetime:
13+
return datetime.now(UTC)
14+
15+
16+
class MobileDeviceFields(SQLModel):
17+
"""Shared columns for non-phone mobile device categories."""
18+
19+
id: int | None = Field(default=None, primary_key=True)
20+
slug: str = Field(index=True, unique=True)
21+
base_model_slug: str | None = Field(default=None, index=True)
22+
name: str
23+
brand_id: int = Field(foreign_key="brands.id", index=True)
24+
soc_id: int | None = Field(default=None, foreign_key="socs.id", index=True)
25+
26+
release_date: date
27+
msrp_usd: int | None = None
28+
29+
ram_gb: float
30+
storage_options_gb: list[int] = Field(default_factory=list, sa_type=JSON)
31+
variant: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
32+
33+
display: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
34+
cameras: list[dict[str, Any]] = Field(default_factory=list, sa_type=JSON)
35+
36+
battery_mah: int
37+
charging_wired_w: float | None = None
38+
charging_wireless_w: float | None = None
39+
40+
weight_g: float
41+
dimensions: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
42+
ip_rating: str | None = None
43+
44+
os: str
45+
os_version: str | None = None
46+
connectivity: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
47+
48+
image_url: str | None = None
49+
images: list[str] = Field(default_factory=list, sa_type=JSON)
50+
51+
verified: bool = False
52+
source_urls: list[str] = Field(default_factory=list, sa_type=JSON)
53+
created_at: datetime = Field(default_factory=_utcnow)
54+
updated_at: datetime = Field(default_factory=_utcnow)
55+
56+
57+
class Tablet(MobileDeviceFields, table=True):
58+
"""A tablet device variant."""
59+
60+
__tablename__ = "tablets"
61+
62+
63+
class Watch(MobileDeviceFields, table=True):
64+
"""A smartwatch or connected wearable variant."""
65+
66+
__tablename__ = "watches"
67+
68+
69+
class PDA(MobileDeviceFields, table=True):
70+
"""A PDA or handheld mobile computing variant."""
71+
72+
__tablename__ = "pdas"

app/models/smartphone.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Smartphone(SQLModel, table=True):
2020

2121
id: int | None = Field(default=None, primary_key=True)
2222
slug: str = Field(index=True, unique=True)
23+
base_model_slug: str | None = Field(default=None, index=True)
2324
name: str
2425
brand_id: int = Field(foreign_key="brands.id", index=True)
2526
soc_id: int = Field(foreign_key="socs.id", index=True)
@@ -30,6 +31,7 @@ class Smartphone(SQLModel, table=True):
3031
# Memory
3132
ram_gb: int
3233
storage_options_gb: list[int] = Field(default_factory=list, sa_column=Column(JSON))
34+
variant: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
3335

3436
# Display — {size_inch, resolution, refresh_hz, type, brightness_nits, ppi}
3537
display: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))

app/routers/mobile_devices.py

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

app/schemas/mobile_device.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Response schemas for tablets, watches, and PDAs."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date, datetime
6+
from typing import Any
7+
8+
from pydantic import BaseModel, Field
9+
10+
from app.schemas.brand import BrandSummary
11+
from app.schemas.soc import SoCSummary
12+
13+
14+
class MobileDeviceRead(BaseModel):
15+
"""Full mobile device detail response."""
16+
17+
id: int
18+
slug: str
19+
base_model_slug: str | None = None
20+
name: str
21+
brand: BrandSummary
22+
soc: SoCSummary | None = None
23+
release_date: date
24+
msrp_usd: int | None = None
25+
ram_gb: float
26+
storage_options_gb: list[int]
27+
variant: dict[str, Any]
28+
display: dict[str, Any]
29+
cameras: list[dict[str, Any]]
30+
battery_mah: int
31+
charging_wired_w: float | None = None
32+
charging_wireless_w: float | None = None
33+
weight_g: float
34+
dimensions: dict[str, Any]
35+
ip_rating: str | None = None
36+
os: str
37+
os_version: str | None = None
38+
connectivity: dict[str, Any]
39+
image_url: str | None = None
40+
images: list[str] = Field(default_factory=list)
41+
verified: bool
42+
source_urls: list[str]
43+
created_at: datetime
44+
updated_at: datetime
45+
url: str

app/schemas/serializers.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
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.mobile_device import MobileDeviceFields
910
from app.models.smartphone import Smartphone
1011
from app.models.soc import SoC
1112
from app.schemas.brand import BrandRead, BrandSummary
1213
from app.schemas.common import ManufacturerRef, ResourceRef
1314
from app.schemas.cpu import CPURead
1415
from app.schemas.gpu import GPURead
16+
from app.schemas.mobile_device import MobileDeviceRead
1517
from app.schemas.smartphone import ScoreRead, SmartphoneRead
1618
from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary
1719
from app.services.scoring import Scores
@@ -182,13 +184,15 @@ def smartphone_read(
182184
return SmartphoneRead(
183185
id=phone.id,
184186
slug=phone.slug,
187+
base_model_slug=phone.base_model_slug,
185188
name=phone.name,
186189
brand=brand_summary(brand),
187190
soc=soc_summary(soc, soc_manufacturer),
188191
release_date=phone.release_date,
189192
msrp_usd=phone.msrp_usd,
190193
ram_gb=phone.ram_gb,
191194
storage_options_gb=phone.storage_options_gb,
195+
variant=phone.variant,
192196
display=phone.display,
193197
cameras=phone.cameras,
194198
battery_mah=phone.battery_mah,
@@ -216,3 +220,44 @@ def smartphone_read(
216220
created_at=phone.created_at,
217221
updated_at=phone.updated_at,
218222
)
223+
224+
225+
def mobile_device_read(
226+
resource: str,
227+
device: MobileDeviceFields,
228+
brand: Brand,
229+
soc: SoC | None,
230+
soc_manufacturer: Brand | None,
231+
) -> MobileDeviceRead:
232+
assert device.id is not None
233+
return MobileDeviceRead(
234+
id=device.id,
235+
slug=device.slug,
236+
base_model_slug=device.base_model_slug,
237+
name=device.name,
238+
brand=brand_summary(brand),
239+
soc=soc_summary(soc, soc_manufacturer) if soc and soc_manufacturer else None,
240+
release_date=device.release_date,
241+
msrp_usd=device.msrp_usd,
242+
ram_gb=device.ram_gb,
243+
storage_options_gb=device.storage_options_gb,
244+
variant=device.variant,
245+
display=device.display,
246+
cameras=device.cameras,
247+
battery_mah=device.battery_mah,
248+
charging_wired_w=device.charging_wired_w,
249+
charging_wireless_w=device.charging_wireless_w,
250+
weight_g=device.weight_g,
251+
dimensions=device.dimensions,
252+
ip_rating=device.ip_rating,
253+
os=device.os,
254+
os_version=device.os_version,
255+
connectivity=device.connectivity,
256+
image_url=device.image_url,
257+
images=device.images,
258+
verified=device.verified,
259+
source_urls=device.source_urls,
260+
created_at=device.created_at,
261+
updated_at=device.updated_at,
262+
url=url_for(resource, device.slug),
263+
)

0 commit comments

Comments
 (0)