From 0e07645241619c58aafbacb4c4fbc49dc1136ca3 Mon Sep 17 00:00:00 2001 From: Seungpyo1007 Date: Thu, 30 Jul 2026 12:04:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(website):=20add=20unscored=20website=20cat?= =?UTF-8?q?egory=20(=C2=A76.12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `website` collection for websites and web services, following the same standalone shape as game and software: no Brand FK (operators are a free-text `owners` list) and no scoring. Fields were chosen from what the source actually populates rather than from what a website could theoretically have: homepage_url (89% populated upstream), launch_date (42%), languages (41%), owners (17%). `homepage_url` rather than `url` for the site's own address -- `url` is reserved across every read schema for the API self-link, and a collision there would have silently shadowed one of the two. - model/schema/serializer/router, registered in main.py and dump.py COLLECTIONS - seed loads data/website; validate.py enforces WEBSITE_REQUIRED {slug, name, source_urls, verified} plus slug/date/source-url checks - 4 integration tests: list, detail, unknown-sort rejection, 404 --- app/dump.py | 1 + app/main.py | 2 + app/models/website.py | 40 +++++++++++++++++ app/routers/websites.py | 62 +++++++++++++++++++++++++++ app/schemas/serializers.py | 20 +++++++++ app/schemas/website.py | 24 +++++++++++ app/seed.py | 11 +++++ app/validate.py | 16 +++++++ tests/integration/test_websites.py | 37 ++++++++++++++++ tests/integration/website_fixtures.py | 32 ++++++++++++++ 10 files changed, 245 insertions(+) create mode 100644 app/models/website.py create mode 100644 app/routers/websites.py create mode 100644 app/schemas/website.py create mode 100644 tests/integration/test_websites.py create mode 100644 tests/integration/website_fixtures.py diff --git a/app/dump.py b/app/dump.py index 71a83ff..01445c8 100644 --- a/app/dump.py +++ b/app/dump.py @@ -33,6 +33,7 @@ "monitors", "games", "software", + "websites", ] # 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 d708c87..ae7f479 100644 --- a/app/main.py +++ b/app/main.py @@ -26,6 +26,7 @@ smartphones, socs, software, + websites, ) PREFIX = settings.api_version_prefix @@ -90,6 +91,7 @@ async def add_request_id( app.include_router(monitors.router, prefix=PREFIX) app.include_router(games.router, prefix=PREFIX) app.include_router(software.router, prefix=PREFIX) +app.include_router(websites.router, prefix=PREFIX) @app.get("/", include_in_schema=False) diff --git a/app/models/website.py b/app/models/website.py new file mode 100644 index 0000000..d25b974 --- /dev/null +++ b/app/models/website.py @@ -0,0 +1,40 @@ +"""Website model (§6.12). + +A website or web service. Like games and software, a website references no +Brand — its operators are free-text ``owners``. Unscored. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(UTC) + + +class Website(SQLModel, table=True): + """A website (e.g. Wikipedia, Hacker News).""" + + __tablename__ = "website" + + id: int | None = Field(default=None, primary_key=True) + slug: str = Field(index=True, unique=True) + name: str + + # The site's own address. Named homepage_url because ``url`` is reserved + # across every read schema for the API self-link. + homepage_url: str | None = None + launch_date: date | None = None + + owners: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + languages: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + + # 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/websites.py b/app/routers/websites.py new file mode 100644 index 0000000..6ab2d4f --- /dev/null +++ b/app/routers/websites.py @@ -0,0 +1,62 @@ +"""Website endpoints (§6.12). List + detail; websites are unscored.""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from sqlalchemy import func +from sqlmodel import select +from sqlmodel.sql.expression import SelectOfScalar + +from app.dependencies import PaginationDep, SessionDep +from app.errors import APIError, not_found +from app.models.website import Website +from app.routers.utils import build_ref_page +from app.schemas.common import Page, ResourceRef +from app.schemas.serializers import resource_ref, website_read +from app.schemas.website import WebsiteRead + +router = APIRouter(prefix="/websites", tags=["websites"]) + +_SORT_FIELDS: dict[str, Any] = { + "name": Website.name, + "launch_date": Website.launch_date, +} + + +def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]: + if not sort: + return stmt.order_by(Website.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 websites") +def list_websites( + session: SessionDep, + pagination: PaginationDep, + sort: Annotated[str | None, Query()] = None, +) -> Page[ResourceRef]: + count = session.exec(select(func.count()).select_from(Website)).one() + list_stmt = _apply_sort(select(Website), sort) + list_stmt = list_stmt.offset(pagination.offset).limit(pagination.limit) + rows = session.exec(list_stmt).all() + + refs = [resource_ref("websites", row.slug, row.name) for row in rows] + applied = {k: v for k, v in (("sort", sort),) if v} + return build_ref_page( + refs, count=count, path="/v1/websites", pagination=pagination, filters=applied + ) + + +@router.get("/{slug}", summary="Get a website") +def get_website(slug: str, session: SessionDep) -> WebsiteRead: + website = session.exec(select(Website).where(Website.slug == slug)).first() + if website is None: + raise not_found("Website", slug) + return website_read(website) diff --git a/app/schemas/serializers.py b/app/schemas/serializers.py index 27f27fd..445ef06 100644 --- a/app/schemas/serializers.py +++ b/app/schemas/serializers.py @@ -13,6 +13,7 @@ from app.models.smartphone import Smartphone from app.models.soc import SoC from app.models.software import Software +from app.models.website import Website from app.schemas.brand import BrandRead, BrandSummary from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef from app.schemas.cpu import CPURead, CPUScoreRead @@ -24,6 +25,7 @@ from app.schemas.smartphone import ScoreRead, SmartphoneRead from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary from app.schemas.software import SoftwareRead +from app.schemas.website import WebsiteRead from app.services.scoring import CPUScore, GPUScore, Hybrid, PhoneScore, SoCScore PREFIX = settings.api_version_prefix @@ -428,3 +430,21 @@ def software_read(software: Software) -> SoftwareRead: updated_at=software.updated_at, url=url_for("software", software.slug), ) + + +def website_read(website: Website) -> WebsiteRead: + assert website.id is not None + return WebsiteRead( + id=website.id, + slug=website.slug, + name=website.name, + homepage_url=website.homepage_url, + launch_date=website.launch_date, + owners=website.owners, + languages=website.languages, + verified=website.verified, + source_urls=website.source_urls, + created_at=website.created_at, + updated_at=website.updated_at, + url=url_for("websites", website.slug), + ) diff --git a/app/schemas/website.py b/app/schemas/website.py new file mode 100644 index 0000000..6148431 --- /dev/null +++ b/app/schemas/website.py @@ -0,0 +1,24 @@ +"""Website response schema (§6.12). Websites are unscored (no ``score`` field).""" + +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel + + +class WebsiteRead(BaseModel): + """Full website detail response.""" + + id: int + slug: str + name: str + homepage_url: str | None = None + launch_date: date | None = None + owners: list[str] + languages: list[str] + verified: bool + source_urls: list[str] + created_at: datetime + updated_at: datetime + url: str # API self-link, as on every other collection diff --git a/app/seed.py b/app/seed.py index 57778de..2794518 100644 --- a/app/seed.py +++ b/app/seed.py @@ -34,6 +34,7 @@ from app.models.smartphone import Smartphone from app.models.soc import SoC from app.models.software import Software +from app.models.website import Website DATA_DIR = get_data_root() @@ -71,6 +72,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]: "monitors": 0, "games": 0, "software": 0, + "websites": 0, } # --- Brands --- @@ -244,6 +246,15 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N counts["software"] += 1 session.commit() + # --- Websites (standalone; no brand FK) --- + website_slugs = _existing_slugs(session, Website) + for record in _load_dir(data_dir / "website"): + if record["slug"] in website_slugs: + continue + session.add(Website(**record)) + counts["websites"] += 1 + session.commit() + return counts diff --git a/app/validate.py b/app/validate.py index ca24fb5..680d70f 100644 --- a/app/validate.py +++ b/app/validate.py @@ -124,6 +124,13 @@ "verified", } +WEBSITE_REQUIRED = { + "slug", + "name", + "source_urls", + "verified", +} + DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -238,6 +245,7 @@ def validate() -> list[str]: monitors = _load("monitor") games = _load("game") software = _load("software") + websites = _load("website") brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec} soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec} @@ -257,6 +265,7 @@ def validate() -> list[str]: ("monitor", monitors), ("game", games), ("software", software), + ("website", websites), ): _check_unique_slugs(category, records, errors) @@ -425,6 +434,13 @@ def validate() -> list[str]: if rec.get("release_date") is not None: _check_date(fname, rec["release_date"], errors) + for fname, rec in websites: + _check_required(fname, rec, WEBSITE_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if rec.get("launch_date") is not None: + _check_date(fname, rec["launch_date"], errors) + return errors diff --git a/tests/integration/test_websites.py b/tests/integration/test_websites.py new file mode 100644 index 0000000..39922ba --- /dev/null +++ b/tests/integration/test_websites.py @@ -0,0 +1,37 @@ +"""Integration tests for website endpoints (unscored category).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.integration.website_fixtures import ensure_website_fixtures + + +def test_list_websites(client: TestClient) -> None: + ensure_website_fixtures() + body = client.get("/v1/websites").json() + assert body["count"] >= 1 + assert "results" in body + + +def test_website_detail(client: TestClient) -> None: + ensure_website_fixtures() + body = client.get("/v1/websites/wikipedia-test").json() + assert body["slug"] == "wikipedia-test" + assert body["homepage_url"] == "https://www.wikipedia.org/" + assert "English" in body["languages"] + assert "Wikimedia Foundation" in body["owners"] + # `url` is the API self-link, distinct from the site's own address. + assert body["url"].endswith("/v1/websites/wikipedia-test") + # Websites are unscored — no score field. + assert "score" not in body + + +def test_website_sort_rejects_unknown_field(client: TestClient) -> None: + ensure_website_fixtures() + assert client.get("/v1/websites", params={"sort": "nope"}).status_code == 400 + + +def test_website_not_found(client: TestClient) -> None: + ensure_website_fixtures() + assert client.get("/v1/websites/nonexistent-website").status_code == 404 diff --git a/tests/integration/website_fixtures.py b/tests/integration/website_fixtures.py new file mode 100644 index 0000000..b9c181c --- /dev/null +++ b/tests/integration/website_fixtures.py @@ -0,0 +1,32 @@ +"""Small database fixtures for website endpoint tests.""" + +from __future__ import annotations + +from datetime import date + +from sqlmodel import Session, select + +from app.database import engine +from app.models.website import Website + + +def ensure_website_fixtures() -> None: + """Insert a compact website when the data checkout lacks it.""" + + with Session(engine) as session: + site = session.exec( + select(Website).where(Website.slug == "wikipedia-test") + ).first() + if site is None: + session.add( + Website( + slug="wikipedia-test", + name="Wikipedia (test)", + homepage_url="https://www.wikipedia.org/", + launch_date=date(2001, 1, 15), + owners=["Wikimedia Foundation"], + languages=["English"], + source_urls=["https://example.com"], + ) + ) + session.commit()