feat: add awards to REST response

This commit is contained in:
ducklet 2024-05-19 21:42:24 +02:00
parent f0f69c1954
commit b0f5ec4cc9
5 changed files with 142 additions and 5 deletions

View file

@ -32,6 +32,74 @@ def admin_client() -> TestClient:
return client return client
@pytest.mark.asyncio
async def test_get_ratings_for_group_with_awards(
conn: db.Connection, unauthorized_client: TestClient
):
user = models.User(
imdb_id="ur12345678",
name="user-1",
secret="secret-1", # noqa: S106
groups=[],
)
group = models.Group(
name="group-1",
users=[models.GroupUser(id=str(user.id), name=user.name)],
)
user.groups = [models.UserGroup(id=str(group.id), access="r")]
path = app.url_path_for("get_ratings_for_group", group_id=str(group.id))
await db.add(conn, user)
await db.add(conn, group)
movie1 = models.Movie(
title="test movie",
release_year=2013,
media_type="Movie",
imdb_id="tt12345678",
genres={"genre-1"},
)
await db.add(conn, movie1)
movie2 = models.Movie(
title="test movie 2",
release_year=2014,
media_type="Movie",
imdb_id="tt12345679",
genres={"genre-2"},
)
await db.add(conn, movie2)
award1 = models.Award(
movie_id=movie1.id, category="imdb-top-250", details='{"position":23}'
)
award2 = models.Award(
movie_id=movie2.id, category="imdb-top-250", details='{"position":99}'
)
await db.add(conn, award1)
await db.add(conn, award2)
rating = models.Rating(
movie_id=movie1.id, user_id=user.id, score=66, rating_date=datetime.now(tz=UTC)
)
await db.add(conn, rating)
rating_aggregate = {
"canonical_title": movie1.title,
"imdb_score": movie1.imdb_score,
"imdb_votes": movie1.imdb_votes,
"link": imdb.movie_url(movie1.imdb_id),
"media_type": movie1.media_type,
"original_title": movie1.original_title,
"user_scores": [rating.score],
"year": movie1.release_year,
"awards": ["imdb-top-250:23"],
}
resp = unauthorized_client.get(path)
assert resp.status_code == 200
assert resp.json() == [rating_aggregate]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_ratings_for_group( async def test_get_ratings_for_group(
conn: db.Connection, unauthorized_client: TestClient conn: db.Connection, unauthorized_client: TestClient
@ -82,6 +150,7 @@ async def test_get_ratings_for_group(
"original_title": movie.original_title, "original_title": movie.original_title,
"user_scores": [rating.score], "user_scores": [rating.score],
"year": movie.release_year, "year": movie.release_year,
"awards": [],
} }
resp = unauthorized_client.get(path) resp = unauthorized_client.get(path)
@ -158,6 +227,7 @@ async def test_list_movies(
"original_title": m.original_title, "original_title": m.original_title,
"user_scores": [], "user_scores": [],
"year": m.release_year, "year": m.release_year,
"awards": [],
} }
response = authorized_client.get(path, params={"imdb_id": m.imdb_id}) response = authorized_client.get(path, params={"imdb_id": m.imdb_id})

View file

