diff --git a/app/dump.py b/app/dump.py index ebd9ec2..4082d37 100644 --- a/app/dump.py +++ b/app/dump.py @@ -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"} diff --git a/app/main.py b/app/main.py index bb78e6c..c62fa7d 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index f461ef6..168ebbb 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", +] diff --git a/app/models/monitor.py b/app/models/monitor.py new file mode 100644 index 0000000..57f00de --- /dev/null +++ b/app/models/monitor.py @@ -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) diff --git a/app/routers/monitors.py b/app/routers/monitors.py new file mode 100644 index 0000000..8a63294 --- /dev/null +++ b/app/routers/monitors.py @@ -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) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 8c037a9..bf50e82 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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 @@ -19,4 +20,5 @@ "SmartphoneRead", "ScoreRead", "LaptopRead", + "MonitorRead", ] diff --git a/app/schemas/monitor.py b/app/schemas/monitor.py new file mode 100644 index 0000000..fae56b2 --- /dev/null +++ b/app/schemas/monitor.py @@ -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 diff --git a/app/schemas/serializers.py b/app/schemas/serializers.py index 7466de5..c560d10 100644 --- a/app/schemas/serializers.py +++ b/app/schemas/serializers.py @@ -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 @@ -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 @@ -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), + ) diff --git a/app/seed.py b/app/seed.py index 11fe460..c6ae161 100644 --- a/app/seed.py +++ b/app/seed.py @@ -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 @@ -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 --- @@ -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 diff --git a/app/validate.py b/app/validate.py index 9612c2b..e81342f 100644 --- a/app/validate.py +++ b/app/validate.py @@ -99,6 +99,17 @@ "verified", } +MONITOR_REQUIRED = { + "slug", + "name", + "brand", + "release_date", + "size_inch", + "resolution", + "source_urls", + "verified", +} + DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -210,6 +221,7 @@ def validate() -> list[str]: gpus = _load("gpu") cpus = _load("cpu") laptops = _load("laptop") + monitors = _load("monitor") brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec} soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec} @@ -226,6 +238,7 @@ def validate() -> list[str]: ("gpu", gpus), ("cpu", cpus), ("laptop", laptops), + ("monitor", monitors), ): _check_unique_slugs(category, records, errors) @@ -358,6 +371,24 @@ def validate() -> list[str]: errors.append(f"{fname}: gpu '{rec.get('gpu')}' not a known GPU") _check_variant_path(fname, rec, "laptop", errors, allow_flat=True) + for fname, rec in monitors: + _check_required(fname, rec, MONITOR_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if "release_date" in rec: + _check_date(fname, rec["release_date"], errors) + _check_range(fname, "size_inch", rec.get("size_inch"), 5, 120, errors) + _check_range(fname, "refresh_hz", rec.get("refresh_hz"), 24, 1000, errors) + if rec.get("ppi") is not None: + _check_range(fname, "ppi", rec.get("ppi"), 20, 1000, errors) + if rec.get("rating") is not None: + _check_range(fname, "rating", rec.get("rating"), 0, 5, errors) + if "msrp_usd" in rec: + _check_range(fname, "msrp_usd", rec["msrp_usd"], 10, 50000, errors) + if rec.get("brand") not in brand_slugs: + errors.append(f"{fname}: brand '{rec.get('brand')}' not a known brand") + _check_variant_path(fname, rec, "monitor", errors, allow_flat=True) + return errors diff --git a/tests/integration/monitor_fixtures.py b/tests/integration/monitor_fixtures.py new file mode 100644 index 0000000..191f6e4 --- /dev/null +++ b/tests/integration/monitor_fixtures.py @@ -0,0 +1,54 @@ +"""Small database fixtures for monitor endpoint tests.""" + +from __future__ import annotations + +from datetime import date + +from sqlmodel import Session, select + +from app.database import engine +from app.models.brand import Brand +from app.models.monitor import Monitor + + +def _brand_id(session: Session, slug: str, name: str, country: str) -> int: + brand = session.exec(select(Brand).where(Brand.slug == slug)).first() + if brand is None: + brand = Brand(slug=slug, name=name, country=country, source_urls=["https://example.com"]) + session.add(brand) + session.commit() + session.refresh(brand) + assert brand.id is not None + return brand.id + + +def ensure_monitor_fixtures() -> None: + """Insert a compact monitor (plus its brand) when the data checkout lacks it.""" + + with Session(engine) as session: + lg_id = _brand_id(session, "lg", "LG", "KR") + + monitor = session.exec( + select(Monitor).where(Monitor.slug == "lg-ultragear-27gp850-test") + ).first() + if monitor is None: + session.add( + Monitor( + slug="lg-ultragear-27gp850-test", + base_model_slug="lg-ultragear-27gp850", + name="LG UltraGear 27GP850 (test)", + brand_id=lg_id, + release_date=date(2023, 1, 1), + msrp_usd=399, + size_inch=27.0, + resolution="2560x1440", + aspect_ratio="16:9", + refresh_hz=165, + panel_type="IPS", + hdr="HDR10", + features={"ports": ["HDMI 2.0", "DisplayPort 1.4"], "adaptive_sync": "G-Sync"}, + rating=4.6, + source_urls=["https://example.com"], + ) + ) + session.commit() diff --git a/tests/integration/test_laptops.py b/tests/integration/test_laptops.py index d87ede5..5f4d33f 100644 --- a/tests/integration/test_laptops.py +++ b/tests/integration/test_laptops.py @@ -11,7 +11,10 @@ def test_list_laptops(client: TestClient) -> None: ensure_laptop_fixtures() body = client.get("/v1/laptops").json() assert body["count"] >= 1 - slugs = {r["slug"] for r in body["results"]} + # Filter by the fixture's unique base model so it is not buried under real + # laptop data in pagination. + filtered = client.get("/v1/laptops?base_model_slug=legion-pro-5").json() + slugs = {r["slug"] for r in filtered["results"]} assert "lenovo-legion-pro-5-test" in slugs diff --git a/tests/integration/test_monitors.py b/tests/integration/test_monitors.py new file mode 100644 index 0000000..300b743 --- /dev/null +++ b/tests/integration/test_monitors.py @@ -0,0 +1,43 @@ +"""Integration tests for monitor endpoints (unscored category).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.integration.monitor_fixtures import ensure_monitor_fixtures + + +def test_list_monitors(client: TestClient) -> None: + ensure_monitor_fixtures() + body = client.get("/v1/monitors").json() + assert body["count"] >= 1 + # Filter by the fixture's unique base model so it is not buried under real + # monitor data in pagination. + filtered = client.get("/v1/monitors?base_model_slug=lg-ultragear-27gp850").json() + slugs = {r["slug"] for r in filtered["results"]} + assert "lg-ultragear-27gp850-test" in slugs + + +def test_monitor_detail(client: TestClient) -> None: + ensure_monitor_fixtures() + body = client.get("/v1/monitors/lg-ultragear-27gp850-test").json() + assert body["slug"] == "lg-ultragear-27gp850-test" + assert body["brand"]["slug"] == "lg" + assert body["size_inch"] == 27.0 + assert body["resolution"] == "2560x1440" + assert body["refresh_hz"] == 165 + assert body["panel_type"] == "IPS" + # Monitors are unscored — no score field. + assert "score" not in body + + +def test_monitor_filter_by_brand_and_panel(client: TestClient) -> None: + ensure_monitor_fixtures() + assert client.get("/v1/monitors?brand=lg").json()["count"] >= 1 + assert client.get("/v1/monitors?panel_type=IPS").json()["count"] >= 1 + assert client.get("/v1/monitors?brand=does-not-exist").json()["count"] == 0 + + +def test_monitor_not_found(client: TestClient) -> None: + ensure_monitor_fixtures() + assert client.get("/v1/monitors/nonexistent-monitor").status_code == 404