From 92b0f4e077f5830dbb819c8e6ad7b7de8fe20bab Mon Sep 17 00:00:00 2001 From: NixReaper Date: Sat, 5 Sep 2026 19:08:14 -0500 Subject: [PATCH] Add GET /companies browse/discover endpoint Completes the browse/discover set across all four entity types. Companies have no popularity or date field, so unlike movies/series/ people there's no sort_by choice -- always alphabetical by name. Filters are company_type (validated against the enum) and country (a plain ISO 3166-1 code comparison, nothing to validate). build_company_summary refactored to share row-to-summary logic with the new list path, same pattern used for movies/series/people. Co-Authored-By: Claude Sonnet 5 --- app/api/v1/companies.py | 22 ++++- app/repositories/company_repository.py | 30 +++++- app/schemas/company.py | 9 ++ app/services/company_service.py | 57 ++++++++++-- docs/api.md | 7 ++ tests/integration/test_company_browse.py | 114 +++++++++++++++++++++++ 6 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 tests/integration/test_company_browse.py diff --git a/app/api/v1/companies.py b/app/api/v1/companies.py index 8134ede..818224f 100644 --- a/app/api/v1/companies.py +++ b/app/api/v1/companies.py @@ -2,13 +2,13 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import not_found from app.core.database import get_db from app.core.security import enforce_rate_limit -from app.schemas.company import CompanyDetailOut +from app.schemas.company import CompanyDetailOut, CompanyListOut from app.services import company_service router = APIRouter( @@ -16,6 +16,24 @@ router = APIRouter( ) +@router.get("", response_model=CompanyListOut) +async def list_companies( + company_type: str | None = Query( + None, description="production_company, network, studio, or distributor" + ), + country: str | None = Query(None, description="ISO 3166-1 alpha-2 country code, e.g. US"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=50), + session: AsyncSession = Depends(get_db), +) -> CompanyListOut: + try: + return await company_service.list_companies( + session, company_type=company_type, country=country, page=page, page_size=page_size + ) + except company_service.InvalidCompanyFilterError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + + @router.get("/{aurora_id}", response_model=CompanyDetailOut) async def get_company( aurora_id: str, session: AsyncSession = Depends(get_db) diff --git a/app/repositories/company_repository.py b/app/repositories/company_repository.py index c3d43aa..584250f 100644 --- a/app/repositories/company_repository.py +++ b/app/repositories/company_repository.py @@ -4,10 +4,11 @@ from __future__ import annotations import uuid -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models.company import Company, MovieCompany, SeriesCompany +from app.models.enums import CompanyType from app.models.movie import Movie from app.models.tv import Series @@ -37,3 +38,30 @@ async def get_company_series(session: AsyncSession, company_id: uuid.UUID) -> li ) result = await session.execute(stmt) return list(result.scalars().all()) + + +async def list_companies( + session: AsyncSession, + *, + company_type: CompanyType | None, + country_code: str | None, + limit: int, + offset: int, +) -> tuple[list[Company], int]: + """Browse companies -- see movie_repository.list_movies for the + window-function total-count approach this mirrors. Companies have no + popularity/date equivalent to sort by, so this always orders + alphabetically by name (with an id tiebreak for stable pagination).""" + + stmt = select(Company, func.count().over().label("total_count")) + if company_type is not None: + stmt = stmt.where(Company.company_type == company_type) + if country_code is not None: + stmt = stmt.where(Company.country_code == country_code) + + stmt = stmt.order_by(Company.name.asc(), Company.id.asc()).limit(limit).offset(offset) + + rows = (await session.execute(stmt)).all() + if not rows: + return [], 0 + return [row[0] for row in rows], rows[0].total_count diff --git a/app/schemas/company.py b/app/schemas/company.py index c94a23a..37e6a6f 100644 --- a/app/schemas/company.py +++ b/app/schemas/company.py @@ -6,6 +6,7 @@ from datetime import datetime from pydantic import BaseModel +from app.schemas.common import CompanyBriefOut from app.schemas.movie import MovieSummaryOut from app.schemas.tv import SeriesSummaryOut @@ -28,3 +29,11 @@ class CompanyDetailOut(BaseModel): created_at: datetime updated_at: datetime + + +class CompanyListOut(BaseModel): + page: int + page_size: int + total_results: int + total_pages: int + results: list[CompanyBriefOut] = [] diff --git a/app/services/company_service.py b/app/services/company_service.py index 79bc36d..6f49f08 100644 --- a/app/services/company_service.py +++ b/app/services/company_service.py @@ -7,10 +7,11 @@ import uuid from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id, parse_aurora_id -from app.models.enums import ArtworkType +from app.models.company import Company +from app.models.enums import ArtworkType, CompanyType from app.repositories import company_repository, polymorphic from app.schemas.common import CompanyBriefOut -from app.schemas.company import CompanyDetailOut +from app.schemas.company import CompanyDetailOut, CompanyListOut from app.services.common import primary_artwork_path from app.services.movie_service import build_movie_summaries from app.services.tv_service import build_series_summaries @@ -20,6 +21,10 @@ class CompanyNotFoundError(Exception): pass +class InvalidCompanyFilterError(Exception): + pass + + def _resolve_company_id(aid: str) -> uuid.UUID: try: return uuid.UUID(parse_aurora_id(aid, expected_type="company")) @@ -60,13 +65,8 @@ async def get_company_detail(session: AsyncSession, company_aurora_id: str) -> C ) -async def build_company_summary( - session: AsyncSession, company_id: uuid.UUID -) -> CompanyBriefOut | None: - company = await company_repository.get_company_by_id(session, company_id) - if company is None: - return None - logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO) +async def _company_summary_from_row(session: AsyncSession, company: Company) -> CompanyBriefOut: + logos = await polymorphic.get_artwork(session, "company", company.id, ArtworkType.LOGO) return CompanyBriefOut( id=aurora_id("company", str(company.id)), name=company.name, @@ -75,6 +75,15 @@ async def build_company_summary( ) +async def build_company_summary( + session: AsyncSession, company_id: uuid.UUID +) -> CompanyBriefOut | None: + company = await company_repository.get_company_by_id(session, company_id) + if company is None: + return None + return await _company_summary_from_row(session, company) + + async def build_company_summaries( session: AsyncSession, company_ids: list[uuid.UUID] ) -> list[CompanyBriefOut]: @@ -84,3 +93,33 @@ async def build_company_summaries( if summary is not None: out.append(summary) return out + + +async def list_companies( + session: AsyncSession, + *, + company_type: str | None, + country: str | None, + page: int, + page_size: int, +) -> CompanyListOut: + company_type_enum: CompanyType | None = None + if company_type is not None: + try: + company_type_enum = CompanyType(company_type) + except ValueError as exc: + raise InvalidCompanyFilterError(f"unknown company type: {company_type!r}") from exc + + companies, total = await company_repository.list_companies( + session, + company_type=company_type_enum, + country_code=country, + limit=page_size, + offset=(page - 1) * page_size, + ) + results = [await _company_summary_from_row(session, c) for c in companies] + total_pages = (total + page_size - 1) // page_size if total else 0 + + return CompanyListOut( + page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results + ) diff --git a/docs/api.md b/docs/api.md index 0941688..1764622 100644 --- a/docs/api.md +++ b/docs/api.md @@ -166,8 +166,15 @@ pagination and response shape as `/movies` and `/series`. | Method | Path | |---|---| | GET | `/api/v1/collections/{aurora_id}` | +| GET | `/api/v1/companies` | | GET | `/api/v1/companies/{aurora_id}` | +`GET /api/v1/companies` is the browse/discover equivalent for companies: +`company_type` (`production_company` | `network` | `studio` | +`distributor`) and `country` (ISO 3166-1 alpha-2) filters, `page`/ +`page_size` pagination. No `sort_by` — companies have no popularity or +date field to sort by, so results are always alphabetical by name. + Collection detail's `movies` list only includes movies already ingested into Aurora — `POST /api/v1/admin/ingest/collection` links a franchise's member movies purely by external-id lookup, the same never-fabricate-a-stub diff --git a/tests/integration/test_company_browse.py b/tests/integration/test_company_browse.py new file mode 100644 index 0000000..3493add --- /dev/null +++ b/tests/integration/test_company_browse.py @@ -0,0 +1,114 @@ +"""Integration tests for GET /api/v1/companies (browse/discover, section 17 +-- additive beyond the spec's minimum endpoint list): filtering and +pagination against real PostgreSQL, exercised through +company_service.list_companies directly. Mirrors test_movie_browse.py; +companies have no popularity/date field, so unlike movies/series/people +there's no sort_by choice -- always alphabetical by name.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.enums import CompanyType +from app.repositories.provider_repository import get_or_create_provider +from app.repositories.reference_repository import get_or_create_company +from app.services import company_service + +_COMPANIES = [ + ("2001", "Zeta Studios", CompanyType.PRODUCTION_COMPANY, "US"), + ("2002", "Alpha Network", CompanyType.NETWORK, "US"), + ("2003", "Mid Distributors", CompanyType.DISTRIBUTOR, "GB"), + ("2004", "Beta Pictures", CompanyType.PRODUCTION_COMPANY, "GB"), +] + + +async def _seed_companies(db_session: AsyncSession) -> None: + provider = await get_or_create_provider(db_session, "tmdb", "TMDB") + for external_id, name, company_type, country in _COMPANIES: + await get_or_create_company( + db_session, + provider_id=provider.id, + external_id=external_id, + name=name, + company_type=company_type, + country_code=country, + ) + await db_session.commit() + + +@pytest.mark.asyncio +async def test_list_companies_defaults_to_alphabetical_by_name(db_session: AsyncSession): + await _seed_companies(db_session) + + out = await company_service.list_companies( + db_session, company_type=None, country=None, page=1, page_size=20 + ) + + assert out.total_results == 4 + assert [c.name for c in out.results] == [ + "Alpha Network", + "Beta Pictures", + "Mid Distributors", + "Zeta Studios", + ] + + +@pytest.mark.asyncio +async def test_list_companies_filters_by_company_type(db_session: AsyncSession): + await _seed_companies(db_session) + + out = await company_service.list_companies( + db_session, company_type="production_company", country=None, page=1, page_size=20 + ) + + assert [c.name for c in out.results] == ["Beta Pictures", "Zeta Studios"] + + +@pytest.mark.asyncio +async def test_list_companies_filters_by_country(db_session: AsyncSession): + await _seed_companies(db_session) + + out = await company_service.list_companies( + db_session, company_type=None, country="GB", page=1, page_size=20 + ) + + assert [c.name for c in out.results] == ["Beta Pictures", "Mid Distributors"] + + +@pytest.mark.asyncio +async def test_list_companies_paginates(db_session: AsyncSession): + await _seed_companies(db_session) + + page1 = await company_service.list_companies( + db_session, company_type=None, country=None, page=1, page_size=2 + ) + page2 = await company_service.list_companies( + db_session, company_type=None, country=None, page=2, page_size=2 + ) + + assert page1.total_results == 4 + assert page1.total_pages == 2 + assert [c.name for c in page1.results] == ["Alpha Network", "Beta Pictures"] + assert [c.name for c in page2.results] == ["Mid Distributors", "Zeta Studios"] + + +@pytest.mark.asyncio +async def test_list_companies_rejects_unknown_company_type(db_session: AsyncSession): + with pytest.raises(company_service.InvalidCompanyFilterError): + await company_service.list_companies( + db_session, company_type="not_a_real_type", country=None, page=1, page_size=20 + ) + + +@pytest.mark.asyncio +async def test_list_companies_returns_empty_page_when_nothing_matches(db_session: AsyncSession): + await _seed_companies(db_session) + + out = await company_service.list_companies( + db_session, company_type=None, country="ZZ", page=1, page_size=20 + ) + + assert out.results == [] + assert out.total_results == 0 + assert out.total_pages == 0