Skip to content

Commit b4dc648

Browse files
committed
feat: device identity fields, device_catalog category, optional phone battery/weight and brand country
Prepares the engine for the Google Play supported-devices / device-catalog import without inventing values: anything a source does not state stays null. - smartphone, tablet, watch, pda: optional model_numbers, codenames (lists of unique non-empty strings) and release_date_precision (day|month|year|year_estimated, null = unspecified), stored, seeded, serialized and dumped. - smartphone: battery_mah and weight_g are no longer required (null allowed, range checks kept when present); the battery score is null when the capacity is unknown. - brand: country is optional; a brand without one lives under brand/unknown/. - new unscored category device_catalog (data/device_catalog/<brand>/<slug>.json, no year folder), served at /v1/device-catalog: identity fields, optional Play Console specs (ram_gb, soc FK, soc_raw, gpu_raw, screen_resolution, screen_density_dpi, android_sdk_min/max), release_year only together with release_year_source, form_factor vs device_type_guess, promoted_to. - category registry is now 13 entries; scope strings derive the count.
1 parent 94c7deb commit b4dc648

23 files changed

Lines changed: 777 additions & 32 deletions

‎app/categories.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
CATEGORIES: tuple[str, ...] = (
44
"brand", "soc", "smartphone", "tablet", "watch", "pda", "gpu", "cpu",
5-
"laptop", "monitor", "software", "website",
5+
"laptop", "monitor", "software", "website", "device_catalog",
66
)
77
COLLECTIONS = dict(zip(CATEGORIES, (
88
"brands", "socs", "smartphones", "tablets", "watches", "pdas", "gpus", "cpus",
9-
"laptops", "monitors", "software", "websites",
9+
"laptops", "monitors", "software", "websites", "device-catalog",
1010
), strict=True))

