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
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down
1 change: 1 addition & 0 deletions app/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
monitors,
smartphones,
socs,
software,
)

PREFIX = settings.api_version_prefix
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -25,4 +26,5 @@
"Laptop",
"Monitor",
"Game",
"Software",
]
41 changes: 41 additions & 0 deletions app/models/software.py
Original file line number Diff line number Diff line change
@@ -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)
62 changes: 62 additions & 0 deletions app/routers/software.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -23,4 +24,5 @@
"LaptopRead",
"MonitorRead",
"GameRead",
"SoftwareRead",
]
23 changes: 23 additions & 0 deletions app/schemas/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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),
)
27 changes: 27 additions & 0 deletions app/schemas/software.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions app/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

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


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

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

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


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

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


Expand Down
35 changes: 35 additions & 0 deletions tests/integration/software_fixtures.py
Original file line number Diff line number Diff line change
@@ -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()
29 changes: 29 additions & 0 deletions tests/integration/test_software.py
Original file line number Diff line number Diff line change
@@ -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
Loading