Skip to content

Commit f785908

Browse files
authored
feat(website): add unscored website category (§6.12) (#48)
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
1 parent 6be6574 commit f785908

10 files changed

Lines changed: 245 additions & 0 deletions

File tree

app/dump.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"monitors",
3434
"games",
3535
"software",
36+
"websites",
3637
]
3738
# Collections with a /score sub-resource (§8) and a `scored` manifest count.
3839
SCORED = {"smartphones", "cpus", "gpus", "socs"}

app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
smartphones,
2727
socs,
2828
software,
29+
websites,
2930
)
3031

3132
PREFIX = settings.api_version_prefix
@@ -90,6 +91,7 @@ async def add_request_id(
9091
app.include_router(monitors.router, prefix=PREFIX)
9192
app.include_router(games.router, prefix=PREFIX)
9293
app.include_router(software.router, prefix=PREFIX)
94+
app.include_router(websites.router, prefix=PREFIX)
9395

9496

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

app/models/website.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Website model (§6.12).
2+
3+
A website or web service. Like games and software, a website references no
4+
Brand — its operators are free-text ``owners``. Unscored.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from datetime import UTC, date, datetime
10+
11+
from sqlalchemy import JSON, Column
12+
from sqlmodel import Field, SQLModel
13+
14+
15+
def _utcnow() -> datetime:
16+
return datetime.now(UTC)
17+
18+
19+
class Website(SQLModel, table=True):
20+
"""A website (e.g. Wikipedia, Hacker News)."""
21+
22+
__tablename__ = "website"
23+
24+
id: int | None = Field(default=None, primary_key=True)
25+
slug: str = Field(index=True, unique=True)
26+
name: str
27+
28+
# The site's own address. Named homepage_url because ``url`` is reserved
29+
# across every read schema for the API self-link.
30+
homepage_url: str | None = None
31+
launch_date: date | None = None
32+
33+
owners: list[str] = Field(default_factory=list, sa_column=Column(JSON))
34+
languages: list[str] = Field(default_factory=list, sa_column=Column(JSON))
35+
36+
# Meta
37+
verified: bool = False
38+
source_urls: list[str] = Field(default_factory=list, sa_column=Column(JSON))
39+
created_at: datetime = Field(default_factory=_utcnow)
40+
updated_at: datetime = Field(default_factory=_utcnow)

app/routers/websites.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Website endpoints (§6.12). List + detail; websites 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 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.website import Website
15+
from app.routers.utils import build_ref_page
16+
from app.schemas.common import Page, ResourceRef
17+
from app.schemas.serializers import resource_ref, website_read
18+
from app.schemas.website import WebsiteRead
19+
20+
router = APIRouter(prefix="/websites", tags=["websites"])
21+
22+
_SORT_FIELDS: dict[str, Any] = {
23+
"name": Website.name,
24+
"launch_date": Website.launch_date,
25+
}
26+
27+
28+
def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]:
29+
if not sort:
30+
return stmt.order_by(Website.name)
31+
descending = sort.startswith("-")
32+
field = sort[1:] if descending else sort
33+
column = _SORT_FIELDS.get(field)
34+
if column is None:
35+
raise APIError(400, "INVALID_REQUEST", f"Cannot sort by '{field}'")
36+
return stmt.order_by(column.desc() if descending else column.asc())
37+
38+
39+
@router.get("", summary="List websites")
40+
def list_websites(
41+
session: SessionDep,
42+
pagination: PaginationDep,
43+
sort: Annotated[str | None, Query()] = None,
44+
) -> Page[ResourceRef]:
45+
count = session.exec(select(func.count()).select_from(Website)).one()
46+
list_stmt = _apply_sort(select(Website), sort)
47+
list_stmt = list_stmt.offset(pagination.offset).limit(pagination.limit)
48+
rows = session.exec(list_stmt).all()
49+
50+
refs = [resource_ref("websites", row.slug, row.name) for row in rows]
51+
applied = {k: v for k, v in (("sort", sort),) if v}
52+
return build_ref_page(
53+
refs, count=count, path="/v1/websites", pagination=pagination, filters=applied
54+
)
55+
56+
57+
@router.get("/{slug}", summary="Get a website")
58+
def get_website(slug: str, session: SessionDep) -> WebsiteRead:
59+
website = session.exec(select(Website).where(Website.slug == slug)).first()
60+
if website is None:
61+
raise not_found("Website", slug)
62+
return website_read(website)

app/schemas/serializers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from app.models.smartphone import Smartphone
1414
from app.models.soc import SoC
1515
from app.models.software import Software
16+
from app.models.website import Website
1617
from app.schemas.brand import BrandRead, BrandSummary
1718
from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef
1819
from app.schemas.cpu import CPURead, CPUScoreRead
@@ -24,6 +25,7 @@
2425
from app.schemas.smartphone import ScoreRead, SmartphoneRead
2526
from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary
2627
from app.schemas.software import SoftwareRead
28+
from app.schemas.website import WebsiteRead
2729
from app.services.scoring import CPUScore, GPUScore, Hybrid, PhoneScore, SoCScore
2830

2931
PREFIX = settings.api_version_prefix
@@ -428,3 +430,21 @@ def software_read(software: Software) -> SoftwareRead:
428430
updated_at=software.updated_at,
429431
url=url_for("software", software.slug),
430432
)
433+
434+
435+
def website_read(website: Website) -> WebsiteRead:
436+
assert website.id is not None
437+
return WebsiteRead(
438+
id=website.id,
439+
slug=website.slug,
440+
name=website.name,
441+
homepage_url=website.homepage_url,
442+
launch_date=website.launch_date,
443+
owners=website.owners,
444+
languages=website.languages,
445+
verified=website.verified,
446+
source_urls=website.source_urls,
447+
created_at=website.created_at,
448+
updated_at=website.updated_at,
449+
url=url_for("websites", website.slug),
450+
)

app/schemas/website.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Website response schema (§6.12). Websites are unscored (no ``score`` field)."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date, datetime
6+
7+
from pydantic import BaseModel
8+
9+
10+
class WebsiteRead(BaseModel):
11+
"""Full website detail response."""
12+
13+
id: int
14+
slug: str
15+
name: str
16+
homepage_url: str | None = None
17+
launch_date: date | None = None
18+
owners: list[str]
19+
languages: list[str]
20+
verified: bool
21+
source_urls: list[str]
22+
created_at: datetime
23+
updated_at: datetime
24+
url: str # API self-link, as on every other collection

app/seed.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from app.models.smartphone import Smartphone
3535
from app.models.soc import SoC
3636
from app.models.software import Software
37+
from app.models.website import Website
3738

3839
DATA_DIR = get_data_root()
3940

@@ -71,6 +72,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]:
7172
"monitors": 0,
7273
"games": 0,
7374
"software": 0,
75+
"websites": 0,
7476
}
7577

7678
# --- Brands ---
@@ -244,6 +246,15 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N
244246
counts["software"] += 1
245247
session.commit()
246248

249+
# --- Websites (standalone; no brand FK) ---
250+
website_slugs = _existing_slugs(session, Website)
251+
for record in _load_dir(data_dir / "website"):
252+
if record["slug"] in website_slugs:
253+
continue
254+
session.add(Website(**record))
255+
counts["websites"] += 1
256+
session.commit()
257+
247258
return counts
248259

249260

app/validate.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@
124124
"verified",
125125
}
126126

127+
WEBSITE_REQUIRED = {
128+
"slug",
129+
"name",
130+
"source_urls",
131+
"verified",
132+
}
133+
127134
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
128135

129136

@@ -238,6 +245,7 @@ def validate() -> list[str]:
238245
monitors = _load("monitor")
239246
games = _load("game")
240247
software = _load("software")
248+
websites = _load("website")
241249

242250
brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec}
243251
soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec}
@@ -257,6 +265,7 @@ def validate() -> list[str]:
257265
("monitor", monitors),
258266
("game", games),
259267
("software", software),
268+
("website", websites),
260269
):
261270
_check_unique_slugs(category, records, errors)
262271

@@ -425,6 +434,13 @@ def validate() -> list[str]:
425434
if rec.get("release_date") is not None:
426435
_check_date(fname, rec["release_date"], errors)
427436

437+
for fname, rec in websites:
438+
_check_required(fname, rec, WEBSITE_REQUIRED, errors)
439+
_check_source_urls(fname, rec, errors)
440+
_check_slug(fname, rec.get("slug"), errors)
441+
if rec.get("launch_date") is not None:
442+
_check_date(fname, rec["launch_date"], errors)
443+
428444
return errors
429445

430446

tests/integration/test_websites.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Integration tests for website endpoints (unscored category)."""
2+
3+
from __future__ import annotations
4+
5+
from fastapi.testclient import TestClient
6+
7+
from tests.integration.website_fixtures import ensure_website_fixtures
8+
9+
10+
def test_list_websites(client: TestClient) -> None:
11+
ensure_website_fixtures()
12+
body = client.get("/v1/websites").json()
13+
assert body["count"] >= 1
14+
assert "results" in body
15+
16+
17+
def test_website_detail(client: TestClient) -> None:
18+
ensure_website_fixtures()
19+
body = client.get("/v1/websites/wikipedia-test").json()
20+
assert body["slug"] == "wikipedia-test"
21+
assert body["homepage_url"] == "https://www.wikipedia.org/"
22+
assert "English" in body["languages"]
23+
assert "Wikimedia Foundation" in body["owners"]
24+
# `url` is the API self-link, distinct from the site's own address.
25+
assert body["url"].endswith("/v1/websites/wikipedia-test")
26+
# Websites are unscored — no score field.
27+
assert "score" not in body
28+
29+
30+
def test_website_sort_rejects_unknown_field(client: TestClient) -> None:
31+
ensure_website_fixtures()
32+
assert client.get("/v1/websites", params={"sort": "nope"}).status_code == 400
33+
34+
35+
def test_website_not_found(client: TestClient) -> None:
36+
ensure_website_fixtures()
37+
assert client.get("/v1/websites/nonexistent-website").status_code == 404
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Small database fixtures for website endpoint tests."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date
6+
7+
from sqlmodel import Session, select
8+
9+
from app.database import engine
10+
from app.models.website import Website
11+
12+
13+
def ensure_website_fixtures() -> None:
14+
"""Insert a compact website when the data checkout lacks it."""
15+
16+
with Session(engine) as session:
17+
site = session.exec(
18+
select(Website).where(Website.slug == "wikipedia-test")
19+
).first()
20+
if site is None:
21+
session.add(
22+
Website(
23+
slug="wikipedia-test",
24+
name="Wikipedia (test)",
25+
homepage_url="https://www.wikipedia.org/",
26+
launch_date=date(2001, 1, 15),
27+
owners=["Wikimedia Foundation"],
28+
languages=["English"],
29+
source_urls=["https://example.com"],
30+
)
31+
)
32+
session.commit()

0 commit comments

Comments
 (0)