Skip to content

Commit c906d9e

Browse files
committed
feat(monitor): add monitor category (model, schema, router, seed, dump, validate)
Second non-mobile device category, wired end-to-end like laptops: - app/models/monitor.py — Monitor table; brand FK required. Display specs (size_inch, resolution, aspect_ratio, refresh_hz, panel_type, curvature, hdr, ppi) as first-class fields. Unscored. - app/schemas/monitor.py + serializer monitor_read — detail response embeds brand; no score field. - app/routers/monitors.py — list + detail with brand/panel_type filters and size/refresh sort keys. - app/main.py — register the router. - app/seed.py — seed data/monitor/, resolve brand FK. - app/dump.py — add "monitors" to COLLECTIONS (not SCORED). - app/validate.py — MONITOR_REQUIRED + ranges (size 5-120, refresh 24-1000, msrp 10-50000, rating 0-5) + brand FK + variant path. - tests: monitor fixtures + integration tests. Paired with the TechAPI data PR seeding data/monitor/.
1 parent 6a31a76 commit c906d9e

12 files changed

Lines changed: 403 additions & 2 deletions

File tree

app/dump.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"gpus",
3131
"cpus",
3232
"laptops",
33+
"monitors",
3334
]
3435
# Collections with a /score sub-resource (§8) and a `scored` manifest count.
3536
SCORED = {"smartphones", "cpus", "gpus", "socs"}

app/main.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,17 @@
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, laptops, meta, mobile_devices, smartphones, socs
17+
from app.routers import (
18+
brands,
19+
cpus,
20+
gpus,
21+
laptops,
22+
meta,
23+
mobile_devices,
24+
monitors,
25+
smartphones,
26+
socs,
27+
)
1828

1929
PREFIX = settings.api_version_prefix
2030

