feat: add awards to REST response
This commit is contained in:
parent
f0f69c1954
commit
b0f5ec4cc9
5 changed files with 142 additions and 5 deletions
|
|
@ -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})
|
||||||
|
|
|
||||||
22
unwind/db.py
22
unwind/db.py
|
|
@ -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}_")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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__
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue