diff --git a/app/api/v1/account.py b/app/api/v1/account.py new file mode 100644 index 0000000..6db52af --- /dev/null +++ b/app/api/v1/account.py @@ -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 + ) diff --git a/app/api/v1/movies.py b/app/api/v1/movies.py index c906fe4..000c066 100644 --- a/app/api/v1/movies.py +++ b/app/api/v1/movies.py @@ -8,10 +8,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from redis.asyncio import Redis 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.redis import get_redis 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 ( MovieCreditsOut, MovieDetailOut, @@ -155,3 +157,52 @@ async def get_movie_watch_providers( return await movie_service.get_movie_watch_providers(session, aurora_id) except movie_service.MovieNotFoundError as 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 diff --git a/app/api/v1/series.py b/app/api/v1/series.py index 3ff0586..4c19845 100644 --- a/app/api/v1/series.py +++ b/app/api/v1/series.py @@ -8,10 +8,12 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from redis.asyncio import Redis 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.redis import get_redis 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 ( SeriesCreditsOut, SeriesDetailOut, @@ -135,3 +137,52 @@ async def get_series_watch_providers( return await tv_service.get_series_watch_providers(session, aurora_id) except tv_service.SeriesNotFoundError as 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 diff --git a/app/core/ids.py b/app/core/ids.py index 122187b..7e3e209 100644 --- a/app/core/ids.py +++ b/app/core/ids.py @@ -52,6 +52,8 @@ ENTITY_PREFIXES: dict[str, str] = { "release": "rel", "watch_provider": "watch_provider", "user": "user", + "rating": "rating", + "watchlist_entry": "watchlist", } diff --git a/app/main.py b/app/main.py index 2523f83..f0a5d56 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_ from starlette.exceptions import HTTPException as StarletteHTTPException from app.api.v1 import ( + account, admin, auth, collections, @@ -183,6 +184,7 @@ API_V1_PREFIX = "/api/v1" app.include_router(health.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(account.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(seasons.router, prefix=API_V1_PREFIX) diff --git a/app/models/__init__.py b/app/models/__init__.py index fae8d24..d34470a 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -24,6 +24,7 @@ from app.models.movie import ( ) from app.models.person import Person from app.models.provider import IngestionJob, Provider, ProviderRecord +from app.models.rating import Rating from app.models.trending import TrendingEntry from app.models.tv import ( Episode, @@ -36,6 +37,7 @@ from app.models.tv import ( from app.models.user import User from app.models.video import Video from app.models.watch_provider import WatchProvider, WatchProviderOffering +from app.models.watchlist import WatchlistEntry __all__ = [ "Base", @@ -79,4 +81,6 @@ __all__ = [ "WatchProviderOffering", "TrendingEntry", "User", + "Rating", + "WatchlistEntry", ] diff --git a/app/models/rating.py b/app/models/rating.py new file mode 100644 index 0000000..b322659 --- /dev/null +++ b/app/models/rating.py @@ -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"] diff --git a/app/models/watchlist.py b/app/models/watchlist.py new file mode 100644 index 0000000..1c380f2 --- /dev/null +++ b/app/models/watchlist.py @@ -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"] diff --git a/app/repositories/rating_repository.py b/app/repositories/rating_repository.py new file mode 100644 index 0000000..731c263 --- /dev/null +++ b/app/repositories/rating_repository.py @@ -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 diff --git a/app/repositories/watchlist_repository.py b/app/repositories/watchlist_repository.py new file mode 100644 index 0000000..cfc973e --- /dev/null +++ b/app/repositories/watchlist_repository.py @@ -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 diff --git a/app/schemas/account.py b/app/schemas/account.py new file mode 100644 index 0000000..9d181a9 --- /dev/null +++ b/app/schemas/account.py @@ -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 diff --git a/app/schemas/movie.py b/app/schemas/movie.py index 2736675..b000b25 100644 --- a/app/schemas/movie.py +++ b/app/schemas/movie.py @@ -106,3 +106,15 @@ class MovieListOut(BaseModel): total_results: int total_pages: int 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] = [] diff --git a/app/schemas/tv.py b/app/schemas/tv.py index c41f5b3..89a429f 100644 --- a/app/schemas/tv.py +++ b/app/schemas/tv.py @@ -115,6 +115,18 @@ class SeriesListOut(BaseModel): 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): id: str episode_number: int diff --git a/app/services/movie_service.py b/app/services/movie_service.py index cd2e86d..ce2822c 100644 --- a/app/services/movie_service.py +++ b/app/services/movie_service.py @@ -9,7 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.ids import aurora_id, parse_aurora_id from app.models.enums import ArtworkType, MovieStatus 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.movie import ( MovieCreditsOut, @@ -20,6 +27,8 @@ from app.schemas.movie import ( MovieSummaryOut, MovieVideosOut, MovieWatchProvidersOut, + RatedMovieListOut, + RatedMovieOut, ) from app.services.common import ( build_watch_providers_out, @@ -209,6 +218,79 @@ async def build_movie_summaries( 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( session: AsyncSession, *, region: str, page: int, page_size: int ) -> MovieListOut: diff --git a/app/services/tv_service.py b/app/services/tv_service.py index dbda0f4..8dbe75f 100644 --- a/app/services/tv_service.py +++ b/app/services/tv_service.py @@ -10,11 +10,20 @@ from app.core.ids import aurora_id, parse_aurora_id from app.models.credit import Credit from app.models.enums import ArtworkType, CompanyType, SeriesStatus 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.tv import ( EpisodeBriefOut, EpisodeDetailOut, + RatedSeriesListOut, + RatedSeriesOut, SeasonBriefOut, SeasonDetailOut, SeriesCreditsOut, @@ -316,6 +325,81 @@ async def build_series_summaries( 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") diff --git a/docs/api.md b/docs/api.md index 2554639..90f5c55 100644 --- a/docs/api.md +++ b/docs/api.md @@ -30,8 +30,26 @@ a consistent envelope: 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) -once a token expires. This is the foundation ratings and a watchlist will -build on next; those aren't live yet. +once a token expires. + +## Ratings and watchlist + +All of these require `Authorization: Bearer ` 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 diff --git a/migrations/versions/0005_ratings_and_watchlist.py b/migrations/versions/0005_ratings_and_watchlist.py new file mode 100644 index 0000000..b5e4a9f --- /dev/null +++ b/migrations/versions/0005_ratings_and_watchlist.py @@ -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") diff --git a/tests/integration/factories.py b/tests/integration/factories.py index cf874f5..7327ffc 100644 --- a/tests/integration/factories.py +++ b/tests/integration/factories.py @@ -14,6 +14,7 @@ from app.models.genre import Genre, MovieGenre from app.models.movie import Movie from app.models.person import Person from app.models.provider import Provider +from app.models.tv import Series 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: 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)) diff --git a/tests/integration/test_ratings_api.py b/tests/integration/test_ratings_api.py new file mode 100644 index 0000000..f6ea03f --- /dev/null +++ b/tests/integration/test_ratings_api.py @@ -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, "neo@zion.net") + + 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, "trinity@zion.net") + + 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, "morpheus@zion.net") + 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, "cypher@zion.net") + + 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, "tank@zion.net") + + 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, "dozer@zion.net") + other_token = await _register(client, "switch@zion.net") + + 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, "avon@baltimore.net") + + 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, "stringer@baltimore.net") + 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, "mcnulty@baltimore.net") + + 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 diff --git a/tests/integration/test_watchlist_api.py b/tests/integration/test_watchlist_api.py new file mode 100644 index 0000000..d9b40a0 --- /dev/null +++ b/tests/integration/test_watchlist_api.py @@ -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, "neo@zion.net") + + 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, "trinity@zion.net") + + 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, "morpheus@zion.net") + + 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, "cypher@zion.net") + 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, "tank@zion.net") + other_token = await _register(client, "dozer@zion.net") + + 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, "avon@baltimore.net") + + 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, "stringer@baltimore.net") + + 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, "mcnulty@baltimore.net") + 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