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 @@ -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"}
Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
smartphones,
socs,
software,
websites,
)

PREFIX = settings.api_version_prefix
Expand Down Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions app/models/website.py
Original file line number Diff line number Diff line change
@@ -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)
62 changes: 62 additions & 0 deletions app/routers/websites.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions app/schemas/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
)
24 changes: 24 additions & 0 deletions app/schemas/website.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions app/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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


Expand Down
16 changes: 16 additions & 0 deletions app/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,13 @@
"verified",
}

WEBSITE_REQUIRED = {
"slug",
"name",
"source_urls",
"verified",
}

DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


Expand Down Expand Up @@ -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}
Expand All @@ -257,6 +265,7 @@ def validate() -> list[str]:
("monitor", monitors),
("game", games),
("software", software),
("website", websites),
):
_check_unique_slugs(category, records, errors)

Expand Down Expand Up @@ -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


Expand Down
37 changes: 37 additions & 0 deletions tests/integration/test_websites.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions tests/integration/website_fixtures.py
Original file line number Diff line number Diff line change
@@ -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()
Loading