Compare commits
2 commits
main
...
feat/watch
| Author | SHA1 | Date | |
|---|---|---|---|
| d9fc178d6d | |||
| 2bf6bc6e77 |
3 changed files with 111 additions and 21 deletions
4
scripts/tests-cov-report
Executable file
4
scripts/tests-cov-report
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/sh -eu
|
||||||
|
|
||||||
|
cd "$RUN_DIR"
|
||||||
|
exec "$RUN_BIN" tests -x --cov-report html:cov_html
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,23 +62,24 @@ class BearerAuthBackend(AuthenticationBackend):
|
||||||
|
|
||||||
roles = []
|
roles = []
|
||||||
|
|
||||||
if scheme.lower() == "bearer":
|
match scheme.lower():
|
||||||
is_admin = credentials in self.admin_tokens
|
case "bearer":
|
||||||
if not is_admin:
|
is_admin = credentials in self.admin_tokens
|
||||||
|
if not is_admin:
|
||||||
|
return
|
||||||
|
name = self.admin_tokens[credentials]
|
||||||
|
user = SimpleUser(name)
|
||||||
|
roles.append("admin")
|
||||||
|
|
||||||
|
case "basic":
|
||||||
|
try:
|
||||||
|
name, secret = b64decode(credentials).decode().split(":")
|
||||||
|
except Exception as err:
|
||||||
|
raise AuthenticationError("Invalid auth credentials") from err
|
||||||
|
user = AuthedUser(name, secret)
|
||||||
|
|
||||||
|
case _:
|
||||||
return
|
return
|
||||||
name = self.admin_tokens[credentials]
|
|
||||||
user = SimpleUser(name)
|
|
||||||
roles.append("admin")
|
|
||||||
|
|
||||||
elif scheme.lower() == "basic":
|
|
||||||
try:
|
|
||||||
name, secret = b64decode(credentials).decode().split(":")
|
|
||||||
except Exception as err:
|
|
||||||
raise AuthenticationError("Invalid auth credentials") from err
|
|
||||||
user = AuthedUser(name, secret)
|
|
||||||
|
|
||||||
else:
|
|
||||||
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),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue