diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5710a45..c07e7da 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,9 @@ jobs: repository: GetTechAPI/TechAPI path: TechAPI - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" - cache: pip - name: Install dependencies run: pip install -e ".[dev]" diff --git a/app/dump.py b/app/dump.py index f949f1d..36c3ac7 100644 --- a/app/dump.py +++ b/app/dump.py @@ -32,6 +32,7 @@ "laptops", "monitors", "games", + "software", ] # 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 29d7965..d708c87 100644 --- a/app/main.py +++ b/app/main.py @@ -25,6 +25,7 @@ monitors, smartphones, socs, + software, ) PREFIX = settings.api_version_prefix @@ -88,6 +89,7 @@ async def add_request_id( app.include_router(laptops.router, prefix=PREFIX) app.include_router(monitors.router, prefix=PREFIX) app.include_router(games.router, prefix=PREFIX) +app.include_router(software.router, prefix=PREFIX) @app.get("/", include_in_schema=False) diff --git a/app/models/__init__.py b/app/models/__init__.py index d9e59f3..b974ca6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -12,6 +12,7 @@ from app.models.monitor import Monitor from app.models.smartphone import Smartphone from app.models.soc import SoC +from app.models.software import Software __all__ = [ "Brand", @@ -25,4 +26,5 @@ "Laptop", "Monitor", "Game", + "Software", ] diff --git a/app/models/software.py b/app/models/software.py new file mode 100644 index 0000000..e8cd345 --- /dev/null +++ b/app/models/software.py @@ -0,0 +1,41 @@ +"""Software model (§6.11). + +A software application/program. Like games, software references no Brand — its +makers are free-text ``developers`` / ``publishers`` lists. 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 Software(SQLModel, table=True): + """A software product (e.g. Blender, Firefox).""" + + __tablename__ = "software" + + id: int | None = Field(default=None, primary_key=True) + slug: str = Field(index=True, unique=True) + name: str + + release_date: date | None = None + + developers: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + publishers: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + operating_systems: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + programming_languages: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + licenses: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + genres: 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/software.py b/app/routers/software.py new file mode 100644 index 0000000..2038f37 --- /dev/null +++ b/app/routers/software.py @@ -0,0 +1,62 @@ +"""Software endpoints (§6.11). List + detail; software is 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.software import Software +from app.routers.utils import build_ref_page +from app.schemas.common import Page, ResourceRef +from app.schemas.serializers import resource_ref, software_read +from app.schemas.software import SoftwareRead + +router = APIRouter(prefix="/software", tags=["software"]) + +_SORT_FIELDS: dict[str, Any] = { + "name": Software.name, + "release_date": Software.release_date, +} + + +def _apply_sort(stmt: SelectOfScalar[Any], sort: str | None) -> SelectOfScalar[Any]: + if not sort: + return stmt.order_by(Software.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 software") +def list_software( + session: SessionDep, + pagination: PaginationDep, + sort: Annotated[str | None, Query()] = None, +) -> Page[ResourceRef]: + count = session.exec(select(func.count()).select_from(Software)).one() + list_stmt = _apply_sort(select(Software), sort) + list_stmt = list_stmt.offset(pagination.offset).limit(pagination.limit) + rows = session.exec(list_stmt).all() + + refs = [resource_ref("software", 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/software", pagination=pagination, filters=applied + ) + + +@router.get("/{slug}", summary="Get a software product") +def get_software(slug: str, session: SessionDep) -> SoftwareRead: + software = session.exec(select(Software).where(Software.slug == slug)).first() + if software is None: + raise not_found("Software", slug) + return software_read(software) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 5a5beb6..d0fccf4 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -7,6 +7,7 @@ from app.schemas.monitor import MonitorRead from app.schemas.smartphone import ScoreRead, SmartphoneRead from app.schemas.soc import SoCManufacturer, SoCRead, SoCSummary +from app.schemas.software import SoftwareRead __all__ = [ "Page", @@ -23,4 +24,5 @@ "LaptopRead", "MonitorRead", "GameRead", + "SoftwareRead", ] diff --git a/app/schemas/serializers.py b/app/schemas/serializers.py index be8c999..27f27fd 100644 --- a/app/schemas/serializers.py +++ b/app/schemas/serializers.py @@ -12,6 +12,7 @@ from app.models.monitor import Monitor from app.models.smartphone import Smartphone from app.models.soc import SoC +from app.models.software import Software from app.schemas.brand import BrandRead, BrandSummary from app.schemas.common import HybridRead, ManufacturerRef, ResourceRef from app.schemas.cpu import CPURead, CPUScoreRead @@ -22,6 +23,7 @@ from app.schemas.monitor import MonitorRead from app.schemas.smartphone import ScoreRead, SmartphoneRead from app.schemas.soc import SoCManufacturer, SoCRead, SoCScoreRead, SoCSummary +from app.schemas.software import SoftwareRead from app.services.scoring import CPUScore, GPUScore, Hybrid, PhoneScore, SoCScore PREFIX = settings.api_version_prefix @@ -405,3 +407,24 @@ def game_read(game: Game) -> GameRead: updated_at=game.updated_at, url=url_for("games", game.slug), ) + + +def software_read(software: Software) -> SoftwareRead: + assert software.id is not None + return SoftwareRead( + id=software.id, + slug=software.slug, + name=software.name, + release_date=software.release_date, + developers=software.developers, + publishers=software.publishers, + operating_systems=software.operating_systems, + programming_languages=software.programming_languages, + licenses=software.licenses, + genres=software.genres, + verified=software.verified, + source_urls=software.source_urls, + created_at=software.created_at, + updated_at=software.updated_at, + url=url_for("software", software.slug), + ) diff --git a/app/schemas/software.py b/app/schemas/software.py new file mode 100644 index 0000000..4e8c9f2 --- /dev/null +++ b/app/schemas/software.py @@ -0,0 +1,27 @@ +"""Software response schema (§6.11). Software is unscored (no ``score`` field).""" + +from __future__ import annotations + +from datetime import date, datetime + +from pydantic import BaseModel + + +class SoftwareRead(BaseModel): + """Full software detail response.""" + + id: int + slug: str + name: str + release_date: date | None = None + developers: list[str] + publishers: list[str] + operating_systems: list[str] + programming_languages: list[str] + licenses: list[str] + genres: list[str] + verified: bool + source_urls: list[str] + created_at: datetime + updated_at: datetime + url: str diff --git a/app/seed.py b/app/seed.py index 00d5309..57778de 100644 --- a/app/seed.py +++ b/app/seed.py @@ -33,6 +33,7 @@ from app.models.monitor import Monitor from app.models.smartphone import Smartphone from app.models.soc import SoC +from app.models.software import Software DATA_DIR = get_data_root() @@ -69,6 +70,7 @@ def seed(session: Session, data_dir: Path = DATA_DIR) -> dict[str, int]: "laptops": 0, "monitors": 0, "games": 0, + "software": 0, } # --- Brands --- @@ -233,6 +235,15 @@ def seed_mobile_devices(subdir: str, model: type[SQLModel], count_key: str) -> N counts["games"] += 1 session.commit() + # --- Software (standalone; no brand FK) --- + software_slugs = _existing_slugs(session, Software) + for record in _load_dir(data_dir / "software"): + if record["slug"] in software_slugs: + continue + session.add(Software(**record)) + counts["software"] += 1 + session.commit() + return counts diff --git a/app/validate.py b/app/validate.py index 4397f08..ca24fb5 100644 --- a/app/validate.py +++ b/app/validate.py @@ -117,6 +117,13 @@ "verified", } +SOFTWARE_REQUIRED = { + "slug", + "name", + "source_urls", + "verified", +} + DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -230,6 +237,7 @@ def validate() -> list[str]: laptops = _load("laptop") monitors = _load("monitor") games = _load("game") + software = _load("software") brand_slugs = {rec["slug"] for _, rec in brands if "slug" in rec} soc_slugs = {rec["slug"] for _, rec in socs if "slug" in rec} @@ -248,6 +256,7 @@ def validate() -> list[str]: ("laptop", laptops), ("monitor", monitors), ("game", games), + ("software", software), ): _check_unique_slugs(category, records, errors) @@ -409,6 +418,13 @@ def validate() -> list[str]: if rec.get("metacritic") is not None: _check_range(fname, "metacritic", rec.get("metacritic"), 0, 100, errors) + for fname, rec in software: + _check_required(fname, rec, SOFTWARE_REQUIRED, errors) + _check_source_urls(fname, rec, errors) + _check_slug(fname, rec.get("slug"), errors) + if rec.get("release_date") is not None: + _check_date(fname, rec["release_date"], errors) + return errors diff --git a/tests/integration/software_fixtures.py b/tests/integration/software_fixtures.py new file mode 100644 index 0000000..46ed5cf --- /dev/null +++ b/tests/integration/software_fixtures.py @@ -0,0 +1,35 @@ +"""Small database fixtures for software endpoint tests.""" + +from __future__ import annotations + +from datetime import date + +from sqlmodel import Session, select + +from app.database import engine +from app.models.software import Software + + +def ensure_software_fixtures() -> None: + """Insert a compact software product when the data checkout lacks it.""" + + with Session(engine) as session: + sw = session.exec( + select(Software).where(Software.slug == "blender-test") + ).first() + if sw is None: + session.add( + Software( + slug="blender-test", + name="Blender (test)", + release_date=date(1998, 1, 2), + developers=["Blender Foundation"], + publishers=["Blender Foundation"], + operating_systems=["Linux", "Windows", "macOS"], + programming_languages=["C", "C++", "Python"], + licenses=["GNU General Public License"], + genres=["3D computer graphics software"], + source_urls=["https://example.com"], + ) + ) + session.commit() diff --git a/tests/integration/test_software.py b/tests/integration/test_software.py new file mode 100644 index 0000000..a0b1f50 --- /dev/null +++ b/tests/integration/test_software.py @@ -0,0 +1,29 @@ +"""Integration tests for software endpoints (unscored category).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.integration.software_fixtures import ensure_software_fixtures + + +def test_list_software(client: TestClient) -> None: + ensure_software_fixtures() + body = client.get("/v1/software").json() + assert body["count"] >= 1 + assert "results" in body + + +def test_software_detail(client: TestClient) -> None: + ensure_software_fixtures() + body = client.get("/v1/software/blender-test").json() + assert body["slug"] == "blender-test" + assert "Python" in body["programming_languages"] + assert "Linux" in body["operating_systems"] + # Software is unscored — no score field. + assert "score" not in body + + +def test_software_not_found(client: TestClient) -> None: + ensure_software_fixtures() + assert client.get("/v1/software/nonexistent-software").status_code == 404