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
1 change: 1 addition & 0 deletions app/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"gpus",
"cpus",
"laptops",
"monitors",
]
# Collections with a /score sub-resource (§8) and a `scored` manifest count.
SCORED = {"smartphones", "cpus", "gpus", "socs"}
Expand Down
13 changes: 12 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,17 @@
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, laptops, meta, mobile_devices, smartphones, socs
from app.routers import (
brands,
cpus,
gpus,
laptops,
meta,
mobile_devices,
monitors,
smartphones,
socs,
)

PREFIX = settings.api_version_prefix

Expand Down Expand Up @@ -75,6 +85,7 @@ async def add_request_id(
app.include_router(gpus.router, prefix=PREFIX)
app.include_router(cpus.router, prefix=PREFIX)
app.include_router(laptops.router, prefix=PREFIX)
app.include_router(monitors.router, prefix=PREFIX)


@app.get("/", include_in_schema=False)
Expand Down
14 changes: 13 additions & 1 deletion app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,19 @@
from app.models.gpu import DiscreteGPU
from app.models.laptop import Laptop
from app.models.mobile_device import PDA, Tablet, Watch
from app.models.monitor import Monitor
from app.models.smartphone import Smartphone
from app.models.soc import SoC

__all__ = ["Brand", "SoC", "Smartphone", "Tablet", "Watch", "PDA", "DiscreteGPU", "CPU", "Laptop"]
__all__ = [
"Brand",
"SoC",
"Smartphone",
"Tablet",
"Watch",
"PDA",
"DiscreteGPU",
"CPU",
"Laptop",
"Monitor",
]
57 changes: 57 additions & 0 deletions app/models/monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Monitor model (§6.9).

A computer monitor references a Brand (required). Display specs (size,
resolution, refresh, panel, curvature, HDR) are first-class fields. Monitors
are currently unscored.
"""

from __future__ import annotations

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

from sqlalchemy import JSON, Column
from sqlmodel import Field, SQLModel


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


class Monitor(SQLModel, table=True):
"""A computer monitor model/variant (e.g. LG UltraGear 27GP850)."""

__tablename__ = "monitors"

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)

release_date: date
msrp_usd: int | None = None

# Display
size_inch: float
resolution: str # e.g. "1920x1080"
aspect_ratio: str | None = None # e.g. "16:9"
refresh_hz: int | None = None
panel_type: str | None = None # IPS / VA / OLED / TN
curvature: str | None = None # e.g. "1500R"
hdr: str | None = None
ppi: int | None = None

# Extras — {ports: [...], speakers, adaptive_sync, ...}
features: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
rating: float | None = None

# Source tracking
variant: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
image_url: str | None = None

# Meta
verified: bool = False
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
created_at: datetime = Field(default_factory=_utcnow)
updated_at: datetime = Field(default_factory=_utcnow)
105 changes: 105 additions & 0 deletions app/routers/monitors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Monitor endpoints (§6.9). List + detail; monitors are unscored."""

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.monitor import Monitor
from app.routers.utils import build_ref_page
from app.schemas.common import Page, ResourceRef
from app.schemas.monitor import MonitorRead
from app.schemas.serializers import monitor_read, resource_ref

router = APIRouter(prefix="/monitors", tags=["monitors"])

_SORT_FIELDS: dict[str, Any] = {
"name": Monitor.name,
"release_date": Monitor.release_date,
"msrp_usd": Monitor.msrp_usd,
"size_inch": Monitor.size_inch,
"refresh_hz": Monitor.refresh_hz,
}


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 _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]:
if not sort:
return stmt.order_by(Monitor.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="List monitors")
def list_monitors(
session: SessionDep,
pagination: PaginationDep,
brand: Annotated[str | None, Query()] = None,
panel_type: Annotated[str | None, Query()] = None,
base_model: Annotated[str | None, Query(alias="base_model_slug")] = None,
sort: Annotated[str | None, Query()] = None,
) -> Page[ResourceRef]:
brand_id = _resolve_id(session, Brand, brand)
if brand_id == "MISSING":
return build_ref_page([], count=0, path="/v1/monitors", pagination=pagination)

filters = []
if brand_id is not None:
filters.append(Monitor.brand_id == brand_id)
if panel_type is not None:
filters.append(Monitor.panel_type == panel_type)
if base_model is not None:
filters.append(Monitor.base_model_slug == base_model)

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