@@ -75,6 +85,7 @@ async def add_request_id(
7585
app.include_router(gpus.router, prefix=PREFIX)
7686
app.include_router(cpus.router, prefix=PREFIX)
7787
app.include_router(laptops.router, prefix=PREFIX)
88+
app.include_router(monitors.router, prefix=PREFIX)
7889

7990

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

app/models/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@
88
from app.models.gpu import DiscreteGPU
99
from app.models.laptop import Laptop
1010
from app.models.mobile_device import PDA, Tablet, Watch
11+
from app.models.monitor import Monitor
1112
from app.models.smartphone import Smartphone
1213
from app.models.soc import SoC
1314

14-
__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU", "Laptop"]
15+
__all__ = [
16+
"Brand",
17+
"SoC",
18+
"Smartphone",
19+
"Tablet",
20+
"Watch",
21+
"PDA",
22+
"DiscreteGPU",
23+
"CPU",
24+
"Laptop",
25+
"Monitor",
26+
]

app/models/monitor.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Monitor model (§6.9).
2+
3+
A computer monitor references a Brand (required). Display specs (size,
4+
resolution, refresh, panel, curvature, HDR) are first-class fields. Monitors
5+
are currently unscored.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from datetime import UTC, date, datetime
11+
from typing import Any
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 Monitor(SQLModel, table=True):
22+
"""A computer monitor model/variant (e.g. LG UltraGear 27GP850)."""
23+
24+
__tablename__ = "monitors"
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+
32+
release_date: date
33+
msrp_usd: int | None = None
34+
35+
# Display
36+
size_inch: float
37+
resolution: str # e.g. "1920x1080"
38+
aspect_ratio: str | None = None # e.g. "16:9"
39+
refresh_hz: int | None = None
40+
panel_type: str | None = None # IPS / VA / OLED / TN
41+
curvature: str | None = None # e.g. "1500R"
42+
hdr: str | None = None
43+
ppi: int | None = None
44+
45+
# Extras — {ports: [...], speakers, adaptive_sync, ...}
46+
features: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
47+
rating: float | None = None
48+
49+
# Source tracking
50+
variant: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
51+
image_url: str | None = None
52+
53+
# Meta
54+
verified: bool = False
55+
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
56+
created_at: datetime = Field(default_factory=_utcnow)
57+
updated_at: datetime = Field(default_factory=_utcnow)

app/routers/monitors.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Monitor endpoints (§6.9). List + detail; monitors 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.monitor import Monitor
16+
from app.routers.utils import build_ref_page
17+
from app.schemas.common import Page, ResourceRef
18+
from app.schemas.monitor import MonitorRead
19+
from app.schemas.serializers import monitor_read, resource_ref
20+
21+
router = APIRouter(prefix="/monitors", tags=["monitors"])
22+
23+
_SORT_FIELDS: dict[str, Any] = {
24+
"name": Monitor.name,
25+
"release_date": Monitor.release_date,
26+
"msrp_usd": Monitor.msrp_usd,
27+
"size_inch": Monitor.size_inch,
28+
"refresh_hz": Monitor.refresh_hz,
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(Monitor.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 monitors")
51+
def list_monitors(
52+
session: SessionDep,
53+
pagination: PaginationDep,
54+
brand: Annotated[str | None, Query()] = None,
55+
panel_type: Annotated[str | None, Query()] = None,
56+
base_model: Annotated[str | None, Query(alias="base_model_slug")] = None,
57+
sort: Annotated[str | None, Query()] = None,
58+
) -> Page[ResourceRef]:
59+
brand_id = _resolve_id(session, Brand, brand)
60+
if brand_id == "MISSING":
61+
return build_ref_page([], count=0, path="/v1/monitors", pagination=pagination)
62+
63+
filters = []
64+
if brand_id is not None:
65+
filters.append(Monitor.brand_id == brand_id)
66+
if panel_type is not None:
67+
filters.append(Monitor.panel_type == panel_type)
68+
if base_model is not None:
69+
filters.append(Monitor.base_model_slug == base_model)
70+
71+
count_stmt = select(func.count()).select_from(Monitor)
72+
list_stmt = select(Monitor)
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("monitors", row.slug, row.name) for row in rows]
82+
applied = {
83+
k: v
84+
for k, v in (
85+
("brand", brand),
86+
("panel_type", panel_type),
87+
("base_model_slug", base_model),
88+
("sort", sort),
89+
)
90+
if v
91+
}
92+
return build_ref_page(
93+
refs, count=count, path="/v1/monitors", pagination=pagination, filters=applied
94+
)
95+
96+
97+
@router.get("/{slug}", summary="Get a monitor")
98+
def get_monitor(slug: str, session: SessionDep) -> MonitorRead:
99+
monitor = session.exec(select(Monitor).where(Monitor.slug == slug)).first()
100+
if monitor is None:
101+
raise not_found("Monitor", slug)
102+
brand = session.get(Brand, monitor.brand_id)
103+
if brand is None: # pragma: no cover - guarded by FK + validation
104+
raise not_found("Brand", str(monitor.brand_id))
105+
return monitor_read(monitor, brand)

app/schemas/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from app.schemas.brand import BrandRead, BrandSummary
44
from app.schemas.common import ErrorBody, ErrorResponse, Page, ResourceRef
55
from app.schemas.laptop import LaptopRead
6+
from app.schemas.monitor import MonitorRead
67
from app.schemas.smartphone import ScoreRead, SmartphoneRead
78
from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary
89

@@ -19,4 +20,5 @@
1920
"SmartphoneRead",
2021
"ScoreRead",
2122
"LaptopRead",
23+
"MonitorRead",
2224
]

app/schemas/monitor.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Monitor response schema (§6.9). Monitors are unscored (no ``score`` field)."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date, datetime
6+
from typing import Any
7+
8+
from pydantic import BaseModel
9+
10+
from app.schemas.brand import BrandSummary
11+
12+
13+
class MonitorRead(BaseModel):
14+
"""Full monitor detail response."""
15+
16+
id: int
17+
slug: str
18+
base_model_slug: str | None = None
19+
name: str
20+
brand: BrandSummary
21+
release_date: date
22+
msrp_usd: int | None = None
23+
size_inch: float
24+
resolution: str
25+
aspect_ratio: str | None = None
26+
refresh_hz: int | None = None
27+
panel_type: str | None = None
28+
curvature: str | None = None
29+
hdr: str | None = None
30+
ppi: int | None = None
31+
features: dict[str, Any]
32+
rating: float | None = None
33+
variant: dict[str, Any]
34+
image_url: str | None = None
35+
verified: bool
36+
source_urls: list[str]
37+
created_at: datetime
38+
updated_at: datetime
39+
url: str

app/schemas/serializers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.models.gpu import DiscreteGPU
99
from app.models.laptop import Laptop
1010
from app.models.mobile_device import MobileDeviceFields
11+
from app.models.monitor import Monitor
1112
from app.models.smartphone import Smartphone
1213
from app.models.soc import SoC
1314
from app.schemas.brand import BrandRead, BrandSummary
@@ -16,6 +17,7 @@
1617
from app.schemas.gpu import GPURead, GPUScoreRead
1718
from app.schemas.laptop import LaptopRead
1819
from app.schemas.mobile_device import MobileDeviceRead
20+
from app.schemas.monitor import MonitorRead
1921
from app.schemas.smartphone import ScoreRead, SmartphoneRead
2022
from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary
2123
from app.services.scoring import CPUScore, GPUScore, Hybrid, PhoneScore, SoCScore
@@ -344,3 +346,33 @@ def laptop_read(
344346
updated_at=laptop.updated_at,
345347
url=url_for("laptops", laptop.slug),
346348
)
349+
350+
351+
def monitor_read(monitor: Monitor, brand: Brand) -> MonitorRead:
352+
assert monitor.id is not None
353+
return MonitorRead(
354+
id=monitor.id,
355+
slug=monitor.slug,
356+
base_model_slug=monitor.base_model_slug,
357+
name=monitor.name,
358+
brand=brand_summary(brand),
359+
release_date=monitor.release_date,
360+
msrp_usd=monitor.msrp_usd,
361+
size_inch=monitor.size_inch,
362+
resolution=monitor.resolution,
363+
aspect_ratio=monitor.aspect_ratio,
364+
refresh_hz=monitor.refresh_hz,
365+
panel_type=monitor.panel_type,
366+
curvature=monitor.curvature,
367+
hdr=monitor.hdr,
368+
ppi=monitor.ppi,
369+
features=monitor.features,
370+
rating=monitor.rating,
371+
variant=monitor.variant,
372+
image_url=monitor.image_url,
373+
verified=monitor.verified,
374+
source_urls=monitor.source_urls,
375+
created_at=monitor.created_at,
376+
updated_at=monitor.updated_at,
377+
url=url_for("monitors", monitor.slug),
378+
)

app/seed.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from app.models.gpu import DiscreteGPU
3030
from app.models.laptop import Laptop
3131
from app.models.mobile_device import PDA, Tablet, Watch
32+
from app.models.monitor import Monitor
3233
from app.models.smartphone import Smartphone
3334
from app.models.soc import SoC
3435

@@ -65,6 +66,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]:
6566
"gpus": 0,
6667
"cpus": 0,
6768
"laptops": 0,
69+
"monitors": 0,
6870
}
6971

7072
# --- Brands ---
@@ -205,6 +207,21 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N
205207
counts["laptops"] += 1
206208
session.commit()
207209

210+
# --- Monitors (reference brand [required]) ---
211+
monitor_slugs = _existing_slugs(session, Monitor)
212+
for record in _load_dir(data_dir / "monitor"):
213+
if record["slug"] in monitor_slugs:
214+
continue
215+
brand_slug = record.pop("brand")
216+
brand_id = brand_id_by_slug.get(brand_slug)
217+
if brand_id is None:
218+
raise ValueError(
219+
f"Monitor '{record['slug']}' references unknown brand '{brand_slug}'"
220+
)
221+
session.add(Monitor(brand_id=brand_id, **record))
222+
counts["monitors"] += 1
223+
session.commit()
224+
208225
return counts
209226

210227

0 commit comments

Comments
 (0)