Compare commits

...
2 Commits
Author SHA1 Message Date
NixReaperandClaude Sonnet 5 0be052bc1f Pin pytest-asyncio below 1.0
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
1.x changed session-scoped async fixture/event-loop behavior across test
modules, breaking the single-shared-engine assumption tests/conftest.py's
engine fixture and the asyncio_default_*_loop_scope settings depend on.
Surfaced as sporadic "relation ... does not exist" failures in specific
integration test files when run alongside the full suite, despite those
same files passing cleanly when run standalone -- confirmed via a real
Postgres run on production hardware while verifying the watch-provider
dedup fix.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 04:46:08 -05:00
NixReaperandClaude Sonnet 5 0ab31a314c Add movie/series ratings and watchlist
Builds on the accounts foundation: PUT/DELETE a rating (0.5-10.0) or a
watchlist entry on any movie or series, all scoped to the bearer token's
own user, plus GET /account/ratings|watchlist/{movies,series} to list
them back. Both are polymorphic entity_type/entity_id tables, same shape
as watch_provider_offerings and trending_entries, so movie and series
share one repository/router each rather than duplicating per type.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-06 10:47:41 -05:00
21 changed files with 1050 additions and 7 deletions
+62
View File
@@ -0,0 +1,62 @@
"""The authenticated user's own ratings and watchlist (section 17,
additive) -- everything here requires a bearer token and is scoped to the
caller, unlike every other read endpoint in Aurora Core."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.database import get_db
from app.core.security import enforce_rate_limit
from app.models.user import User
from app.schemas.movie import MovieListOut, RatedMovieListOut
from app.schemas.tv import RatedSeriesListOut, SeriesListOut
from app.services import movie_service, tv_service
router = APIRouter(prefix="/account", tags=["account"], dependencies=[Depends(enforce_rate_limit)])
@router.get("/ratings/movies", response_model=RatedMovieListOut)
async def list_rated_movies(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> RatedMovieListOut:
return await movie_service.list_rated_movies(session, user_id=user.id, page=page, page_size=page_size)
@router.get("/ratings/series", response_model=RatedSeriesListOut)
async def list_rated_series(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> RatedSeriesListOut:
return await tv_service.list_rated_series(session, user_id=user.id, page=page, page_size=page_size)
@router.get("/watchlist/movies", response_model=MovieListOut)
async def list_watchlist_movies(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> MovieListOut:
return await movie_service.list_watchlist_movies(
session, user_id=user.id, page=page, page_size=page_size
)
@router.get("/watchlist/series", response_model=SeriesListOut)
async def list_watchlist_series(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=50),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> SeriesListOut:
return await tv_service.list_watchlist_series(
session, user_id=user.id, page=page, page_size=page_size
)
+52 -1
View File
@@ -8,10 +8,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from redis.asyncio import Redis from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found from app.api.deps import cached_response, get_current_user, not_found
from app.core.database import get_db from app.core.database import get_db
from app.core.redis import get_redis from app.core.redis import get_redis
from app.core.security import enforce_rate_limit from app.core.security import enforce_rate_limit
from app.models.user import User
from app.schemas.account import RatingIn, RatingOut
from app.schemas.movie import ( from app.schemas.movie import (
MovieCreditsOut, MovieCreditsOut,
MovieDetailOut, MovieDetailOut,
@@ -155,3 +157,52 @@ async def get_movie_watch_providers(
return await movie_service.get_movie_watch_providers(session, aurora_id) return await movie_service.get_movie_watch_providers(session, aurora_id)
except movie_service.MovieNotFoundError as exc: except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc raise not_found("movie", aurora_id) from exc
@router.put("/{aurora_id}/rating", response_model=RatingOut)
async def rate_movie(
aurora_id: str,
body: RatingIn,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> RatingOut:
try:
return await movie_service.rate_movie(session, aurora_id, user_id=user.id, value=body.value)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.delete("/{aurora_id}/rating", status_code=status.HTTP_204_NO_CONTENT)
async def unrate_movie(
aurora_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> None:
try:
await movie_service.unrate_movie(session, aurora_id, user_id=user.id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.post("/{aurora_id}/watchlist", status_code=status.HTTP_204_NO_CONTENT)
async def add_movie_to_watchlist(
aurora_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> None:
try:
await movie_service.add_movie_to_watchlist(session, aurora_id, user_id=user.id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
@router.delete("/{aurora_id}/watchlist", status_code=status.HTTP_204_NO_CONTENT)
async def remove_movie_from_watchlist(
aurora_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> None:
try:
await movie_service.remove_movie_from_watchlist(session, aurora_id, user_id=user.id)
except movie_service.MovieNotFoundError as exc:
raise not_found("movie", aurora_id) from exc
+52 -1
View File
@@ -8,10 +8,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from redis.asyncio import Redis from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import cached_response, not_found from app.api.deps import cached_response, get_current_user, not_found
from app.core.database import get_db from app.core.database import get_db
from app.core.redis import get_redis from app.core.redis import get_redis
from app.core.security import enforce_rate_limit from app.core.security import enforce_rate_limit
from app.models.user import User
from app.schemas.account import RatingIn, RatingOut
from app.schemas.tv import ( from app.schemas.tv import (
SeriesCreditsOut, SeriesCreditsOut,
SeriesDetailOut, SeriesDetailOut,
@@ -135,3 +137,52 @@ async def get_series_watch_providers(
return await tv_service.get_series_watch_providers(session, aurora_id) return await tv_service.get_series_watch_providers(session, aurora_id)
except tv_service.SeriesNotFoundError as exc: except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc raise not_found("series", aurora_id) from exc
@router.put("/{aurora_id}/rating", response_model=RatingOut)
async def rate_series(
aurora_id: str,
body: RatingIn,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> RatingOut:
try:
return await tv_service.rate_series(session, aurora_id, user_id=user.id, value=body.value)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc
@router.delete("/{aurora_id}/rating", status_code=status.HTTP_204_NO_CONTENT)
async def unrate_series(
aurora_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> None:
try:
await tv_service.unrate_series(session, aurora_id, user_id=user.id)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc
@router.post("/{aurora_id}/watchlist", status_code=status.HTTP_204_NO_CONTENT)
async def add_series_to_watchlist(
aurora_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> None:
try:
await tv_service.add_series_to_watchlist(session, aurora_id, user_id=user.id)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc
@router.delete("/{aurora_id}/watchlist", status_code=status.HTTP_204_NO_CONTENT)
async def remove_series_from_watchlist(
aurora_id: str,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> None:
try:
await tv_service.remove_series_from_watchlist(session, aurora_id, user_id=user.id)
except tv_service.SeriesNotFoundError as exc:
raise not_found("series", aurora_id) from exc
+2
View File
@@ -52,6 +52,8 @@ ENTITY_PREFIXES: dict[str, str] = {
"release": "rel", "release": "rel",
"watch_provider": "watch_provider", "watch_provider": "watch_provider",
"user": "user", "user": "user",
"rating": "rating",
"watchlist_entry": "watchlist",
} }
+2
View File
@@ -22,6 +22,7 @@ from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from app.api.v1 import ( from app.api.v1 import (
account,
admin, admin,
auth, auth,
collections, collections,
@@ -183,6 +184,7 @@ API_V1_PREFIX = "/api/v1"
app.include_router(health.router, prefix=API_V1_PREFIX) app.include_router(health.router, prefix=API_V1_PREFIX)
app.include_router(search.router, prefix=API_V1_PREFIX) app.include_router(search.router, prefix=API_V1_PREFIX)
app.include_router(auth.router, prefix=API_V1_PREFIX) app.include_router(auth.router, prefix=API_V1_PREFIX)
app.include_router(account.router, prefix=API_V1_PREFIX)
app.include_router(movies.router, prefix=API_V1_PREFIX) app.include_router(movies.router, prefix=API_V1_PREFIX)
app.include_router(series.router, prefix=API_V1_PREFIX) app.include_router(series.router, prefix=API_V1_PREFIX)
app.include_router(seasons.router, prefix=API_V1_PREFIX) app.include_router(seasons.router, prefix=API_V1_PREFIX)
+4
View File
@@ -24,6 +24,7 @@ from app.models.movie import (
) )
from app.models.person import Person from app.models.person import Person
from app.models.provider import IngestionJob, Provider, ProviderRecord from app.models.provider import IngestionJob, Provider, ProviderRecord
from app.models.rating import Rating
from app.models.trending import TrendingEntry from app.models.trending import TrendingEntry
from app.models.tv import ( from app.models.tv import (
Episode, Episode,
@@ -36,6 +37,7 @@ from app.models.tv import (
from app.models.user import User from app.models.user import User
from app.models.video import Video from app.models.video import Video
from app.models.watch_provider import WatchProvider, WatchProviderOffering from app.models.watch_provider import WatchProvider, WatchProviderOffering
from app.models.watchlist import WatchlistEntry
__all__ = [ __all__ = [
"Base", "Base",
@@ -79,4 +81,6 @@ __all__ = [
"WatchProviderOffering", "WatchProviderOffering",
"TrendingEntry", "TrendingEntry",
"User", "User",
"Rating",
"WatchlistEntry",
] ]
+34
View File
@@ -0,0 +1,34 @@
"""User ratings -- section 17, additive (accounts/ratings/watchlist).
Polymorphic across movie/series, same shape as WatchProviderOffering and
TrendingEntry: `entity_type` + `entity_id` rather than a separate ratings
table per entity type."""
from __future__ import annotations
import uuid
from sqlalchemy import Float, ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class Rating(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "ratings"
__table_args__ = (
UniqueConstraint("user_id", "entity_type", "entity_id"),
Index("ix_ratings_entity", "entity_type", "entity_id"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
# 0.5-10.0 in 0.5 steps, same scale as TMDb's own user ratings -- see
# app/schemas/account.py:RatingIn for the enforced bounds.
value: Mapped[float] = mapped_column(Float, nullable=False)
__all__ = ["Rating"]
+30
View File
@@ -0,0 +1,30 @@
"""User watchlists -- section 17, additive (accounts/ratings/watchlist).
Polymorphic across movie/series, same shape as Rating -- no value beyond
membership, so no extra columns beyond the timestamps."""
from __future__ import annotations
import uuid
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class WatchlistEntry(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "watchlist_entries"
__table_args__ = (
UniqueConstraint("user_id", "entity_type", "entity_id"),
Index("ix_watchlist_entries_user", "user_id", "entity_type"),
)
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(nullable=False)
__all__ = ["WatchlistEntry"]
+66
View File
@@ -0,0 +1,66 @@
"""Data access for Rating."""
from __future__ import annotations
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.rating import Rating
async def get_rating(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID
) -> Rating | None:
stmt = select(Rating).where(
Rating.user_id == user_id, Rating.entity_type == entity_type, Rating.entity_id == entity_id
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def upsert_rating(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID, value: float
) -> Rating:
rating = await get_rating(session, user_id=user_id, entity_type=entity_type, entity_id=entity_id)
if rating is not None:
rating.value = value
await session.flush()
return rating
rating = Rating(user_id=user_id, entity_type=entity_type, entity_id=entity_id, value=value)
session.add(rating)
await session.flush()
return rating
async def delete_rating(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID
) -> None:
await session.execute(
delete(Rating).where(
Rating.user_id == user_id, Rating.entity_type == entity_type, Rating.entity_id == entity_id
)
)
async def list_rated(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, limit: int, offset: int
) -> tuple[list[Rating], int]:
"""Rated entities for a user, newest-rated first, paginated."""
total = await session.scalar(
select(func.count())
.select_from(Rating)
.where(Rating.user_id == user_id, Rating.entity_type == entity_type)
)
stmt = (
select(Rating)
.where(Rating.user_id == user_id, Rating.entity_type == entity_type)
.order_by(Rating.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return list(result.scalars().all()), total or 0
+71
View File
@@ -0,0 +1,71 @@
"""Data access for WatchlistEntry."""
from __future__ import annotations
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.watchlist import WatchlistEntry
async def get_entry(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID
) -> WatchlistEntry | None:
stmt = select(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entity_type == entity_type,
WatchlistEntry.entity_id == entity_id,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def add_entry(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID
) -> WatchlistEntry:
"""Idempotent: adding an already-watchlisted entity just returns the
existing row rather than raising on the unique constraint."""
entry = await get_entry(session, user_id=user_id, entity_type=entity_type, entity_id=entity_id)
if entry is not None:
return entry
entry = WatchlistEntry(user_id=user_id, entity_type=entity_type, entity_id=entity_id)
session.add(entry)
await session.flush()
return entry
async def remove_entry(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, entity_id: uuid.UUID
) -> None:
await session.execute(
delete(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entity_type == entity_type,
WatchlistEntry.entity_id == entity_id,
)
)
async def list_entries(
session: AsyncSession, *, user_id: uuid.UUID, entity_type: str, limit: int, offset: int
) -> tuple[list[WatchlistEntry], int]:
"""Watchlisted entities for a user, newest-added first, paginated."""
total = await session.scalar(
select(func.count())
.select_from(WatchlistEntry)
.where(WatchlistEntry.user_id == user_id, WatchlistEntry.entity_type == entity_type)
)
stmt = (
select(WatchlistEntry)
.where(WatchlistEntry.user_id == user_id, WatchlistEntry.entity_type == entity_type)
.order_by(WatchlistEntry.created_at.desc())
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return list(result.scalars().all()), total or 0
+21
View File
@@ -0,0 +1,21 @@
"""Request/response schemas for the authenticated user's own ratings and
watchlist (section 17, additive)."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class RatingIn(BaseModel):
# 0.5-10.0 in practice (TMDb's own scale), enforced as a continuous
# range rather than an enum of 0.5 steps -- not worth the extra
# validation for a value nothing downstream treats as discrete.
value: float = Field(ge=0.5, le=10.0)
class RatingOut(BaseModel):
value: float
created_at: datetime
updated_at: datetime
+12
View File
@@ -106,3 +106,15 @@ class MovieListOut(BaseModel):
total_results: int total_results: int
total_pages: int total_pages: int
results: list[MovieSummaryOut] = [] results: list[MovieSummaryOut] = []
class RatedMovieOut(MovieSummaryOut):
rating: float
class RatedMovieListOut(BaseModel):
page: int
page_size: int
total_results: int
total_pages: int
results: list[RatedMovieOut] = []
+12
View File
@@ -115,6 +115,18 @@ class SeriesListOut(BaseModel):
results: list[SeriesSummaryOut] = [] results: list[SeriesSummaryOut] = []
class RatedSeriesOut(SeriesSummaryOut):
rating: float
class RatedSeriesListOut(BaseModel):
page: int
page_size: int
total_results: int
total_pages: int
results: list[RatedSeriesOut] = []
class EpisodeBriefOut(BaseModel): class EpisodeBriefOut(BaseModel):
id: str id: str
episode_number: int episode_number: int
+83 -1
View File
@@ -9,7 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.ids import aurora_id, parse_aurora_id from app.core.ids import aurora_id, parse_aurora_id
from app.models.enums import ArtworkType, MovieStatus from app.models.enums import ArtworkType, MovieStatus
from app.models.movie import Movie from app.models.movie import Movie
from app.repositories import movie_repository, polymorphic, trending_repository from app.repositories import (
movie_repository,
polymorphic,
rating_repository,
trending_repository,
watchlist_repository,
)
from app.schemas.account import RatingOut
from app.schemas.common import CollectionBriefOut, CompanyBriefOut, GenreOut, KeywordOut from app.schemas.common import CollectionBriefOut, CompanyBriefOut, GenreOut, KeywordOut
from app.schemas.movie import ( from app.schemas.movie import (
MovieCreditsOut, MovieCreditsOut,
@@ -20,6 +27,8 @@ from app.schemas.movie import (
MovieSummaryOut, MovieSummaryOut,
MovieVideosOut, MovieVideosOut,
MovieWatchProvidersOut, MovieWatchProvidersOut,
RatedMovieListOut,
RatedMovieOut,
) )
from app.services.common import ( from app.services.common import (
build_watch_providers_out, build_watch_providers_out,
@@ -209,6 +218,79 @@ async def build_movie_summaries(
return out return out
async def rate_movie(
session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID, value: float
) -> RatingOut:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
rating = await rating_repository.upsert_rating(
session, user_id=user_id, entity_type="movie", entity_id=movie_id, value=value
)
return RatingOut(value=rating.value, created_at=rating.created_at, updated_at=rating.updated_at)
async def unrate_movie(session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID) -> None:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
await rating_repository.delete_rating(session, user_id=user_id, entity_type="movie", entity_id=movie_id)
async def list_rated_movies(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> RatedMovieListOut:
ratings, total = await rating_repository.list_rated(
session, user_id=user_id, entity_type="movie", limit=page_size, offset=(page - 1) * page_size
)
results: list[RatedMovieOut] = []
for rating in ratings:
summary = await build_movie_summary(session, rating.entity_id)
if summary is not None:
results.append(RatedMovieOut(**summary.model_dump(), rating=rating.value))
total_pages = (total + page_size - 1) // page_size if total else 0
return RatedMovieListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
async def add_movie_to_watchlist(
session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID
) -> None:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
await watchlist_repository.add_entry(session, user_id=user_id, entity_type="movie", entity_id=movie_id)
async def remove_movie_from_watchlist(
session: AsyncSession, movie_aurora_id: str, *, user_id: uuid.UUID
) -> None:
movie_id = await _resolve_movie_id(movie_aurora_id)
if await movie_repository.get_movie_by_id(session, movie_id) is None:
raise MovieNotFoundError(movie_aurora_id)
await watchlist_repository.remove_entry(session, user_id=user_id, entity_type="movie", entity_id=movie_id)
async def list_watchlist_movies(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> MovieListOut:
entries, total = await watchlist_repository.list_entries(
session, user_id=user_id, entity_type="movie", limit=page_size, offset=(page - 1) * page_size
)
results = await build_movie_summaries(session, [entry.entity_id for entry in entries])
total_pages = (total + page_size - 1) // page_size if total else 0
return MovieListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
async def list_now_playing( async def list_now_playing(
session: AsyncSession, *, region: str, page: int, page_size: int session: AsyncSession, *, region: str, page: int, page_size: int
) -> MovieListOut: ) -> MovieListOut:
+85 -1
View File
@@ -10,11 +10,20 @@ from app.core.ids import aurora_id, parse_aurora_id
from app.models.credit import Credit from app.models.credit import Credit
from app.models.enums import ArtworkType, CompanyType, SeriesStatus from app.models.enums import ArtworkType, CompanyType, SeriesStatus
from app.models.tv import Series from app.models.tv import Series
from app.repositories import polymorphic, trending_repository, tv_repository from app.repositories import (
polymorphic,
rating_repository,
trending_repository,
tv_repository,
watchlist_repository,
)
from app.schemas.account import RatingOut
from app.schemas.common import CastCreditOut, CompanyBriefOut, CrewCreditOut, GenreOut, KeywordOut from app.schemas.common import CastCreditOut, CompanyBriefOut, CrewCreditOut, GenreOut, KeywordOut
from app.schemas.tv import ( from app.schemas.tv import (
EpisodeBriefOut, EpisodeBriefOut,
EpisodeDetailOut, EpisodeDetailOut,
RatedSeriesListOut,
RatedSeriesOut,
SeasonBriefOut, SeasonBriefOut,
SeasonDetailOut, SeasonDetailOut,
SeriesCreditsOut, SeriesCreditsOut,
@@ -316,6 +325,81 @@ async def build_series_summaries(
return out return out
async def rate_series(
session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID, value: float
) -> RatingOut:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
rating = await rating_repository.upsert_rating(
session, user_id=user_id, entity_type="series", entity_id=series_id, value=value
)
return RatingOut(value=rating.value, created_at=rating.created_at, updated_at=rating.updated_at)
async def unrate_series(session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID) -> None:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
await rating_repository.delete_rating(session, user_id=user_id, entity_type="series", entity_id=series_id)
async def list_rated_series(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> RatedSeriesListOut:
ratings, total = await rating_repository.list_rated(
session, user_id=user_id, entity_type="series", limit=page_size, offset=(page - 1) * page_size
)
results: list[RatedSeriesOut] = []
for rating in ratings:
summary = await build_series_summary(session, rating.entity_id)
if summary is not None:
results.append(RatedSeriesOut(**summary.model_dump(), rating=rating.value))
total_pages = (total + page_size - 1) // page_size if total else 0
return RatedSeriesListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
async def add_series_to_watchlist(
session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID
) -> None:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
await watchlist_repository.add_entry(session, user_id=user_id, entity_type="series", entity_id=series_id)
async def remove_series_from_watchlist(
session: AsyncSession, series_aurora_id: str, *, user_id: uuid.UUID
) -> None:
series_id = _resolve_id(series_aurora_id, "series", SeriesNotFoundError)
if await tv_repository.get_series_by_id(session, series_id) is None:
raise SeriesNotFoundError(series_aurora_id)
await watchlist_repository.remove_entry(
session, user_id=user_id, entity_type="series", entity_id=series_id
)
async def list_watchlist_series(
session: AsyncSession, *, user_id: uuid.UUID, page: int, page_size: int
) -> SeriesListOut:
entries, total = await watchlist_repository.list_entries(
session, user_id=user_id, entity_type="series", limit=page_size, offset=(page - 1) * page_size
)
results = await build_series_summaries(session, [entry.entity_id for entry in entries])
total_pages = (total + page_size - 1) // page_size if total else 0
return SeriesListOut(
page=page, page_size=page_size, total_results=total, total_pages=total_pages, results=results
)
_TRENDING_WINDOWS = ("day", "week") _TRENDING_WINDOWS = ("day", "week")
+20 -2
View File
@@ -30,8 +30,26 @@ a consistent envelope:
Access tokens are JWTs signed with `AURORA_SECRET_KEY`, valid for 24 hours, Access tokens are JWTs signed with `AURORA_SECRET_KEY`, valid for 24 hours,
with no refresh-token flow yet — a client re-authenticates (logs in again) with no refresh-token flow yet — a client re-authenticates (logs in again)
once a token expires. This is the foundation ratings and a watchlist will once a token expires.
build on next; those aren't live yet.
## Ratings and watchlist
All of these require `Authorization: Bearer <access_token>` and are scoped
to the caller — there is no way to read or modify another user's ratings
or watchlist. `{aurora_id}` is a movie or series id.
| Method | Path | Body | Notes |
|---|---|---|---|
| PUT | `/api/v1/movies/{aurora_id}/rating` | `{"value"}` | Rates a movie, 0.5-10.0. Upsert — rating the same movie again replaces the previous value rather than erroring. Returns `{"value", "created_at", "updated_at"}`. 404 if the movie doesn't exist, 422 if `value` is out of range. |
| DELETE | `/api/v1/movies/{aurora_id}/rating` | — | Removes the caller's rating, if any. 204. |
| PUT / DELETE | `/api/v1/series/{aurora_id}/rating` | `{"value"}` | Same as above, for a series. |
| POST | `/api/v1/movies/{aurora_id}/watchlist` | — | Adds a movie to the caller's watchlist. Idempotent — adding an already-watchlisted movie is a no-op, not a 409. 204. |
| DELETE | `/api/v1/movies/{aurora_id}/watchlist` | — | Removes it, if present. 204. |
| POST / DELETE | `/api/v1/series/{aurora_id}/watchlist` | — | Same as above, for a series. |
| GET | `/api/v1/account/ratings/movies` | — | Paginated list of the caller's rated movies. Each result is a normal movie summary plus `"rating"`. |
| GET | `/api/v1/account/ratings/series` | — | Same, for series. |
| GET | `/api/v1/account/watchlist/movies` | — | Paginated list of the caller's watchlisted movies (movie summaries, no rating field). |
| GET | `/api/v1/account/watchlist/series` | — | Same, for series. |
## Search ## Search
@@ -0,0 +1,76 @@
"""ratings and watchlist
Revision ID: 0005_ratings_and_watchlist
Revises: 0004_users
Create Date: 2026-09-06
Adds the ratings and watchlist_entries tables (section 17, additive) --
both polymorphic across movie/series, same entity_type/entity_id shape
as watch_provider_offerings and trending_entries.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0005_ratings_and_watchlist"
down_revision: str | None = "0004_users"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"ratings",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.Uuid(), nullable=False),
sa.Column("value", sa.Float(), nullable=False),
sa.UniqueConstraint("user_id", "entity_type", "entity_id", name="uq_ratings_user_id"),
)
op.create_index("ix_ratings_entity", "ratings", ["entity_type", "entity_id"])
op.create_table(
"watchlist_entries",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("entity_type", sa.String(30), nullable=False),
sa.Column("entity_id", sa.Uuid(), nullable=False),
sa.UniqueConstraint("user_id", "entity_type", "entity_id", name="uq_watchlist_entries_user_id"),
)
op.create_index("ix_watchlist_entries_user", "watchlist_entries", ["user_id", "entity_type"])
def downgrade() -> None:
op.drop_index("ix_watchlist_entries_user", table_name="watchlist_entries")
op.drop_table("watchlist_entries")
op.drop_index("ix_ratings_entity", table_name="ratings")
op.drop_table("ratings")
+7 -1
View File
@@ -33,7 +33,13 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8.3.0", "pytest>=8.3.0",
"pytest-asyncio>=0.24.0", # <1.0: 1.x changed session-scoped async fixture/event-loop behavior
# across test modules, breaking the single-shared-engine assumption
# tests/conftest.py:engine and the asyncio_default_*_loop_scope
# settings below depend on (surfaced as sporadic "relation ... does
# not exist" failures in specific test files when run alongside others,
# despite passing standalone).
"pytest-asyncio>=0.24.0,<1.0.0",
"pytest-cov>=5.0.0", "pytest-cov>=5.0.0",
"ruff>=0.6.9", "ruff>=0.6.9",
"mypy>=1.11.2", "mypy>=1.11.2",
+12
View File
@@ -14,6 +14,7 @@ from app.models.genre import Genre, MovieGenre
from app.models.movie import Movie from app.models.movie import Movie
from app.models.person import Person from app.models.person import Person
from app.models.provider import Provider from app.models.provider import Provider
from app.models.tv import Series
from app.repositories.provider_repository import get_or_create_provider from app.repositories.provider_repository import get_or_create_provider
@@ -81,3 +82,14 @@ async def make_movie(
def movie_aurora_id(movie: Movie) -> str: def movie_aurora_id(movie: Movie) -> str:
return aurora_id("movie", str(movie.id)) return aurora_id("movie", str(movie.id))
async def make_series(session: AsyncSession, *, title: str = "The Wire") -> Series:
series = Series(title=title, sort_title=title, overview="Baltimore, drugs, and wiretaps.")
session.add(series)
await session.flush()
return series
def series_aurora_id(series: Series) -> str:
return aurora_id("series", str(series.id))
+191
View File
@@ -0,0 +1,191 @@
"""Integration tests for user ratings (section 17, additive) -- PUT/DELETE
a rating on a movie or series, and list the caller's own rated movies/
series. Exercised at the HTTP layer, same convention as test_auth_api.py,
since the auth header handling is part of the real contract."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from tests.integration.factories import (
make_movie,
make_provider,
make_series,
movie_aurora_id,
series_aurora_id,
)
async def _register(client: AsyncClient, email: str) -> str:
response = await client.post(
"/api/v1/auth/register", json={"email": email, "password": "correct-horse-battery"}
)
assert response.status_code == 201
return response.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_rate_movie_creates_a_rating(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
token = await _register(client, "[email protected]")
response = await client.put(
f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 9.0}, headers=_auth(token)
)
assert response.status_code == 200
body = response.json()
assert body["value"] == 9.0
assert "created_at" in body and "updated_at" in body
@pytest.mark.asyncio
async def test_rate_movie_twice_updates_rather_than_duplicates(
client: AsyncClient, db_session: AsyncSession
):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
token = await _register(client, "[email protected]")
await client.put(f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 6.0}, headers=_auth(token))
response = await client.put(
f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 8.5}, headers=_auth(token)
)
assert response.status_code == 200
assert response.json()["value"] == 8.5
listing = await client.get("/api/v1/account/ratings/movies", headers=_auth(token))
assert listing.json()["total_results"] == 1
assert listing.json()["results"][0]["rating"] == 8.5
@pytest.mark.asyncio
async def test_rate_movie_requires_auth(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
response = await client.put(f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 5.0})
assert response.status_code == 401
@pytest.mark.asyncio
async def test_rate_movie_404_for_unknown_movie(client: AsyncClient, db_session: AsyncSession):
from app.core.ids import aurora_id, new_uuid7
token = await _register(client, "[email protected]")
unknown_id = aurora_id("movie", new_uuid7())
response = await client.put(
f"/api/v1/movies/{unknown_id}/rating", json={"value": 5.0}, headers=_auth(token)
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_rate_movie_rejects_out_of_range_value(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
token = await _register(client, "[email protected]")
too_high = await client.put(
f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 11.0}, headers=_auth(token)
)
too_low = await client.put(
f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 0.0}, headers=_auth(token)
)
assert too_high.status_code == 422
assert too_low.status_code == 422
@pytest.mark.asyncio
async def test_unrate_movie_removes_it_from_the_list(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
token = await _register(client, "[email protected]")
await client.put(f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 7.0}, headers=_auth(token))
delete_response = await client.delete(
f"/api/v1/movies/{movie_aurora_id(movie)}/rating", headers=_auth(token)
)
assert delete_response.status_code == 204
listing = await client.get("/api/v1/account/ratings/movies", headers=_auth(token))
assert listing.json()["total_results"] == 0
@pytest.mark.asyncio
async def test_list_rated_movies_is_scoped_to_the_caller(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
owner_token = await _register(client, "[email protected]")
other_token = await _register(client, "[email protected]")
await client.put(
f"/api/v1/movies/{movie_aurora_id(movie)}/rating", json={"value": 10.0}, headers=_auth(owner_token)
)
owner_listing = await client.get("/api/v1/account/ratings/movies", headers=_auth(owner_token))
other_listing = await client.get("/api/v1/account/ratings/movies", headers=_auth(other_token))
assert owner_listing.json()["total_results"] == 1
assert other_listing.json()["total_results"] == 0
@pytest.mark.asyncio
async def test_list_rated_movies_requires_auth(client: AsyncClient):
response = await client.get("/api/v1/account/ratings/movies")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_rate_series_creates_a_rating(client: AsyncClient, db_session: AsyncSession):
series = await make_series(db_session)
await db_session.commit()
token = await _register(client, "[email protected]")
response = await client.put(
f"/api/v1/series/{series_aurora_id(series)}/rating", json={"value": 4.5}, headers=_auth(token)
)
assert response.status_code == 200
assert response.json()["value"] == 4.5
listing = await client.get("/api/v1/account/ratings/series", headers=_auth(token))
assert listing.json()["total_results"] == 1
assert listing.json()["results"][0]["rating"] == 4.5
@pytest.mark.asyncio
async def test_rate_series_404_for_unknown_series(client: AsyncClient, db_session: AsyncSession):
from app.core.ids import aurora_id, new_uuid7
token = await _register(client, "[email protected]")
unknown_id = aurora_id("series", new_uuid7())
response = await client.put(
f"/api/v1/series/{unknown_id}/rating", json={"value": 5.0}, headers=_auth(token)
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_unrate_series_removes_it_from_the_list(client: AsyncClient, db_session: AsyncSession):
series = await make_series(db_session)
await db_session.commit()
token = await _register(client, "[email protected]")
await client.put(
f"/api/v1/series/{series_aurora_id(series)}/rating", json={"value": 6.0}, headers=_auth(token)
)
delete_response = await client.delete(
f"/api/v1/series/{series_aurora_id(series)}/rating", headers=_auth(token)
)
assert delete_response.status_code == 204
listing = await client.get("/api/v1/account/ratings/series", headers=_auth(token))
assert listing.json()["total_results"] == 0
+156
View File
@@ -0,0 +1,156 @@
"""Integration tests for the user watchlist (section 17, additive) --
POST/DELETE a movie or series on the caller's watchlist, and list it back.
Same HTTP-layer convention as test_ratings_api.py."""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from tests.integration.factories import (
make_movie,
make_provider,
make_series,
movie_aurora_id,
series_aurora_id,
)
async def _register(client: AsyncClient, email: str) -> str:
response = await client.post(
"/api/v1/auth/register", json={"email": email, "password": "correct-horse-battery"}
)
assert response.status_code == 201
return response.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_add_movie_to_watchlist_then_list(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
token = await _register(client, "[email protected]")
add_response = await client.post(
f"/api/v1/movies/{movie_aurora_id(movie)}/watchlist", headers=_auth(token)
)
assert add_response.status_code == 204
listing = await client.get("/api/v1/account/watchlist/movies", headers=_auth(token))
assert listing.json()["total_results"] == 1
assert listing.json()["results"][0]["id"] == movie_aurora_id(movie)
@pytest.mark.asyncio
async def test_adding_the_same_movie_twice_is_idempotent(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
token = await _register(client, "[email protected]")
for _ in range(2):
response = await client.post(
f"/api/v1/movies/{movie_aurora_id(movie)}/watchlist", headers=_auth(token)
)
assert response.status_code == 204
listing = await client.get("/api/v1/account/watchlist/movies", headers=_auth(token))
assert listing.json()["total_results"] == 1
@pytest.mark.asyncio
async def test_remove_movie_from_watchlist(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
token = await _register(client, "[email protected]")
await client.post(f"/api/v1/movies/{movie_aurora_id(movie)}/watchlist", headers=_auth(token))
remove_response = await client.delete(
f"/api/v1/movies/{movie_aurora_id(movie)}/watchlist", headers=_auth(token)
)
assert remove_response.status_code == 204
listing = await client.get("/api/v1/account/watchlist/movies", headers=_auth(token))
assert listing.json()["total_results"] == 0
@pytest.mark.asyncio
async def test_watchlist_requires_auth(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
response = await client.post(f"/api/v1/movies/{movie_aurora_id(movie)}/watchlist")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_add_to_watchlist_404_for_unknown_movie(client: AsyncClient, db_session: AsyncSession):
from app.core.ids import aurora_id, new_uuid7
token = await _register(client, "[email protected]")
unknown_id = aurora_id("movie", new_uuid7())
response = await client.post(f"/api/v1/movies/{unknown_id}/watchlist", headers=_auth(token))
assert response.status_code == 404
@pytest.mark.asyncio
async def test_watchlist_is_scoped_to_the_caller(client: AsyncClient, db_session: AsyncSession):
movie = await make_movie(db_session, provider=await make_provider(db_session))
await db_session.commit()
owner_token = await _register(client, "[email protected]")
other_token = await _register(client, "[email protected]")
await client.post(f"/api/v1/movies/{movie_aurora_id(movie)}/watchlist", headers=_auth(owner_token))
owner_listing = await client.get("/api/v1/account/watchlist/movies", headers=_auth(owner_token))
other_listing = await client.get("/api/v1/account/watchlist/movies", headers=_auth(other_token))
assert owner_listing.json()["total_results"] == 1
assert other_listing.json()["total_results"] == 0
@pytest.mark.asyncio
async def test_add_series_to_watchlist_then_list(client: AsyncClient, db_session: AsyncSession):
series = await make_series(db_session)
await db_session.commit()
token = await _register(client, "[email protected]")
add_response = await client.post(
f"/api/v1/series/{series_aurora_id(series)}/watchlist", headers=_auth(token)
)
assert add_response.status_code == 204
listing = await client.get("/api/v1/account/watchlist/series", headers=_auth(token))
assert listing.json()["total_results"] == 1
assert listing.json()["results"][0]["id"] == series_aurora_id(series)
@pytest.mark.asyncio
async def test_remove_series_from_watchlist(client: AsyncClient, db_session: AsyncSession):
series = await make_series(db_session)
await db_session.commit()
token = await _register(client, "[email protected]")
await client.post(f"/api/v1/series/{series_aurora_id(series)}/watchlist", headers=_auth(token))
remove_response = await client.delete(
f"/api/v1/series/{series_aurora_id(series)}/watchlist", headers=_auth(token)
)
assert remove_response.status_code == 204
listing = await client.get("/api/v1/account/watchlist/series", headers=_auth(token))
assert listing.json()["total_results"] == 0
@pytest.mark.asyncio
async def test_add_to_watchlist_404_for_unknown_series(client: AsyncClient, db_session: AsyncSession):
from app.core.ids import aurora_id, new_uuid7
token = await _register(client, "[email protected]")
unknown_id = aurora_id("series", new_uuid7())
response = await client.post(f"/api/v1/series/{unknown_id}/watchlist", headers=_auth(token))
assert response.status_code == 404