Add GET /companies browse/discover endpoint
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / unit-tests (push) Canceled after 0s
CI / integration-tests (push) Canceled after 0s
CI / container-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s

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 <[email protected]>
This commit is contained in:
NixReaper
2026-09-05 19:08:14 -05:00
co-authored by Claude Sonnet 5
parent 6aa9f0b840
commit 92b0f4e077
6 changed files with 227 additions and 12 deletions
+20 -2
View File
@@ -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)
+29 -1
View File
@@ -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
+9
View File
@@ -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] = []
+48 -9
View File
@@ -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
)
+7
View File
@@ -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
+114
View File
@@ -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