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
10 changes: 7 additions & 3 deletions app/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
OUTPUT_DIR = Path(__file__).resolve().parent.parent / "dump"

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


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


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

for resource in COLLECTIONS:
for resource in collections or COLLECTIONS:
count, items = _fetch_all(client, resource)
# Combined list file (un-paginated, convenient for static consumers).
_write_json(
Expand Down
5 changes: 4 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, smartphones, socs
from app.routers import brands, cpus, gpus, meta, mobile_devices, smartphones, socs

PREFIX = settings.api_version_prefix

Expand Down Expand Up @@ -69,6 +69,9 @@ async def add_request_id(
app.include_router(brands.router, prefix=PREFIX)
app.include_router(socs.router, prefix=PREFIX)
app.include_router(smartphones.router, prefix=PREFIX)
app.include_router(mobile_devices.tablets_router, prefix=PREFIX)
app.include_router(mobile_devices.watches_router, prefix=PREFIX)
app.include_router(mobile_devices.pdas_router, prefix=PREFIX)
app.include_router(gpus.router, prefix=PREFIX)
app.include_router(cpus.router, prefix=PREFIX)

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,7 +6,8 @@
from app.models.brand import Brand
from app.models.cpu import CPU
from app.models.gpu import DiscreteGPU
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", "DiscreteGPU", "CPU"]
__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU"]
72 changes: 72 additions & 0 deletions app/models/mobile_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Mobile device models for tablets, watches, and PDAs."""

from __future__ import annotations

from datetime import UTC, date, datetime
from typing import Any

from sqlalchemy import JSON
from sqlmodel import Field, SQLModel


def _utcnow() -> datetime:
return datetime.now(UTC)


class MobileDeviceFields(SQLModel):
"""Shared columns for non-phone mobile device categories."""

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)
soc_id: int | None = Field(default=None, foreign_key="socs.id", index=True)

release_date: date
msrp_usd: int | None = None

ram_gb: float
storage_options_gb: list[int] = Field(default_factory=list, sa_type=JSON)
variant: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)

display: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
cameras: list[dict[str, Any]] = Field(default_factory=list, sa_type=JSON)

battery_mah: int
charging_wired_w: float | None = None
charging_wireless_w: float | None = None

weight_g: float
dimensions: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
ip_rating: str | None = None

os: str
os_version: str | None = None
connectivity: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)

image_url: str | None = None
images: list[str] = Field(default_factory=list, sa_type=JSON)

verified: bool = False
source_urls: list[str] = Field(default_factory=list, sa_type=JSON)
created_at: datetime = Field(default_factory=_utcnow)
updated_at: datetime = Field(default_factory=_utcnow)


class Tablet(MobileDeviceFields, table=True):
"""A tablet device variant."""

__tablename__ = "tablets"


class Watch(MobileDeviceFields, table=True):
"""A smartwatch or connected wearable variant."""

__tablename__ = "watches"


class PDA(MobileDeviceFields, table=True):
"""A PDA or handheld mobile computing variant."""

__tablename__ = "pdas"
2 changes: 2 additions & 0 deletions app/models/smartphone.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Smartphone(SQLModel, table=True):

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)
soc_id: int = Field(foreign_key="socs.id", index=True)
Expand All @@ -30,6 +31,7 @@ class Smartphone(SQLModel, table=True):
# Memory
ram_gb: int
storage_options_gb: list[int] = Field(default_factory=list, sa_column=Column(JSON))
variant: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))

# Display — {size_inch, resolution, refresh_hz, type, brightness_nits, ppi}
display: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
Expand Down
122 changes: 122 additions & 0 deletions app/routers/mobile_devices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Shared endpoints for non-phone mobile device categories."""

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.mobile_device import PDA, Tablet, Watch
from app.models.soc import SoC
from app.routers.utils import build_ref_page
from app.schemas.common import Page, ResourceRef
from app.schemas.mobile_device import MobileDeviceRead
from app.schemas.serializers import mobile_device_read, resource_ref


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 _make_router(resource: str, model: Any) -> APIRouter:
router = APIRouter(prefix=f"/{resource}", tags=[resource])
sort_fields: dict[str, Any] = {
"name": model.name,
"release_date": model.release_date,
"msrp_usd": model.msrp_usd,
}

def apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]:
if not sort:
return stmt.order_by(model.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=f"List {resource}")
def list_devices(
session: SessionDep,
pagination: PaginationDep,
brand: Annotated[str | None, Query()] = None,
soc: Annotated[str | None, Query()] = None,
base_model: Annotated[str | None, Query(alias="base_model_slug")] = None,
sort: Annotated[str | None, Query()] = None,
) -> Page[ResourceRef]:
filters = []
brand_id = _resolve_id(session, Brand, brand)
soc_id = _resolve_id(session, SoC, soc)

if brand_id == "MISSING" or soc_id == "MISSING":
return build_ref_page([], count=0, path=f"/v1/{resource}", pagination=pagination)

if brand_id is not None:
filters.append(model.brand_id == brand_id)
if soc_id is not None:
filters.append(model.soc_id == soc_id)
if base_model is not None:
filters.append(model.base_model_slug == base_model)

count_stmt = select(func.count()).select_from(model)
list_stmt = select(model)
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(resource, row.slug, row.name) for row in rows]
applied = {
k: v
for k, v in (
("brand", brand),
("soc", soc),
("base_model_slug", base_model),
("sort", sort),
)
if v
}
return build_ref_page(
refs, count=count, path=f"/v1/{resource}", pagination=pagination, filters=applied
)

