diff --git a/scripts/tests-cov-report b/scripts/tests-cov-report new file mode 100755 index 0000000..9bed8ab --- /dev/null +++ b/scripts/tests-cov-report @@ -0,0 +1,4 @@ +#!/bin/sh -eu + +cd "$RUN_DIR" +exec "$RUN_BIN" tests -x --cov-report html:cov_html diff --git a/unwind/models.py b/unwind/models.py index aae161b..6671a01 100644 --- a/unwind/models.py +++ b/unwind/models.py @@ -463,10 +463,22 @@ ratings = Rating.__table__ # - I can rate something at any time, without having watched it, e.g. in a discussion with a friend I change my opinion on a movie # - I can watch something without having fully formed an opinion yet, i.e. I don't want to rate it yet +# How are Rating.favorite and Rating.finished linked to Watches? +# - is Rating.favorite automatically Watches[-1].favorite, or any(Watches.favorite)? +# - Rating.favorite is nullable, so unless it's explicitly set we can default to Watches +# - is Rating.finished automatically any(Watches.finished)? +# - Rating.finished is nullable, so unless it's explicitly set we can default to Watches +# - can Rating.finished be set without a Watch? +# - yes +# - can Rating.favorite be set without a Watch? +# - yes + @mapper_registry.mapped @dataclass class Watch: + """A "check-in" event, the user started watching a movie.""" + __table__: ClassVar[Table] = Table( "watches", metadata, diff --git a/unwind/web.py b/unwind/web.py index 294e676..5cfeda9 100644 --- a/unwind/web.py +++ b/unwind/web.py @@ -141,7 +141,7 @@ async def json_from_body(request) -> dict: ... async def json_from_body(request, keys: list[str]) -> list: ... -async def json_from_body(request, keys: list[str] | None = None): +async def json_from_body(request, keys: list[str] | None = None) -> dict | list: if not await request.body(): data = {} @@ -151,13 +151,16 @@ async def json_from_body(request, keys: list[str] | None = None): except JSONDecodeError as err: raise HTTPException(422, "Invalid JSON content.") from err + if not isinstance(data, dict): + raise HTTPException(422, f"Unexpected JSON root type: {type(data)!a}.") + if not keys: return data try: return [data[k] for k in keys] except KeyError as err: - raise HTTPException(422, f"Missing data for key: {err.args[0]}") from err + raise HTTPException(422, f"Missing data for key: {err.args[0]!a}") from err def is_admin(request): @@ -367,9 +370,9 @@ async def progress_for_load_imdb_movies(request): return JSONResponse(resp) -@route("/users/{user_id}/watches", methods=["POST"]) +@route("/users/{user_id}/[movies/{movie_id}/]watches", methods=["POST"]) @requires(["authenticated"]) -async def users_watching_start(request): +async def add_watch_to_user(request): # { # id # movie_id @@ -379,19 +382,20 @@ async def users_watching_start(request): # score # fav # } - pass + user_id = as_ulid(request.path_params["user_id"]) + + geoloc, started = await json_from_body(request, ["geoloc", "started"]) -@route("/users/{user_id}/watches/{id}", methods=["PUT"]) +@route("/users/{user_id}/[movies/{movie_id}/]watches/{watch_id}", methods=["PUT"]) @requires(["authenticated"]) -async def users_watching_done(request): - # { - # ... - # finished - # score - # fav - # } - pass +async def update_watch_for_user(request): + user_id = as_ulid(request.path_params["user_id"]) + watch_id = as_ulid(request.path_params["watch_id"]) + + finished, score, favorite = await json_from_body( + request, ["finished", "score", "favorite"] + ) _import_lock = asyncio.Lock() @@ -686,7 +690,7 @@ def create_app(): Mount(f"{config.api_base}v1", routes=_routes), ], middleware=[ - Middleware(ResponseTimeMiddleware, header_name="Unwind-Elapsed"), + Middleware(ResponseTimeMiddleware, header_name="unwind-elapsed"), Middleware( AuthenticationMiddleware, backend=BearerAuthBackend(config.api_credentials),