‎app/dump.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def resolve_collections(
6060
# are partly relative to the population, so one record can move its neighbours.
6161
DEPENDENTS: dict[str, list[str] | None] = {
6262
"brand": None,
63-
"soc": ["socs", "smartphones", "tablets", "watches", "pdas"],
63+
"soc": ["socs", "smartphones", "tablets", "watches", "pdas", "device-catalog"],
6464
"cpu": ["cpus", "laptops"],
6565
"gpu": ["gpus", "laptops"],
6666
}

‎app/dump_check.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ def scope(repo: Path, base_ref: str, base_sha: str) -> str:
6060
count = sum(1 for category in CATEGORIES
6161
for p in (repo / "data" / category).rglob("*.json")
6262
if not p.name.startswith("_"))
63-
return f"12/12 categories ? {count:,} records ? diff base {base_ref}@{base_sha[:7]}"
63+
n = len(CATEGORIES)
64+
return f"{n}/{n} categories ? {count:,} records ? diff base {base_ref}@{base_sha[:7]}"
6465

6566

6667
def main() -> int:

‎app/main.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from app.routers import (
1818
brands,
1919
cpus,
20+
device_catalog,
2021
gpus,
2122
laptops,
2223
meta,
@@ -90,6 +91,7 @@ async def add_request_id(
9091
app.include_router(monitors.router, prefix=PREFIX)
9192
app.include_router(software.router, prefix=PREFIX)
9293
app.include_router(websites.router, prefix=PREFIX)
94+
app.include_router(device_catalog.router, prefix=PREFIX)
9395

9496

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

‎app/models/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from app.models.brand import Brand
77
from app.models.cpu import CPU
8+
from app.models.device_catalog import DeviceCatalog
89
from app.models.gpu import DiscreteGPU
910
from app.models.laptop import Laptop
1011
from app.models.mobile_device import PDA, Tablet, Watch
@@ -25,4 +26,5 @@
2526
"Laptop",
2627
"Monitor",
2728
"Software",
29+
"DeviceCatalog",
2830
]

‎app/models/device_catalog.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Device catalog model: identity-only device records.
2+
3+
A catalog entry anchors a device that is known to exist (brand, marketing name,
4+
model numbers, codenames — e.g. from Google Play's supported device list) but
5+
has no spec sheet yet. Nothing here is estimated: unknown fields stay null.
6+
Once a full record exists, ``promoted_to`` points at it. Unscored.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import UTC, datetime
12+
13+
from sqlalchemy import JSON, Column
14+
from sqlmodel import Field, SQLModel
15+
16+
17+
def _utcnow() -> datetime:
18+
return datetime.now(UTC)
19+
20+
21+
class DeviceCatalog(SQLModel, table=True):
22+
"""An identity-only device (e.g. a Play-listed phone with no spec source yet)."""
23+
24+
__tablename__ = "device_catalog"
25+
26+
id: int | None = Field(default=None, primary_key=True)
27+
slug: str = Field(index=True, unique=True)
28+
base_model_slug: str | None = Field(default=None, index=True)
29+
name: str
30+
brand_id: int = Field(foreign_key="brands.id", index=True)
31+
soc_id: int | None = Field(default=None, foreign_key="socs.id", index=True)
32+
33+
model_numbers: list[str] = Field(default_factory=list, sa_column=Column(JSON))
34+
codenames: list[str] = Field(default_factory=list, sa_column=Column(JSON))
35+
marketing_names: list[str] = Field(default_factory=list, sa_column=Column(JSON))
36+
37+
# phone | tablet | watch | tv | other — only when a source states it.
38+
form_factor: str | None = None
39+
# Same vocabulary, but a heuristic guess (name/model patterns), never a fact.
40+
device_type_guess: str | None = None
41+
# Play Console device-catalog specs, verbatim where they are strings.
42+
ram_gb: float | None = None
43+
soc_raw: str | None = None
44+
gpu_raw: str | None = None
45+
screen_resolution: str | None = None # "<w>x<h>", e.g. "1080x2400"
46+
screen_density_dpi: int | None = None
47+
android_sdk_min: int | None = None # an upper bound on launch year, not a date
48+
android_sdk_max: int | None = None
49+
# Only with provenance: "model_code" (vendor code encodes the year) | "record".
50+
release_year: int | None = None
51+
release_year_source: str | None = None
52+
# "<category>/<slug>" of the full record once one exists.
53+
promoted_to: str | None = None
54+
55+
# Meta
56+
verified: bool = False
57+
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
58+
created_at: datetime = Field(default_factory=_utcnow)
59+
updated_at: datetime = Field(default_factory=_utcnow)

‎app/models/mobile_device.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ class MobileDeviceFields(SQLModel):
2424
soc_id: int | None = Field(default=None, foreign_key="socs.id", index=True)
2525

2626
release_date: date
27+
# day | month | year | year_estimated; None = unspecified (legacy records).
28+
release_date_precision: str | None = None
2729
msrp_usd: int | None = None
2830

31+
# Identity keys from the vendor / Google Play device list (never derived).
32+
model_numbers: list[str] = Field(default_factory=list, sa_type=JSON)
33+
codenames: list[str] = Field(default_factory=list, sa_type=JSON)
34+
2935
ram_gb: float
3036
storage_options_gb: list[int] = Field(default_factory=list, sa_type=JSON)
3137
variant: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)

‎app/models/smartphone.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,14 @@ class Smartphone(SQLModel, table=True):
2626
soc_id: int = Field(foreign_key="socs.id", index=True)
2727

2828
release_date: date
29+
# day | month | year | year_estimated; None = unspecified (legacy records).
30+
release_date_precision: str | None = None
2931
msrp_usd: int | None = None
3032

33+
# Identity keys from the vendor / Google Play device list (never derived).
34+
model_numbers: list[str] = Field(default_factory=list, sa_column=Column(JSON))
35+
codenames: list[str] = Field(default_factory=list, sa_column=Column(JSON))
36+
3137
# Memory
3238
ram_gb: float # sub-GB on 2006-2012 phones (original iPhone 0.125)
3339
storage_options_gb: list[int] = Field(default_factory=list, sa_column=Column(JSON))
@@ -40,12 +46,12 @@ class Smartphone(SQLModel, table=True):
4046
cameras: list[dict[str, Any]] = Field(default_factory=list, sa_column=Column(JSON))
4147

4248
# Battery
43-
battery_mah: int
49+
battery_mah: int | None = None # null = unknown, never estimated
4450
charging_wired_w: float | None = None
4551
charging_wireless_w: float | None = None
4652

4753
# Physical
48-
weight_g: float
54+
weight_g: float | None = None
4955
dimensions: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
5056
ip_rating: str | None = None
5157

‎app/routers/device_catalog.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Device catalog endpoints. List + detail; identity-only records 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 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.device_catalog import DeviceCatalog
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.device_catalog import DeviceCatalogRead
20+
from app.schemas.serializers import device_catalog_read, resource_ref
21+
22+
router = APIRouter(prefix="/device-catalog", tags=["device-catalog"])
23+
24+
_SORT_FIELDS: dict[str, Any] = {
25+
"name": DeviceCatalog.name,
26+
"android_sdk_min": DeviceCatalog.android_sdk_min,
27+
}
28+
29+
30+
def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]:
31+
if not sort:
32+
return stmt.order_by(DeviceCatalog.name)
33+
descending = sort.startswith("-")
34+
field = sort[1:] if descending else sort
35+
column = _SORT_FIELDS.get(field)
36+
if column is None:
37+
raise APIError(400, "INVALID_REQUEST", f"Cannot sort by '{field}'")
38+
return stmt.order_by(column.desc() if descending else column.asc())
39+
40+
41+
@router.get("", summary="List device catalog entries")
42+
def list_device_catalog(
43+
session: SessionDep,
44+
pagination: PaginationDep,
45+
brand: Annotated[str | None, Query()] = None,
46+
form_factor: Annotated[str | None, Query()] = None,
47+
base_model: Annotated[str | None, Query(alias="base_model_slug")] = None,
48+
sort: Annotated[str | None, Query()] = None,
49+
) -> Page[ResourceRef]:
50+
filters = []
51+
if brand is not None:
52+
row = session.exec(select(Brand).where(Brand.slug == brand)).first()
53+
if row is None:
54+
return build_ref_page([], count=0, path="/v1/device-catalog", pagination=pagination)
55+
filters.append(DeviceCatalog.brand_id == row.id)
56+
if form_factor is not None:
57+
filters.append(DeviceCatalog.form_factor == form_factor)
58+
if base_model is not None:
59+
filters.append(DeviceCatalog.base_model_slug == base_model)
60+
61+
count_stmt = select(func.count()).select_from(DeviceCatalog)
62+
list_stmt = select(DeviceCatalog)
63+
for clause in filters:
64+
count_stmt = count_stmt.where(clause)
65+
list_stmt = list_stmt.where(clause)
66+
67+
count = session.exec(count_stmt).one()
68+
list_stmt = _apply_sort(list_stmt, sort).offset(pagination.offset).limit(pagination.limit)
69+
rows = session.exec(list_stmt).all()
70+
71+
refs = [resource_ref("device-catalog", row.slug, row.name) for row in rows]
72+
applied = {
73+
k: v
74+
for k, v in (
75+
("brand", brand),
76+
("form_factor", form_factor),
77+
("base_model_slug", base_model),
78+
("sort", sort),
79+
)
80+
if v
81+
}
82+
return build_ref_page(
83+
refs, count=count, path="/v1/device-catalog", pagination=pagination, filters=applied
84+
)
85+
86+
87+
@router.get("/{slug}", summary="Get a device catalog entry")
88+
def get_device_catalog_entry(slug: str, session: SessionDep) -> DeviceCatalogRead:
89+
entry = session.exec(select(DeviceCatalog).where(DeviceCatalog.slug == slug)).first()
90+
if entry is None:
91+
raise not_found("Device catalog entry", slug)
92+
brand = session.get(Brand, entry.brand_id)
93+
if brand is None: # pragma: no cover - guarded by FK + validation
94+
raise not_found("Brand", str(entry.brand_id))
95+
soc = session.get(SoC, entry.soc_id) if entry.soc_id is not None else None
96+
soc_manufacturer = session.get(Brand, soc.manufacturer_id) if soc is not None else None
97+
return device_catalog_read(entry, brand, soc, soc_manufacturer)

‎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.device_catalog import DeviceCatalogRead
56
from app.schemas.laptop import LaptopRead
67
from app.schemas.monitor import MonitorRead
78
from app.schemas.smartphone import ScoreRead, SmartphoneRead
@@ -23,4 +24,5 @@
2324
"LaptopRead",
2425
"MonitorRead",
2526
"SoftwareRead",
27+
"DeviceCatalogRead",
2628
]

0 commit comments

Comments
 (0)