@router.get("/{slug}", summary=f"Get a {resource[:-1]}")
def get_device(slug: str, session: SessionDep) -> MobileDeviceRead:
device = session.exec(select(model).where(model.slug == slug)).first()
if device is None:
raise not_found(resource[:-1].title(), slug)
brand = session.get(Brand, device.brand_id)
if brand is None: # pragma: no cover - guarded by FK + validation
raise not_found("Brand", str(device.brand_id))

soc = None
soc_manufacturer = None
if device.soc_id is not None:
soc = session.get(SoC, device.soc_id)
if soc is None: # pragma: no cover
raise not_found("SoC", str(device.soc_id))
soc_manufacturer = session.get(Brand, soc.manufacturer_id)
if soc_manufacturer is None: # pragma: no cover
raise not_found("Brand", str(soc.manufacturer_id))

return mobile_device_read(resource, device, brand, soc, soc_manufacturer)

return router


tablets_router = _make_router("tablets", Tablet)
watches_router = _make_router("watches", Watch)
pdas_router = _make_router("pdas", PDA)
45 changes: 45 additions & 0 deletions app/schemas/mobile_device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Response schemas for tablets, watches, and PDAs."""

from __future__ import annotations

from datetime import date, datetime
from typing import Any

from pydantic import BaseModel, Field

from app.schemas.brand import BrandSummary
from app.schemas.soc import SoCSummary


class MobileDeviceRead(BaseModel):
"""Full mobile device detail response."""

id: int
slug: str
base_model_slug: str | None = None
name: str
brand: BrandSummary
soc: SoCSummary | None = None
release_date: date
msrp_usd: int | None = None
ram_gb: float
storage_options_gb: list[int]
variant: dict[str, Any]
display: dict[str, Any]
cameras: list[dict[str, Any]]
battery_mah: int
charging_wired_w: float | None = None
charging_wireless_w: float | None = None
weight_g: float
dimensions: dict[str, Any]
ip_rating: str | None = None
os: str
os_version: str | None = None
connectivity: dict[str, Any]
image_url: str | None = None
images: list[str] = Field(default_factory=list)
verified: bool
source_urls: list[str]
created_at: datetime
updated_at: datetime
url: str
45 changes: 45 additions & 0 deletions app/schemas/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from app.models.brand import Brand
from app.models.cpu import CPU
from app.models.gpu import DiscreteGPU
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 ManufacturerRef, ResourceRef
from app.schemas.cpu import CPURead
from app.schemas.gpu import GPURead
from app.schemas.mobile_device import MobileDeviceRead
from app.schemas.smartphone import ScoreRead, SmartphoneRead
from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary
from app.services.scoring import Scores
Expand Down Expand Up @@ -182,13 +184,15 @@ def smartphone_read(
return SmartphoneRead(
id=phone.id,
slug=phone.slug,
base_model_slug=phone.base_model_slug,
name=phone.name,
brand=brand_summary(brand),
soc=soc_summary(soc, soc_manufacturer),
release_date=phone.release_date,
msrp_usd=phone.msrp_usd,
ram_gb=phone.ram_gb,
storage_options_gb=phone.storage_options_gb,
variant=phone.variant,
display=phone.display,
cameras=phone.cameras,
battery_mah=phone.battery_mah,
Expand Down Expand Up @@ -216,3 +220,44 @@ def smartphone_read(
created_at=phone.created_at,
updated_at=phone.updated_at,
)


def mobile_device_read(
resource: str,
device: MobileDeviceFields,
brand: Brand,
soc: SoC | None,
soc_manufacturer: Brand | None,
) -> MobileDeviceRead:
assert device.id is not None
return MobileDeviceRead(
id=device.id,
slug=device.slug,
base_model_slug=device.base_model_slug,
name=device.name,
brand=brand_summary(brand),
soc=soc_summary(soc, soc_manufacturer) if soc and soc_manufacturer else None,
release_date=device.release_date,
msrp_usd=device.msrp_usd,
ram_gb=device.ram_gb,
storage_options_gb=device.storage_options_gb,
variant=device.variant,
display=device.display,
cameras=device.cameras,
battery_mah=device.battery_mah,
charging_wired_w=device.charging_wired_w,
charging_wireless_w=device.charging_wireless_w,
weight_g=device.weight_g,
dimensions=device.dimensions,
ip_rating=device.ip_rating,
os=device.os,
os_version=device.os_version,
connectivity=device.connectivity,
image_url=device.image_url,
images=device.images,
verified=device.verified,
source_urls=device.source_urls,
created_at=device.created_at,
updated_at=device.updated_at,
url=url_for(resource, device.slug),
)
Loading
Loading