add more filtering options

This commit is contained in:
ducklet 2021-06-21 23:48:36 +02:00
parent 7dd10f8bc3
commit d09880438d
5 changed files with 113 additions and 41 deletions

View file

@ -4,4 +4,4 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
exec uvicorn unwind:web_app --reload exec uvicorn unwind:create_app --factory --reload

View file

@ -4,4 +4,4 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
exec uvicorn --host 0.0.0.0 unwind:web_app exec uvicorn --host 0.0.0.0 --factory unwind:create_app

View file

@ -1 +1 @@
from .web import app as web_app from .web import create_app

View file

@ -1,8 +1,10 @@
import logging import logging
import re
from dataclasses import fields from dataclasses import fields
from pathlib import Path from pathlib import Path
from typing import Optional, Type, TypeVar from typing import Optional, Type, TypeVar
import sqlalchemy
from databases import Database from databases import Database
from . import config from . import config
@ -152,6 +154,8 @@ async def find_ratings(
title: str = None, title: str = None,
media_type: str = None, media_type: str = None,
ignore_tv_episodes: bool = False, ignore_tv_episodes: bool = False,
include_unrated: bool = False,
year: int = None,
limit_rows=10, limit_rows=10,
): ):
values = { values = {
@ -163,17 +167,19 @@ async def find_ratings(
values["escape"] = "#" values["escape"] = "#"
escaped_title = sql_escape(title, char=values["escape"]) escaped_title = sql_escape(title, char=values["escape"])
values["pattern"] = "%" + "%".join(escaped_title.split()) + "%" values["pattern"] = "%" + "%".join(escaped_title.split()) + "%"
values["opattern"] = values["pattern"]
values["oescape"] = values["escape"]
conditions.append( conditions.append(
f""" f"""
( (
{Movie._table}.title LIKE :pattern ESCAPE :escape {Movie._table}.title LIKE :pattern ESCAPE :escape
OR {Movie._table}.original_title LIKE :opattern ESCAPE :oescape OR {Movie._table}.original_title LIKE :pattern ESCAPE :escape
) )
""" """
) )
if year:
values["year"] = year
conditions.append(f"{Movie._table}.release_year=:year")
if media_type: if media_type:
values["media_type"] = media_type values["media_type"] = media_type
conditions.append(f"{Movie._table}.media_type=:media_type") conditions.append(f"{Movie._table}.media_type=:media_type")
@ -181,19 +187,44 @@ async def find_ratings(
if ignore_tv_episodes: if ignore_tv_episodes:
conditions.append(f"{Movie._table}.media_type!='TV Episode'") conditions.append(f"{Movie._table}.media_type!='TV Episode'")
query = f""" source_table = "newest_movies"
WITH newest_movies ctes = [
AS ( f"""{source_table} AS (
SELECT DISTINCT {Rating._table}.movie_id SELECT DISTINCT {Rating._table}.movie_id
FROM {Rating._table} FROM {Rating._table}
LEFT JOIN {Movie._table} ON {Movie._table}.id={Rating._table}.movie_id LEFT JOIN {Movie._table} ON {Movie._table}.id={Rating._table}.movie_id
{('WHERE ' + ' AND '.join(conditions)) if conditions else ''} {('WHERE ' + ' AND '.join(conditions)) if conditions else ''}
ORDER BY length({Movie._table}.title) ASC, {Rating._table}.rating_date DESC ORDER BY length({Movie._table}.title) ASC, {Rating._table}.rating_date DESC
LIMIT :limit_rows LIMIT :limit_rows
)"""
]
if include_unrated:
source_table = "target_movies"
ctes.extend(
[
f"""unrated_movies AS (
SELECT DISTINCT id AS movie_id
FROM {Movie._table}
WHERE id NOT IN newest_movies
{('AND ' + ' AND '.join(conditions)) if conditions else ''}
ORDER BY length(title) ASC, release_year DESC
LIMIT :limit_rows
)""",
f"""{source_table} AS (
SELECT * FROM newest_movies
UNION ALL -- using ALL here avoids the reordering of IDs
SELECT * FROM unrated_movies
)""",
]
) )
query = f"""
WITH
{','.join(ctes)}
SELECT SELECT
{User._table}.name AS user_name, -- {User._table}.name AS user_name,
{Rating._table}.score AS user_score, {Rating._table}.score AS user_score,
{Movie._table}.score AS imdb_score, {Movie._table}.score AS imdb_score,
{Movie._table}.imdb_id AS movie_imdb_id, {Movie._table}.imdb_id AS movie_imdb_id,
@ -201,11 +232,33 @@ async def find_ratings(
{Movie._table}.title AS canonical_title, {Movie._table}.title AS canonical_title,
{Movie._table}.original_title AS original_title, {Movie._table}.original_title AS original_title,
{Movie._table}.release_year AS release_year {Movie._table}.release_year AS release_year
FROM newest_movies FROM {source_table}
LEFT JOIN {Rating._table} ON {Rating._table}.movie_id=newest_movies.movie_id LEFT JOIN {Rating._table} ON {Rating._table}.movie_id={source_table}.movie_id
LEFT JOIN {User._table} ON {User._table}.id={Rating._table}.user_id -- LEFT JOIN {User._table} ON {User._table}.id={Rating._table}.user_id
LEFT JOIN {Movie._table} ON {Movie._table}.id={Rating._table}.movie_id LEFT JOIN {Movie._table} ON {Movie._table}.id={source_table}.movie_id
LIMIT :limit_rows
""" """
rows = await shared_connection().fetch_all(query=query, values=values) rows = await shared_connection().fetch_all(bindparams(query, values))
return tuple(dict(r) for r in rows) return tuple(dict(r) for r in rows)
def bindparams(query: str, values: dict):
"""Bind values to a query.
This is similar to what SQLAlchemy and Databases do, but it allows to
easily use the same placeholder in multiple places.
"""
pump_vals = {}
pump_keys = {}
def pump(match):
key = match[1]
val = values[key]
pump_keys[key] = 1 + pump_keys.setdefault(key, 0)
pump_key = f"{key}_{pump_keys[key]}"
pump_vals[pump_key] = val
return f":{pump_key}"
pump_query = re.sub(r":(\w+)\b", pump, query)
return sqlalchemy.text(pump_query).bindparams(**pump_vals)

View file

@ -1,5 +1,6 @@
import base64 import base64
import binascii import binascii
import logging
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.authentication import ( from starlette.authentication import (
@ -19,6 +20,8 @@ from . import config, db
from .db import close_connection_pool, find_ratings, open_connection_pool from .db import close_connection_pool, find_ratings, open_connection_pool
from .models import Movie, asplain from .models import Movie, asplain
log = logging.getLogger(__name__)
class BasicAuthBackend(AuthenticationBackend): class BasicAuthBackend(AuthenticationBackend):
async def authenticate(self, request): async def authenticate(self, request):
@ -48,11 +51,13 @@ def truthy(s: str):
async def ratings(request): async def ratings(request):
title = request.query_params.get("title") params = request.query_params
media_type = request.query_params.get("media_type")
ignore_tv_episodes = truthy(request.query_params.get("ignore_tv_episodes"))
rows = await find_ratings( rows = await find_ratings(
title=title, media_type=media_type, ignore_tv_episodes=ignore_tv_episodes title=params.get("title"),
media_type=params.get("media_type"),
ignore_tv_episodes=truthy(params.get("ignore_tv_episodes")),
include_unrated=truthy(params.get("include_unrated")),
year=int(params["year"]) if "year" in params else None,
) )
aggr = {} aggr = {}
@ -69,6 +74,7 @@ async def ratings(request):
"media_type": r["media_type"], "media_type": r["media_type"],
}, },
) )
if r["user_score"] is not None:
mov["user_scores"].append(r["user_score"]) mov["user_scores"].append(r["user_score"])
resp = tuple(aggr.values()) resp = tuple(aggr.values())
@ -121,7 +127,16 @@ async def get_ratings_for_group(request):
request.path_params["group_id"] request.path_params["group_id"]
app = Starlette( def create_app():
if config.loglevel == "DEBUG":
logging.basicConfig(
format="%(asctime)s.%(msecs)03d [%(name)s:%(process)d] %(levelname)s: %(message)s",
datefmt="%H:%M:%S",
level=config.loglevel,
)
log.debug(f"Log level: {config.loglevel}")
return Starlette(
on_startup=[open_connection_pool], on_startup=[open_connection_pool],
on_shutdown=[close_connection_pool], on_shutdown=[close_connection_pool],
routes=[ routes=[
@ -133,12 +148,16 @@ app = Starlette(
Route("/movies", add_movie, methods=["POST"]), Route("/movies", add_movie, methods=["POST"]),
Route("/users", add_user, methods=["POST"]), Route("/users", add_user, methods=["POST"]),
Route("/users/{user_id}/ratings", ratings_for_user), Route("/users/{user_id}/ratings", ratings_for_user),
Route("/users/{user_id}/ratings", set_rating_for_user, methods=["PUT"]), Route(
"/users/{user_id}/ratings", set_rating_for_user, methods=["PUT"]
),
Route("/groups", add_group, methods=["POST"]), Route("/groups", add_group, methods=["POST"]),
Route("/groups/{group_id}/users", add_user_to_group, methods=["POST"]), Route(
"/groups/{group_id}/users", add_user_to_group, methods=["POST"]
),
Route("/groups/{group_id}/ratings", get_ratings_for_group), Route("/groups/{group_id}/ratings", get_ratings_for_group),
], ],
), ),
], ],
middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())], middleware=[Middleware(AuthenticationMiddleware, backend=BasicAuthBackend())],
) )