Add movies/series filmography to company detail
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
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
GET /companies/{id} showed only the company's own info. Adds movies
and series lists (any role: production, distribution, network),
ordered by release/first-air date, read straight off the existing
movie_companies/series_companies junction rows -- no separate sync
needed since those only ever exist as a side effect of ingesting the
movie or series itself.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
606d2532c4
commit
6956bf7406
@@ -7,9 +7,33 @@ import uuid
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.company import Company
|
from app.models.company import Company, MovieCompany, SeriesCompany
|
||||||
|
from app.models.movie import Movie
|
||||||
|
from app.models.tv import Series
|
||||||
|
|
||||||
|
|
||||||
async def get_company_by_id(session: AsyncSession, company_id: uuid.UUID) -> Company | None:
|
async def get_company_by_id(session: AsyncSession, company_id: uuid.UUID) -> Company | None:
|
||||||
result = await session.execute(select(Company).where(Company.id == company_id))
|
result = await session.execute(select(Company).where(Company.id == company_id))
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_company_movies(session: AsyncSession, company_id: uuid.UUID) -> list[Movie]:
|
||||||
|
stmt = (
|
||||||
|
select(Movie)
|
||||||
|
.join(MovieCompany, MovieCompany.movie_id == Movie.id)
|
||||||
|
.where(MovieCompany.company_id == company_id)
|
||||||
|
.order_by(Movie.primary_release_date.asc().nulls_last())
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_company_series(session: AsyncSession, company_id: uuid.UUID) -> list[Series]:
|
||||||
|
stmt = (
|
||||||
|
select(Series)
|
||||||
|
.join(SeriesCompany, SeriesCompany.series_id == Series.id)
|
||||||
|
.where(SeriesCompany.company_id == company_id)
|
||||||
|
.order_by(Series.first_air_date.asc().nulls_last())
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.schemas.movie import MovieSummaryOut
|
||||||
|
from app.schemas.tv import SeriesSummaryOut
|
||||||
|
|
||||||
|
|
||||||
class CompanyDetailOut(BaseModel):
|
class CompanyDetailOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -20,5 +23,8 @@ class CompanyDetailOut(BaseModel):
|
|||||||
parent_company_id: str | None = None
|
parent_company_id: str | None = None
|
||||||
external_ids: dict[str, str] = {}
|
external_ids: dict[str, str] = {}
|
||||||
|
|
||||||
|
movies: list[MovieSummaryOut] = []
|
||||||
|
series: list[SeriesSummaryOut] = []
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from app.repositories import company_repository, polymorphic
|
|||||||
from app.schemas.common import CompanyBriefOut
|
from app.schemas.common import CompanyBriefOut
|
||||||
from app.schemas.company import CompanyDetailOut
|
from app.schemas.company import CompanyDetailOut
|
||||||
from app.services.common import primary_artwork_path
|
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
|
||||||
|
|
||||||
|
|
||||||
class CompanyNotFoundError(Exception):
|
class CompanyNotFoundError(Exception):
|
||||||
@@ -33,6 +35,8 @@ async def get_company_detail(session: AsyncSession, company_aurora_id: str) -> C
|
|||||||
|
|
||||||
logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO)
|
logos = await polymorphic.get_artwork(session, "company", company_id, ArtworkType.LOGO)
|
||||||
external_ids = await polymorphic.get_external_ids(session, "company", company_id)
|
external_ids = await polymorphic.get_external_ids(session, "company", company_id)
|
||||||
|
movies = await company_repository.get_company_movies(session, company_id)
|
||||||
|
series = await company_repository.get_company_series(session, company_id)
|
||||||
|
|
||||||
return CompanyDetailOut(
|
return CompanyDetailOut(
|
||||||
id=aurora_id("company", str(company.id)),
|
id=aurora_id("company", str(company.id)),
|
||||||
@@ -49,6 +53,8 @@ async def get_company_detail(session: AsyncSession, company_aurora_id: str) -> C
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
external_ids=external_ids,
|
external_ids=external_ids,
|
||||||
|
movies=await build_movie_summaries(session, [m.id for m in movies]),
|
||||||
|
series=await build_series_summaries(session, [s.id for s in series]),
|
||||||
created_at=company.created_at,
|
created_at=company.created_at,
|
||||||
updated_at=company.updated_at,
|
updated_at=company.updated_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -160,6 +160,13 @@ member movies purely by external-id lookup, the same never-fabricate-a-stub
|
|||||||
rule `recommendations` follows, so a franchise fills in as more of its
|
rule `recommendations` follows, so a franchise fills in as more of its
|
||||||
movies get ingested rather than all at once.
|
movies get ingested rather than all at once.
|
||||||
|
|
||||||
|
Company detail also carries `movies` and `series` lists — everything that
|
||||||
|
company is credited on (any role: production, distribution, network),
|
||||||
|
ordered by release/first-air date. No never-fabricate-a-stub caveat here:
|
||||||
|
a `MovieCompany`/`SeriesCompany` row is only ever created as a side effect
|
||||||
|
of actually ingesting that movie or series, so there's no separate
|
||||||
|
"company's known filmography" TMDb ever needs linking after the fact.
|
||||||
|
|
||||||
## Administrative (section 17: "separately protected")
|
## Administrative (section 17: "separately protected")
|
||||||
|
|
||||||
Require header `X-API-Key: <one of AURORA_ADMIN_API_KEYS>`. Never exposed to
|
Require header `X-API-Key: <one of AURORA_ADMIN_API_KEYS>`. Never exposed to
|
||||||
|
|||||||
+6
-1
@@ -57,7 +57,12 @@ else carries `job`. Series-level "Creator" credits and episode
|
|||||||
A single `companies` table serves production companies, studios, networks,
|
A single `companies` table serves production companies, studios, networks,
|
||||||
and distributors, distinguished by `company_type` (section 8) — a TV network
|
and distributors, distinguished by `company_type` (section 8) — a TV network
|
||||||
and a movie studio have the same shape (name, logo, country, parent
|
and a movie studio have the same shape (name, logo, country, parent
|
||||||
company), so a separate `Network` entity would just duplicate columns.
|
company), so a separate `Network` entity would just duplicate columns. A
|
||||||
|
company's own filmography (`GET /api/v1/companies/{id}`'s `movies`/`series`
|
||||||
|
lists) is read straight off `movie_companies`/`series_companies` — no
|
||||||
|
separate sync step, since those rows only ever exist as a side effect of
|
||||||
|
ingesting the movie or series itself (`app/repositories/company_repository.py`).
|
||||||
|
|
||||||
`collections` (franchises) are first-class, with their own artwork and
|
`collections` (franchises) are first-class, with their own artwork and
|
||||||
`collection_relations` for shared-universe links between collections
|
`collection_relations` for shared-universe links between collections
|
||||||
(section 9). A collection starts as a bare name-only stub the first time
|
(section 9). A collection starts as a bare name-only stub the first time
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Integration test for company filmography (section 17): GET /companies/{id}
|
||||||
|
listing the movies/series a studio/network has produced, the same pattern
|
||||||
|
collections use for their member movies. Exercised through
|
||||||
|
company_service.get_company_detail against real PostgreSQL."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.ids import aurora_id
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.services import company_service
|
||||||
|
from app.services.ingestion.movie_ingestion import ingest_movie
|
||||||
|
from app.services.ingestion.series_ingestion import ingest_series
|
||||||
|
from tests.integration.test_movie_ingestion import FakeTMDbProvider
|
||||||
|
from tests.integration.test_series_ingestion import FakeTMDbSeriesProvider
|
||||||
|
|
||||||
|
_SHARED_STUDIO = {"id": 555, "name": "Shared Studio", "origin_country": "US"}
|
||||||
|
|
||||||
|
|
||||||
|
def _movie_payload(**overrides: Any) -> dict[str, Any]:
|
||||||
|
base = {
|
||||||
|
"id": 9200,
|
||||||
|
"title": "Untitled",
|
||||||
|
"status": "Released",
|
||||||
|
"production_companies": [_SHARED_STUDIO],
|
||||||
|
"release_date": "2020-01-01",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _series_payload(**overrides: Any) -> dict[str, Any]:
|
||||||
|
base = {
|
||||||
|
"id": 9200,
|
||||||
|
"name": "Untitled",
|
||||||
|
"status": "Ended",
|
||||||
|
"production_companies": [_SHARED_STUDIO],
|
||||||
|
"first_air_date": "2020-01-01",
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_company_detail_lists_its_movies_and_series_by_release_date(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
later_movie = _movie_payload(id=9201, title="Studio Movie 1", release_date="2021-01-01")
|
||||||
|
earlier_movie = _movie_payload(id=9202, title="Studio Movie 2", release_date="2019-01-01")
|
||||||
|
show = _series_payload(id=9203, name="Studio Show", first_air_date="2020-06-01")
|
||||||
|
|
||||||
|
await ingest_movie(db_session, FakeTMDbProvider(later_movie), "9201")
|
||||||
|
await ingest_movie(db_session, FakeTMDbProvider(earlier_movie), "9202")
|
||||||
|
await ingest_series(db_session, FakeTMDbSeriesProvider(show, {}), "9203")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
company = (
|
||||||
|
await db_session.execute(select(Company).where(Company.name == "Shared Studio"))
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
detail = await company_service.get_company_detail(
|
||||||
|
db_session, aurora_id("company", str(company.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordered by release date ascending, not ingestion order.
|
||||||
|
assert [m.title for m in detail.movies] == ["Studio Movie 2", "Studio Movie 1"]
|
||||||
|
assert [s.title for s in detail.series] == ["Studio Show"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_company_detail_series_list_is_empty_for_a_movie_only_studio(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
movie = _movie_payload(
|
||||||
|
id=9204, title="Lone Movie", production_companies=[{"id": 556, "name": "Solo Studio"}]
|
||||||
|
)
|
||||||
|
await ingest_movie(db_session, FakeTMDbProvider(movie), "9204")
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
company = (
|
||||||
|
await db_session.execute(select(Company).where(Company.name == "Solo Studio"))
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
detail = await company_service.get_company_detail(
|
||||||
|
db_session, aurora_id("company", str(company.id))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(detail.movies) == 1
|
||||||
|
assert detail.series == []
|
||||||
Reference in New Issue
Block a user