Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
d9fc178d6d wip 2024-05-11 20:47:46 +02:00
2bf6bc6e77 wip 2024-05-11 20:46:48 +02:00
3 changed files with 111 additions and 21 deletions

4
scripts/tests-cov-report Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh -eu
cd "$RUN_DIR"
exec "$RUN_BIN" tests -x --cov-report html:cov_html

View file

@ -457,6 +457,60 @@ class Rating:
ratings = Rating.__table__ ratings = Rating.__table__
# TODO
# - distinguish between ratings & watches
# - they are completely separate
# - 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,
Column("id", String, primary_key=True), # ULID
Column("movie_id", ForeignKey("movies.id"), nullable=False), # ULID
Column("user_id", ForeignKey("users.id"), nullable=False), # ULID
Column("started", String, nullable=False), # datetime
Column("finished", String), # datetime
Column("geoloc", String), # geo coords
Column("score", Integer), #
Column("favorite", Integer), # bool
)
id: ULID = field(default_factory=ULID)
movie_id: ULID = None
movie: Relation[Movie] = None
user_id: ULID = None
user: Relation[User] = None
started: datetime | None = None
finished: datetime | None = None
geoloc: str | None = None
score: int | None = None # range: [0,100]
favorite: bool | None = None
watches = Rating.__table__
class GroupUser(TypedDict): class GroupUser(TypedDict):
id: str id: str
name: str name: str

View file

@ -49,12 +49,12 @@ class BearerAuthBackend(AuthenticationBackend):
self.admin_tokens = {v: k for k, v in credentials.items()} self.admin_tokens = {v: k for k, v in credentials.items()}
async def authenticate(self, conn: HTTPConnection): async def authenticate(self, conn: HTTPConnection):
if "Authorization" not in conn.headers: if "authorization" not in conn.headers:
return return
# XXX should we remove the auth header after reading, for security reasons? # XXX should we remove the auth header after reading, for security reasons?
auth = conn.headers["Authorization"] auth = conn.headers["authorization"]
try: try:
scheme, credentials = auth.split() scheme, credentials = auth.split()
except ValueError as err: except ValueError as err:
@ -62,7 +62,8 @@ class BearerAuthBackend(AuthenticationBackend):
roles = [] roles = []
if scheme.lower() == "bearer": match scheme.lower():
case "bearer":
is_admin = credentials in self.admin_tokens is_admin = credentials in self.admin_tokens
if not is_admin: if not is_admin:
return return
@ -70,14 +71,14 @@ class BearerAuthBackend(AuthenticationBackend):
user = SimpleUser(name) user = SimpleUser(name)
roles.append("admin") roles.append("admin")
elif scheme.lower() == "basic": case "basic":
try: try:
name, secret = b64decode(credentials).decode().split(":") name, secret = b64decode(credentials).decode().split(":")
except Exception as err: except Exception as err:
raise AuthenticationError("Invalid auth credentials") from err raise AuthenticationError("Invalid auth credentials") from err
user = AuthedUser(name, secret) user = AuthedUser(name, secret)
else: case _:
return return
return AuthCredentials(["authenticated", *roles]), user return AuthCredentials(["authenticated", *roles]), user
@ -140,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]) -> 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(): if not await request.body():
data = {} data = {}
@ -150,13 +151,16 @@ async def json_from_body(request, keys: list[str] | None = None):
except JSONDecodeError as err: except JSONDecodeError as err:
raise HTTPException(422, "Invalid JSON content.") from 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: if not keys:
return data return data
try: try:
return [data[k] for k in keys] return [data[k] for k in keys]
except KeyError as err: 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): def is_admin(request):
@ -366,6 +370,34 @@ async def progress_for_load_imdb_movies(request):
return JSONResponse(resp) return JSONResponse(resp)
@route("/users/{user_id}/[movies/{movie_id}/]watches", methods=["POST"])
@requires(["authenticated"])
async def add_watch_to_user(request):
# {
# id
# movie_id
# location (gps)
# started
# finished
# score
# fav
# }
user_id = as_ulid(request.path_params["user_id"])
geoloc, started = await json_from_body(request, ["geoloc", "started"])
@route("/users/{user_id}/[movies/{movie_id}/]watches/{watch_id}", methods=["PUT"])
@requires(["authenticated"])
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() _import_lock = asyncio.Lock()
@ -658,7 +690,7 @@ def create_app():
Mount(f"{config.api_base}v1", routes=_routes), Mount(f"{config.api_base}v1", routes=_routes),
], ],
middleware=[ middleware=[
Middleware(ResponseTimeMiddleware, header_name="Unwind-Elapsed"), Middleware(ResponseTimeMiddleware, header_name="unwind-elapsed"),
Middleware( Middleware(
AuthenticationMiddleware, AuthenticationMiddleware,
backend=BearerAuthBackend(config.api_credentials), backend=BearerAuthBackend(config.api_credentials),