@router.get("/{slug}", summary="Get a monitor")
def get_monitor(slug: str, session: SessionDep) -> MonitorRead:
monitor = session.exec(select(Monitor).where(Monitor.slug == slug)).first()
if monitor is None:
raise not_found("Monitor", slug)
brand = session.get(Brand, monitor.brand_id)
if brand is None: # pragma: no cover - guarded by FK + validation
raise not_found("Brand", str(monitor.brand_id))
return monitor_read(monitor, brand)
2 changes: 2 additions & 0 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from app.schemas.brand import BrandRead, BrandSummary
from app.schemas.common import ErrorBody, ErrorResponse, Page, ResourceRef
from app.schemas.laptop import LaptopRead
from app.schemas.monitor import MonitorRead
from app.schemas.smartphone import ScoreRead, SmartphoneRead
from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary

Expand All @@ -19,4 +20,5 @@
"SmartphoneRead",
"ScoreRead",
"LaptopRead",
"MonitorRead",
]
39 changes: 39 additions & 0 deletions app/schemas/monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Monitor response schema (§6.9). Monitors are unscored (no ``score`` field)."""

from __future__ import annotations

from datetime import date, datetime
from typing import Any

from pydantic import BaseModel

from app.schemas.brand import BrandSummary


class MonitorRead(BaseModel):
"""Full monitor detail response."""

id: int
slug: str
base_model_slug: str | None = None
name: str
brand: BrandSummary
release_date: date
msrp_usd: int | None = None
size_inch: float
resolution: str
aspect_ratio: str | None = None
refresh_hz: int | None = None
panel_type: str | None = None
curvature: str | None = None
hdr: str | None = None
ppi: int | None = None
features: dict[str, Any]
rating: float | None = None
variant: dict[str, Any]
image_url: str | None = None
verified: bool
source_urls: list[str]
created_at: datetime
updated_at: datetime
url: str
32 changes: 32 additions & 0 deletions app/schemas/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from app.models.gpu import DiscreteGPU
from app.models.laptop import Laptop
from app.models.mobile_device import MobileDeviceFields
from app.models.monitor import Monitor
from app.models.smartphone import Smartphone
from app.models.soc import SoC
from app.schemas.brand import BrandRead, BrandSummary
Expand All @@ -16,6 +17,7 @@
from app.schemas.gpu import GPURead, GPUScoreRead
from app.schemas.laptop import LaptopRead
from app.schemas.mobile_device import MobileDeviceRead
from app.schemas.monitor import MonitorRead
from app.schemas.smartphone import ScoreRead, SmartphoneRead
from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary
from app.services.scoring import CPUScore, GPUScore, Hybrid, PhoneScore, SoCScore
Expand Down Expand Up @@ -344,3 +346,33 @@ def laptop_read(
updated_at=laptop.updated_at,
url=url_for("laptops", laptop.slug),
)


def monitor_read(monitor: Monitor, brand: Brand) -> MonitorRead:
assert monitor.id is not None
return MonitorRead(
id=monitor.id,
slug=monitor.slug,
base_model_slug=monitor.base_model_slug,
name=monitor.name,
brand=brand_summary(brand),
release_date=monitor.release_date,
msrp_usd=monitor.msrp_usd,
size_inch=monitor.size_inch,
resolution=monitor.resolution,
aspect_ratio=monitor.aspect_ratio,
refresh_hz=monitor.refresh_hz,
panel_type=monitor.panel_type,
curvature=monitor.curvature,
hdr=monitor.hdr,
ppi=monitor.ppi,
features=monitor.features,
rating=monitor.rating,
variant=monitor.variant,
image_url=monitor.image_url,
verified=monitor.verified,
source_urls=monitor.source_urls,
created_at=monitor.created_at,
updated_at=monitor.updated_at,
url=url_for("monitors", monitor.slug),
)
17 changes: 17 additions & 0 deletions app/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from app.models.gpu import DiscreteGPU
from app.models.laptop import Laptop
from app.models.mobile_device import PDA, Tablet, Watch
from app.models.monitor import Monitor
from app.models.smartphone import Smartphone
from app.models.soc import SoC

Expand Down Expand Up @@ -65,6 +66,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]:
"gpus": 0,
"cpus": 0,
"laptops": 0,
"monitors": 0,
}

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

# --- Monitors (reference brand [required]) ---
monitor_slugs = _existing_slugs(session, Monitor)
for record in _load_dir(data_dir / "monitor"):
if record["slug"] in monitor_slugs:
continue
brand_slug = record.pop("brand")
brand_id = brand_id_by_slug.get(brand_slug)
if brand_id is None:
raise ValueError(
f"Monitor '{record['slug']}' references unknown brand '{brand_slug}'"
)
session.add(Monitor(brand_id=brand_id, **record))
counts["monitors"] += 1
session.commit()

return counts


Expand Down
Loading
Loading