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]>
This commit is contained in:
NixReaper
2026-09-06 10:47:41 -05:00
co-authored by Claude Sonnet 5
parent a38c2074a6
commit 0ab31a314c
20 changed files with 1043 additions and 6 deletions
+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