From b0f5ec4cc92e077b6f1a393fa992de48e895830a Mon Sep 17 00:00:00 2001 From: ducklet Date: Sun, 19 May 2024 21:42:24 +0200 Subject: [PATCH] feat: add awards to REST response --- tests/test_web.py | 70 ++++++++++++++++++++++++++++++++++++++++++++ unwind/db.py | 22 ++++++++++++++ unwind/models.py | 29 ++++++++++++++++++ unwind/web.py | 8 +++-- unwind/web_models.py | 18 ++++++++++-- 5 files changed, 142 insertions(+), 5 deletions(-) diff --git a/tests/test_web.py b/tests/test_web.py index b1e7e4b..46cc28a 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -32,6 +32,74 @@ def admin_client() -> TestClient: 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 async def test_get_ratings_for_group( conn: db.Connection, unauthorized_client: TestClient @@ -82,6 +150,7 @@ async def test_get_ratings_for_group( "original_title": movie.original_title, "user_scores": [rating.score], "year": movie.release_year, + "awards": [], } resp = unauthorized_client.get(path) @@ -158,6 +227,7 @@ async def test_list_movies( "original_title": m.original_title, "user_scores": [], "year": m.release_year, + "awards": [], } response = authorized_client.get(path, params={"imdb_id": m.imdb_id}) diff --git a/unwind/db.py b/unwind/db.py index 16351c7..e416d8b 100644 --- a/unwind/db.py +++ b/unwind/db.py @@ -12,12 +12,14 @@ import alembic.migration from . import config from .models import ( + Award, Model, Movie, Progress, Rating, User, asplain, + awards, fromplain, metadata, movies, @@ -430,6 +432,26 @@ async def add_or_update_rating(conn: Connection, /, rating: Rating) -> bool: 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: return s.replace(char, 2 * char).replace("%", f"{char}%").replace("_", f"{char}_") diff --git a/unwind/models.py b/unwind/models.py index 31e18c9..2d59cd0 100644 --- a/unwind/models.py +++ b/unwind/models.py @@ -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 deserialized from string. This is the opposite operation of `serialize` for `asplain`. + Fields in the data that cannot be mapped to the given type are simply ignored. """ load = json.loads if serialized else _id dd: JSONObject = {} for f in fields(cls): target: Any = f.type + if isinstance(target, TypeAliasType): + # Support type aliases. + target = target.__value__ + otype = optional_type(f.type) is_opt = otype is not None if is_opt: @@ -198,6 +203,12 @@ def fromplain[T](cls: Type[T], d: Mapping, *, serialized: bool = False) -> T: v = d[f.name] if is_opt and v is None: 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): dd[f.name] = v elif target in {set, list}: @@ -530,5 +541,23 @@ class Award: created: 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__ diff --git a/unwind/web.py b/unwind/web.py index 6a2a0fc..ee024e9 100644 --- a/unwind/web.py +++ b/unwind/web.py @@ -235,11 +235,13 @@ async def get_ratings_for_group(request: Request) -> JSONResponse: 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) diff --git a/unwind/web_models.py b/unwind/web_models.py index 6a2c331..0551ba3 100644 --- a/unwind/web_models.py +++ b/unwind/web_models.py @@ -44,6 +44,7 @@ class RatingAggregate: original_title: str | None user_scores: list[Score100] year: int + awards: list[str] @classmethod def from_movie(cls, movie: models.Movie, *, ratings: Iterable[models.Rating] = []): @@ -56,15 +57,27 @@ class RatingAggregate: original_title=movie.original_title, user_scores=[r.score for r in ratings], year=movie.release_year, + awards=[], ) +type ImdbMovieId = str +type UserId = str + + 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]: - aggr: dict[str, RatingAggregate] = {} + if awards_dict is None: + awards_dict = {} + + aggr: dict[ImdbMovieId, RatingAggregate] = {} for r in ratings: + awards = awards_dict.get(r.movie_imdb_id, []) mov = aggr.setdefault( r.movie_imdb_id, RatingAggregate( @@ -76,6 +89,7 @@ def aggregate_ratings( original_title=r.original_title, user_scores=[], 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?