@ -12,12 +12,14 @@ import alembic.migration
from . import config from . import config
from .models import ( from .models import (
Award,
Model, Model,
Movie, Movie,
Progress, Progress,
Rating, Rating,
User, User,
asplain, asplain,
awards,
fromplain, fromplain,
metadata, metadata,
movies, movies,
@ -430,6 +432,26 @@ async def add_or_update_rating(conn: Connection, /, rating: Rating) -> bool:
return False return False
type MovieImdbId = str
async def get_awards(
conn: Connection, /, imdb_ids: list[MovieImdbId]
) -> dict[MovieImdbId, list[Award]]:
query = (
sa.select(Award, movies.c.imdb_id)
.join(movies, awards.c.movie_id == movies.c.id)
.where(movies.c.imdb_id.in_(imdb_ids))
)
rows = await fetch_all(conn, query)
awards_dict: dict[MovieImdbId, list[Award]] = {}
for row in rows:
awards_dict.setdefault(row.imdb_id, []).append(
fromplain(Award, row._mapping, serialized=True)
)
return awards_dict
def sql_escape(s: str, char: str = "#") -> str: def sql_escape(s: str, char: str = "#") -> str:
return s.replace(char, 2 * char).replace("%", f"{char}%").replace("_", f"{char}_") return s.replace(char, 2 * char).replace("%", f"{char}%").replace("_", f"{char}_")

View file

@ -182,12 +182,17 @@ def fromplain[T](cls: Type[T], d: Mapping, *, serialized: bool = False) -> T:
If `serialized` is `True`, collection types (lists, dicts, etc.) will be If `serialized` is `True`, collection types (lists, dicts, etc.) will be
deserialized from string. This is the opposite operation of `serialize` for deserialized from string. This is the opposite operation of `serialize` for
`asplain`. `asplain`.
Fields in the data that cannot be mapped to the given type are simply ignored.
""" """
load = json.loads if serialized else _id load = json.loads if serialized else _id
dd: JSONObject = {} dd: JSONObject = {}
for f in fields(cls): for f in fields(cls):
target: Any = f.type target: Any = f.type
if isinstance(target, TypeAliasType):
# Support type aliases.
target = target.__value__
otype = optional_type(f.type) otype = optional_type(f.type)
is_opt = otype is not None is_opt = otype is not None
if is_opt: if is_opt:
@ -198,6 +203,12 @@ def fromplain[T](cls: Type[T], d: Mapping, *, serialized: bool = False) -> T:
v = d[f.name] v = d[f.name]
if is_opt and v is None: if is_opt and v is None:
dd[f.name] = v dd[f.name] = v
elif target is Literal:
# Support literal types.
vals = get_args(f.type.__value__)
if v not in vals:
raise ValueError(f"Invalid value: {f.name!a}: {v!a}")
dd[f.name] = v
elif isinstance(v, target): elif isinstance(v, target):
dd[f.name] = v dd[f.name] = v
elif target in {set, list}: elif target in {set, list}:
@ -530,5 +541,23 @@ class Award:
created: datetime = field(default_factory=utcnow) created: datetime = field(default_factory=utcnow)
updated: datetime = field(default_factory=utcnow) updated: datetime = field(default_factory=utcnow)
@property
def _details(self) -> JSONObject:
return json.loads(self.details or "{}")
@_details.setter
def _details(self, details: JSONObject):
self.details = json_dump(details)
@property
def position(self) -> int:
return self._details["position"]
@position.setter
def position(self, position: int):
details = self._details
details["position"] = position
self._details = details
awards = Award.__table__ awards = Award.__table__

View file

@ -235,11 +235,13 @@ async def get_ratings_for_group(request: Request) -> JSONResponse:
user_ids=user_ids, user_ids=user_ids,
) )
ratings = (web_models.Rating(**r) for r in rows) ratings = [web_models.Rating(**r) for r in rows]
aggr = web_models.aggregate_ratings(ratings, user_ids) awards = await db.get_awards(conn, imdb_ids=[r.movie_imdb_id for r in ratings])
resp = tuple(asplain(r) for r in aggr) aggrs = web_models.aggregate_ratings(ratings, user_ids, awards_dict=awards)
resp = tuple(asplain(r) for r in aggrs)
return JSONResponse(resp) return JSONResponse(resp)

View file

@ -44,6 +44,7 @@ class RatingAggregate:
original_title: str | None original_title: str | None
user_scores: list[Score100] user_scores: list[Score100]
year: int year: int
awards: list[str]
@classmethod @classmethod
def from_movie(cls, movie: models.Movie, *, ratings: Iterable[models.Rating] = []): def from_movie(cls, movie: models.Movie, *, ratings: Iterable[models.Rating] = []):
@ -56,15 +57,27 @@ class RatingAggregate:
original_title=movie.original_title, original_title=movie.original_title,
user_scores=[r.score for r in ratings], user_scores=[r.score for r in ratings],
year=movie.release_year, year=movie.release_year,
awards=[],
) )
type ImdbMovieId = str
type UserId = str
def aggregate_ratings( def aggregate_ratings(
ratings: Iterable[Rating], user_ids: Container[str] ratings: Iterable[Rating],
user_ids: Container[UserId],
*,
awards_dict: dict[ImdbMovieId, list[models.Award]] | None = None,
) -> Iterable[RatingAggregate]: ) -> Iterable[RatingAggregate]:
aggr: dict[str, RatingAggregate] = {} if awards_dict is None:
awards_dict = {}
aggr: dict[ImdbMovieId, RatingAggregate] = {}
for r in ratings: for r in ratings:
awards = awards_dict.get(r.movie_imdb_id, [])
mov = aggr.setdefault( mov = aggr.setdefault(
r.movie_imdb_id, r.movie_imdb_id,
RatingAggregate( RatingAggregate(
@ -76,6 +89,7 @@ def aggregate_ratings(
original_title=r.original_title, original_title=r.original_title,
user_scores=[], user_scores=[],
year=r.release_year, year=r.release_year,
awards=[f"{a.category}:{a.position}" for a in awards],
), ),
) )
# XXX do we need this? why don't we just get the ratings we're supposed to aggregate? # XXX do we need this? why don't we just get the ratings we're supposed to aggregate?