diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9c2380d8..95cc66aa 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,6 +11,11 @@ jobs: steps: - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + + - run: | + pip install -r src/lambdas/api/watch_histories/requirements.txt --target src/lambdas/api/watch_histories/ + - name: cdk deploy uses: youyo/aws-cdk-github-actions@v2.0.2 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c912f50..840eb47c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,7 @@ jobs: pip install -r src/layers/databases/requirements.txt pip install -r src/layers/api/requirements.txt pip install -r src/layers/utils/requirements.txt + pip install -r src/lambdas/api/watch_histories/requirements.txt - name: Lint with flake8 run: | diff --git a/Makefile b/Makefile index 8e88304a..c59276a4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test test: pip install -U -r test/unittest/requirements.txt - PYTHONPATH=./src/layers/utils/python:./src/lambdas/:./src/layers/databases/python:./src/layers/api/python \ + PYTHONPATH=./src/layers/utils/python:./src/lambdas/:./src/layers/databases/python:./src/layers/api/python:./src/lambdas/api/watch_histories \ pytest test/unittest --cov-report html --cov=src -vv .PHONY: apitest @@ -14,4 +14,5 @@ generate-hashes: pip-compile --generate-hashes src/layers/api/requirements.in --output-file src/layers/api/requirements.txt --allow-unsafe pip-compile --generate-hashes src/layers/databases/requirements.in --output-file src/layers/databases/requirements.txt --allow-unsafe pip-compile --generate-hashes src/layers/utils/requirements.in --output-file src/layers/utils/requirements.txt --allow-unsafe - pip-compile --generate-hashes deploy/requirements.in --output-file deploy/requirements.txt --allow-unsafe \ No newline at end of file + pip-compile --generate-hashes deploy/requirements.in --output-file deploy/requirements.txt --allow-unsafe + pip-compile --generate-hashes src/lambdas/api/watch_histories/requirements.in --output-file src/lambdas/api/watch_histories/requirements.txt \ No newline at end of file diff --git a/deploy/lib/watch_history.py b/deploy/lib/watch_history.py index fc738474..95c94fc4 100644 --- a/deploy/lib/watch_history.py +++ b/deploy/lib/watch_history.py @@ -36,12 +36,19 @@ def __init__(self, app: core.App, id: str, anime_api_url: str, self.domain_name = domain_name self.layers = {} self.lambdas = {} + self._create_topics() self._create_tables() self._create_lambdas_config() self._create_layers() self._create_lambdas() self._create_gateway() + def _create_topics(self): + self.show_updates_topic = Topic( + self, + "shows_updates", + ) + def _create_tables(self): self.watch_history_table = Table( self, @@ -82,6 +89,11 @@ def _create_tables(self): sort_key=Attribute(name="special_progress", type=AttributeType.NUMBER), index_name="special_progress" ) + self.watch_history_table.add_global_secondary_index( + partition_key=Attribute(name="username", type=AttributeType.STRING), + sort_key=Attribute(name="api_info", type=AttributeType.STRING), + index_name="api_info" + ) self.episodes_table = Table( self, @@ -96,9 +108,48 @@ def _create_tables(self): type=AttributeType.STRING), index_name="latest_watch_date" ) + self.episodes_table.add_global_secondary_index( + partition_key=Attribute(name="username", type=AttributeType.STRING), + sort_key=Attribute(name="api_info", type=AttributeType.STRING), + index_name="api_info" + ) + self.episodes_table.add_global_secondary_index( + partition_key=Attribute(name="api_info", type=AttributeType.STRING), + index_name="all_api_info" + ) def _create_lambdas_config(self): self.lambdas_config = { + "api-watch_histories": { + "layers": ["utils", "databases", "api"], + "variables": { + "DATABASE_NAME": self.watch_history_table.table_name, + "EPISODES_DATABASE_NAME": self.episodes_table.table_name, + "LOG_LEVEL": "INFO", + "ANIME_API_URL": self.anime_api_url, + "MOVIE_API_URL": self.movie_api_url, + }, + "concurrent_executions": 100, + "policies": [ + PolicyStatement( + actions=["dynamodb:Query"], + resources=[ + self.watch_history_table.table_arn, + f"{self.watch_history_table.table_arn}/index/*", + self.episodes_table.table_arn, + f"{self.episodes_table.table_arn}/index/*", + ] + ), + PolicyStatement( + actions=["dynamodb:UpdateItem"], + resources=[ + self.watch_history_table.table_arn, + self.episodes_table.table_arn + ], + ), + ], + "timeout": 60 + }, "api-watch_history": { "layers": ["utils", "databases", "api"], "variables": { @@ -242,6 +293,28 @@ def _create_lambdas_config(self): ], "timeout": 10 }, + "cron-show_updates": { + "layers": ["utils", "databases", "api"], + "variables": { + "DATABASE_NAME": self.watch_history_table.table_name, + "LOG_LEVEL": "INFO", + "UPDATES_TOPIC_ARN": self.show_updates_topic.topic_arn, + }, + "concurrent_executions": 1, + "policies": [ + PolicyStatement( + actions=["dynamodb:Query"], + resources=[ + f"{self.watch_history_table.table_arn}/index/item_id"], + ), + PolicyStatement( + actions=["sns:Publish"], + resources=[self.show_updates_topic.topic_arn], + ) + ], + "timeout": 60, + "memory": 1024 + }, "subscribers-show_updates": { "layers": ["utils", "databases", "api"], "variables": { @@ -307,6 +380,9 @@ def _create_lambdas(self): if f != "__init__.py": continue + if "watch_histories/" in root: + continue + parent_folder = os.path.basename(os.path.dirname(root)) lambda_folder = os.path.basename(root) name = f"{parent_folder}-{lambda_folder}" @@ -395,6 +471,11 @@ def _create_gateway(self): ) routes = { + "watch_histories": { + "method": ["GET", "POST", "PUT", "DELETE"], + "route": "/{proxy+}", + "target_lambda": self.lambdas["api-watch_histories"] + }, "watch_history": { "method": ["GET"], "route": "/watch-history", diff --git a/deploy/requirements.txt b/deploy/requirements.txt index 5e65a30f..d68e8fb9 100644 --- a/deploy/requirements.txt +++ b/deploy/requirements.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with python 3.9 # To update, run: # -# pip-compile --generate-hashes --output-file=deploy/requirements.txt deploy/requirements.in +# pip-compile --allow-unsafe --generate-hashes --output-file=deploy/requirements.txt deploy/requirements.in # attrs==21.2.0 \ --hash=sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1 \ diff --git a/src/lambdas/api/episode_by_collection_item/__init__.py b/src/lambdas/api/episode_by_collection_item/__init__.py index 3e27c8eb..45905b42 100644 --- a/src/lambdas/api/episode_by_collection_item/__init__.py +++ b/src/lambdas/api/episode_by_collection_item/__init__.py @@ -10,7 +10,6 @@ import jwt_utils import schema import episodes_db -import shows_api import watch_history_db log = logger.get_logger("episode_by_collection_item") @@ -65,7 +64,8 @@ def _get_episode_by_api_id(collection_name, item_id, api_name, api_id, s_ret = anime_api.get_episode_by_api_id(item_id, api_name, api_id, token) elif collection_name == "show": - s_ret = shows_api.get_episode_by_api_id(api_name, api_id) + raise utils.HttpError("", 501) + # s_ret = shows_api.get_episode_by_api_id(api_name, api_id) except utils.HttpError as e: err_msg = f"Could not get {collection_name} episode" log.error(f"{err_msg}. Error: {str(e)}") @@ -139,7 +139,8 @@ def _post_episode(username, collection_name, item_id, body, token): if collection_name == "anime": res = anime_api.post_episode(item_id, api_body, token) elif collection_name == "show": - res = shows_api.post_episode(item_id, api_body) + raise utils.HttpError("", 501) + # res = shows_api.post_episode(item_id, api_body) except utils.HttpError as e: err_msg = f"Could not post {collection_name}" log.error(f"{err_msg}. Error: {str(e)}") @@ -161,7 +162,7 @@ def _post_episode(username, collection_name, item_id, body, token): special=res["is_special"] ) - if "dates_watched" not in body: + if body.get("dates_watched") is None: return { "statusCode": 200, "body": json.dumps({"id": episode_id}) diff --git a/src/lambdas/api/episode_by_id/__init__.py b/src/lambdas/api/episode_by_id/__init__.py index 84644dba..672428ff 100644 --- a/src/lambdas/api/episode_by_id/__init__.py +++ b/src/lambdas/api/episode_by_id/__init__.py @@ -11,7 +11,6 @@ import schema import episodes_db import watch_history_db -import shows_api log = logger.get_logger("episodes_by_id") @@ -56,7 +55,8 @@ def _get_episode(username, collection_name, item_id, episode_id, token, api_name if collection_name == "anime": s_ret = anime_api.get_episode(item_id, episode_id, token) elif collection_name == "show": - s_ret = shows_api.get_episode(item_id, episode_id, api_name=api_name) + raise utils.HttpError("", 501) + # s_ret = shows_api.get_episode(item_id, episode_id, api_name=api_name) except utils.HttpError as e: err_msg = f"Could not get {collection_name} episode for " \ f"item: {item_id} and episode_id: {episode_id}" @@ -94,7 +94,8 @@ def _put_episode(username, collection_name, item_id, episode_id, body, token, ap if collection_name == "anime": anime_api.get_episode(item_id, episode_id, token) elif collection_name == "show": - shows_api.get_episode(item_id, episode_id, api_name=api_name) + raise utils.HttpError("", 501) + # shows_api.get_episode(item_id, episode_id, api_name=api_name) except utils.HttpError as e: err_msg = f"Could not get {collection_name} episode for " \ f"item: {item_id} and episode_id: {episode_id}" @@ -109,7 +110,7 @@ def _put_episode(username, collection_name, item_id, episode_id, body, token, ap body ) - if "dates_watched" not in body: + if body.get("dates_watched") is None: return {"statusCode": 204} # If episode watch date is changed check if its larger than current @@ -137,7 +138,8 @@ def _delete_episode(username, collection_name, item_id, episode_id, token, api_n if collection_name == "anime": res = anime_api.get_episode(item_id, episode_id, token) elif collection_name == "show": - res = shows_api.get_episode(item_id, episode_id, api_name=api_name) + raise utils.HttpError("", 501) + # res = shows_api.get_episode(item_id, episode_id, api_name=api_name) except utils.HttpError as e: err_msg = f"Could not get {collection_name} episode for " \ f"item: {item_id} and episode_id: {episode_id}" diff --git a/src/lambdas/api/item_by_collection/__init__.py b/src/lambdas/api/item_by_collection/__init__.py index d0fe0b6d..97b45563 100644 --- a/src/lambdas/api/item_by_collection/__init__.py +++ b/src/lambdas/api/item_by_collection/__init__.py @@ -9,7 +9,6 @@ import jwt_utils import movie_api import schema -import shows_api import watch_history_db log = logger.get_logger("watch_history") @@ -51,7 +50,8 @@ def _get_item(username, collection_name, item_id, token, show_api): if collection_name == "anime": s_ret = anime_api.get_anime(item_id, token) elif collection_name == "show": - s_ret = shows_api.get_show(item_id, show_api) + raise utils.HttpError("", 501) + #s_ret = shows_api.get_show(item_id, show_api) elif collection_name == "movie": s_ret = movie_api.get_movie(item_id, token) except utils.HttpError as e: @@ -92,7 +92,8 @@ def _put_item(username, collection_name, item_id, body, token, show_api): if collection_name == "anime": anime_api.get_anime(item_id, token) elif collection_name == "show": - shows_api.get_show(item_id, show_api) + raise utils.HttpError("", 501) + #shows_api.get_show(item_id, show_api) elif collection_name == "movie": movie_api.get_movie(item_id, token) except utils.HttpError as e: diff --git a/src/lambdas/api/watch_histories/__init__.py b/src/lambdas/api/watch_histories/__init__.py new file mode 100644 index 00000000..4c31de8c --- /dev/null +++ b/src/lambdas/api/watch_histories/__init__.py @@ -0,0 +1,109 @@ +from fastapi import FastAPI, Request +from mangum import Mangum + +import routes +import jwt_utils +from models import PostItem, PostEpisode, ReviewData, review_data_to_dict + +app = FastAPI() + + +@app.get("/watch-histories/items/{api_name}/{item_api_id}") +def get_item(request: Request, api_name: str, item_api_id: str): + return routes.get_item(request.state.username, api_name, item_api_id) + + +@app.delete("/watch-histories/items/{api_name}/{item_api_id}", status_code=204) +def delete_item(request: Request, api_name: str, item_api_id: str): + routes.delete_item(request.state.username, api_name, item_api_id) + + +@app.put("/watch-histories/items/{api_name}/{item_api_id}", status_code=204) +def update_item(request: Request, api_name: str, item_api_id: str, + data: ReviewData): + routes.update_item( + request.state.username, + api_name, + item_api_id, + review_data_to_dict(data), + ) + + +@app.post("/watch-histories/items", status_code=204) +def add_item(request: Request, item: PostItem): + routes.add_item( + request.state.username, + item.api_name, + item.item_api_id, + review_data_to_dict(item), + ) + + +@app.get("/watch-histories/items/{api_name}/{item_api_id}/episodes") +def get_episodes(request: Request, api_name: str, item_api_id: str): + return routes.get_episodes( + request.state.username, + api_name, + item_api_id, + ) + + +@app.post("/watch-histories/items/{api_name}/{item_api_id}/episodes", + status_code=204) +def add_episode(request: Request, api_name, item_api_id, episode: PostEpisode): + routes.add_episode( + request.state.username, + api_name, + item_api_id, + episode.episode_api_id, + review_data_to_dict(episode), + ) + + +@app.get( + "/watch-histories/items/{api_name}/{item_api_id}/episodes/{episode_api_id}") +def get_episode(request: Request, api_name: str, item_api_id: str, + episode_api_id: str): + return routes.get_episode( + request.state.username, + api_name, + item_api_id, + episode_api_id, + ) + + +@app.put( + "/watch-histories/items/{api_name}/{item_api_id}/episodes/{episode_api_id}", + status_code=204) +def update_episode(request: Request, api_name: str, item_api_id: str, + episode_api_id: str, data: ReviewData): + routes.update_episode( + request.state.username, + api_name, + item_api_id, + episode_api_id, + review_data_to_dict(data), + ) + + +@app.delete( + "/watch-histories/items/{api_name}/{item_api_id}/episodes/{episode_api_id}", + status_code=204) +def delete_episode(request: Request, api_name: str, item_api_id: str, + episode_api_id: str): + routes.delete_episode( + request.state.username, + api_name, + item_api_id, + episode_api_id, + ) + + +@app.middleware("http") +def parse_token(request: Request, call_next): + auth_header = request.headers.get("authorization") + request.state.username = jwt_utils.get_username(auth_header) + return call_next(request) + + +handle = Mangum(app, api_gateway_base_path="/prod") diff --git a/src/lambdas/api/watch_histories/models.py b/src/lambdas/api/watch_histories/models.py new file mode 100644 index 00000000..72e14a32 --- /dev/null +++ b/src/lambdas/api/watch_histories/models.py @@ -0,0 +1,50 @@ +from datetime import datetime +from enum import auto +from typing import Optional, List + +from fastapi_utils.enums import StrEnum + +from pydantic import BaseModel + + +class ApiName(StrEnum): + tmdb = auto() + tvmaze = auto() + anidb = auto() + + +class Status(StrEnum): + finished = auto() + following = auto() + watching = auto() + dropped = auto() + backlog = auto() + + +class ReviewData(BaseModel): + rating: Optional[int] + overview: Optional[str] + review: Optional[str] + dates_watched: Optional[List[datetime]] + status: Optional[Status] + + +class PostItem(ReviewData): + item_api_id: str + api_name: ApiName + + +class PostEpisode(ReviewData): + episode_api_id: str + + +def review_data_to_dict(data: ReviewData): + data = data.dict(exclude={"api_name", "item_api_id", "episode_api_id"}) + dates = data.get("dates_watched") + if dates: + parsed_dates = [] + for d in dates: + new_d = d.strftime("%Y-%m-%dT%H:%M:%S.%fZ").replace("000Z", "Z") + parsed_dates.append(new_d) + data["dates_watched"] = parsed_dates + return data diff --git a/src/lambdas/api/watch_histories/requirements.in b/src/lambdas/api/watch_histories/requirements.in new file mode 100644 index 00000000..0905d32f --- /dev/null +++ b/src/lambdas/api/watch_histories/requirements.in @@ -0,0 +1,4 @@ +fastapi==0.70.0 +fastapi-utils==0.2.1 +mangum==0.12.3 +pydantic==1.8.2 \ No newline at end of file diff --git a/src/lambdas/api/watch_histories/requirements.txt b/src/lambdas/api/watch_histories/requirements.txt new file mode 100644 index 00000000..75931485 --- /dev/null +++ b/src/lambdas/api/watch_histories/requirements.txt @@ -0,0 +1,160 @@ +# +# This file is autogenerated by pip-compile with python 3.9 +# To update, run: +# +# pip-compile --generate-hashes --output-file=src/lambdas/api/watch_histories/requirements.txt src/lambdas/api/watch_histories/requirements.in +# +anyio==3.3.4 \ + --hash=sha256:4fd09a25ab7fa01d34512b7249e366cd10358cdafc95022c7ff8c8f8a5026d66 \ + --hash=sha256:67da67b5b21f96b9d3d65daa6ea99f5d5282cb09f50eb4456f8fb51dffefc3ff + # via starlette +fastapi==0.70.0 \ + --hash=sha256:66da43cfe5185ea1df99552acffd201f1832c6b364e0f4136c0a99f933466ced \ + --hash=sha256:a36d5f2fad931aa3575c07a3472c784e81f3e664e3bb5c8b9c88d0ec1104f59c + # via + # -r src/lambdas/api/watch_histories/requirements.in + # fastapi-utils +fastapi-utils==0.2.1 \ + --hash=sha256:0e6c7fc1870b80e681494957abf65d4f4f42f4c7f70005918e9181b22f1bd759 \ + --hash=sha256:dd0be7dc7f03fa681b25487a206651d99f2330d5a567fb8ab6cb5f8a06a29360 + # via -r src/lambdas/api/watch_histories/requirements.in +greenlet==1.1.2 \ + --hash=sha256:00e44c8afdbe5467e4f7b5851be223be68adb4272f44696ee71fe46b7036a711 \ + --hash=sha256:013d61294b6cd8fe3242932c1c5e36e5d1db2c8afb58606c5a67efce62c1f5fd \ + --hash=sha256:049fe7579230e44daef03a259faa24511d10ebfa44f69411d99e6a184fe68073 \ + --hash=sha256:14d4f3cd4e8b524ae9b8aa567858beed70c392fdec26dbdb0a8a418392e71708 \ + --hash=sha256:166eac03e48784a6a6e0e5f041cfebb1ab400b394db188c48b3a84737f505b67 \ + --hash=sha256:17ff94e7a83aa8671a25bf5b59326ec26da379ace2ebc4411d690d80a7fbcf23 \ + --hash=sha256:1e12bdc622676ce47ae9abbf455c189e442afdde8818d9da983085df6312e7a1 \ + --hash=sha256:21915eb821a6b3d9d8eefdaf57d6c345b970ad722f856cd71739493ce003ad08 \ + --hash=sha256:288c6a76705dc54fba69fbcb59904ae4ad768b4c768839b8ca5fdadec6dd8cfd \ + --hash=sha256:32ca72bbc673adbcfecb935bb3fb1b74e663d10a4b241aaa2f5a75fe1d1f90aa \ + --hash=sha256:356b3576ad078c89a6107caa9c50cc14e98e3a6c4874a37c3e0273e4baf33de8 \ + --hash=sha256:40b951f601af999a8bf2ce8c71e8aaa4e8c6f78ff8afae7b808aae2dc50d4c40 \ + --hash=sha256:572e1787d1460da79590bf44304abbc0a2da944ea64ec549188fa84d89bba7ab \ + --hash=sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6 \ + --hash=sha256:64e6175c2e53195278d7388c454e0b30997573f3f4bd63697f88d855f7a6a1fc \ + --hash=sha256:7227b47e73dedaa513cdebb98469705ef0d66eb5a1250144468e9c3097d6b59b \ + --hash=sha256:7418b6bfc7fe3331541b84bb2141c9baf1ec7132a7ecd9f375912eca810e714e \ + --hash=sha256:7cbd7574ce8e138bda9df4efc6bf2ab8572c9aff640d8ecfece1b006b68da963 \ + --hash=sha256:7ff61ff178250f9bb3cd89752df0f1dd0e27316a8bd1465351652b1b4a4cdfd3 \ + --hash=sha256:833e1551925ed51e6b44c800e71e77dacd7e49181fdc9ac9a0bf3714d515785d \ + --hash=sha256:8639cadfda96737427330a094476d4c7a56ac03de7265622fcf4cfe57c8ae18d \ + --hash=sha256:8c790abda465726cfb8bb08bd4ca9a5d0a7bd77c7ac1ca1b839ad823b948ea28 \ + --hash=sha256:8d2f1fb53a421b410751887eb4ff21386d119ef9cde3797bf5e7ed49fb51a3b3 \ + --hash=sha256:903bbd302a2378f984aef528f76d4c9b1748f318fe1294961c072bdc7f2ffa3e \ + --hash=sha256:93f81b134a165cc17123626ab8da2e30c0455441d4ab5576eed73a64c025b25c \ + --hash=sha256:95e69877983ea39b7303570fa6760f81a3eec23d0e3ab2021b7144b94d06202d \ + --hash=sha256:9633b3034d3d901f0a46b7939f8c4d64427dfba6bbc5a36b1a67364cf148a1b0 \ + --hash=sha256:97e5306482182170ade15c4b0d8386ded995a07d7cc2ca8f27958d34d6736497 \ + --hash=sha256:9f3cba480d3deb69f6ee2c1825060177a22c7826431458c697df88e6aeb3caee \ + --hash=sha256:aa5b467f15e78b82257319aebc78dd2915e4c1436c3c0d1ad6f53e47ba6e2713 \ + --hash=sha256:abb7a75ed8b968f3061327c433a0fbd17b729947b400747c334a9c29a9af6c58 \ + --hash=sha256:aec52725173bd3a7b56fe91bc56eccb26fbdff1386ef123abb63c84c5b43b63a \ + --hash=sha256:b11548073a2213d950c3f671aa88e6f83cda6e2fb97a8b6317b1b5b33d850e06 \ + --hash=sha256:b1692f7d6bc45e3200844be0dba153612103db241691088626a33ff1f24a0d88 \ + --hash=sha256:b92e29e58bef6d9cfd340c72b04d74c4b4e9f70c9fa7c78b674d1fec18896dc4 \ + --hash=sha256:be5f425ff1f5f4b3c1e33ad64ab994eed12fc284a6ea71c5243fd564502ecbe5 \ + --hash=sha256:dd0b1e9e891f69e7675ba5c92e28b90eaa045f6ab134ffe70b52e948aa175b3c \ + --hash=sha256:e30f5ea4ae2346e62cedde8794a56858a67b878dd79f7df76a0767e356b1744a \ + --hash=sha256:e6a36bb9474218c7a5b27ae476035497a6990e21d04c279884eb10d9b290f1b1 \ + --hash=sha256:e859fcb4cbe93504ea18008d1df98dee4f7766db66c435e4882ab35cf70cac43 \ + --hash=sha256:eb6ea6da4c787111adf40f697b4e58732ee0942b5d3bd8f435277643329ba627 \ + --hash=sha256:ec8c433b3ab0419100bd45b47c9c8551248a5aee30ca5e9d399a0b57ac04651b \ + --hash=sha256:eff9d20417ff9dcb0d25e2defc2574d10b491bf2e693b4e491914738b7908168 \ + --hash=sha256:f0214eb2a23b85528310dad848ad2ac58e735612929c8072f6093f3585fd342d \ + --hash=sha256:f276df9830dba7a333544bd41070e8175762a7ac20350786b322b714b0e654f5 \ + --hash=sha256:f3acda1924472472ddd60c29e5b9db0cec629fbe3c5c5accb74d6d6d14773478 \ + --hash=sha256:f70a9e237bb792c7cc7e44c531fd48f5897961701cdaa06cf22fc14965c496cf \ + --hash=sha256:f9d29ca8a77117315101425ec7ec2a47a22ccf59f5593378fc4077ac5b754fce \ + --hash=sha256:fa877ca7f6b48054f847b61d6fa7bed5cebb663ebc55e018fda12db09dcc664c \ + --hash=sha256:fdcec0b8399108577ec290f55551d926d9a1fa6cad45882093a7a07ac5ec147b + # via sqlalchemy +idna==3.3 \ + --hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \ + --hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d + # via anyio +mangum==0.12.3 \ + --hash=sha256:128a8c1aa2eef13fc94ac5344210df8bd71dfba584b658bec9473e784bd86301 \ + --hash=sha256:a45cc1c57736e7044318e65216eea83a397cb709e39fe6810a46faf0e15d2664 + # via -r src/lambdas/api/watch_histories/requirements.in +pydantic==1.8.2 \ + --hash=sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd \ + --hash=sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739 \ + --hash=sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f \ + --hash=sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840 \ + --hash=sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23 \ + --hash=sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287 \ + --hash=sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62 \ + --hash=sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b \ + --hash=sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb \ + --hash=sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820 \ + --hash=sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3 \ + --hash=sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b \ + --hash=sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e \ + --hash=sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3 \ + --hash=sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316 \ + --hash=sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b \ + --hash=sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4 \ + --hash=sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20 \ + --hash=sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e \ + --hash=sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505 \ + --hash=sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1 \ + --hash=sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833 + # via + # -r src/lambdas/api/watch_histories/requirements.in + # fastapi + # fastapi-utils +sniffio==1.2.0 \ + --hash=sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663 \ + --hash=sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de + # via anyio +sqlalchemy==1.4.26 \ + --hash=sha256:07ac4461a1116b317519ddf6f34bcb00b011b5c1370ebeaaf56595504ffc7e84 \ + --hash=sha256:090536fd23bf49077ee94ff97142bc5ee8bad24294c3d7c8d5284267c885dde7 \ + --hash=sha256:1dee515578d04bc80c4f9a8c8cfe93f455db725059e885f1b1da174d91c4d077 \ + --hash=sha256:1ef37c9ec2015ce2f0dc1084514e197f2f199d3dc3514190db7620b78e6004c8 \ + --hash=sha256:295b90efef1278f27fe27d94a45460ae3c17f5c5c2b32c163e29c359740a1599 \ + --hash=sha256:2ce42ad1f59eb85c55c44fb505f8854081ee23748f76b62a7f569cfa9b6d0604 \ + --hash=sha256:2feb028dc75e13ba93456a42ac042b255bf94dbd692bf80b47b22653bb25ccf8 \ + --hash=sha256:31f4426cfad19b5a50d07153146b2bcb372a279975d5fa39f98883c0ef0f3313 \ + --hash=sha256:3c0c5f54560a92691d54b0768d67b4d3159e514b426cfcb1258af8c195577e8f \ + --hash=sha256:463ef692259ff8189be42223e433542347ae17e33f91c1013e9c5c64e2798088 \ + --hash=sha256:4a882dedb9dfa6f33524953c3e3d72bcf518a5defd6d5863150a821928b19ad3 \ + --hash=sha256:4c185c928e2638af9bae13acc3f70e0096eac76471a1101a10f96b80666b8270 \ + --hash=sha256:5039faa365e7522a8eb4736a54afd24a7e75dcc33b81ab2f0e6c456140f1ad64 \ + --hash=sha256:5c6774b34782116ad9bdec61c2dbce9faaca4b166a0bc8e7b03c2b870b121d94 \ + --hash=sha256:6bc7f9d7d90ef55e8c6db1308a8619cd8f40e24a34f759119b95e7284dca351a \ + --hash=sha256:7e8ef103eaa72a857746fd57dda5b8b5961e8e82a528a3f8b7e2884d8506f0b7 \ + --hash=sha256:7ef421c3887b39c6f352e5022a53ac18de8387de331130481cb956b2d029cad6 \ + --hash=sha256:908fad32c53b17aad12d722379150c3c5317c422437e44032256a77df1746292 \ + --hash=sha256:91efbda4e6d311812f23996242bad7665c1392209554f8a31ec6db757456db5c \ + --hash=sha256:a6506c17b0b6016656783232d0bdd03fd333f1f654d51a14d93223f953903646 \ + --hash=sha256:a95bf9c725012dcd7ea3cac16bf647054e0d62b31d67467d228338e6a163e4ff \ + --hash=sha256:ad7e403fc1e3cb76e802872694e30d6ca6129b9bc6ad4e7caa48ca35f8a144f8 \ + --hash=sha256:b86f762cee3709722ab4691981958cbec475ea43406a6916a7ec375db9cbd9e9 \ + --hash=sha256:ba84026e84379326bbf2f0c50792f2ae56ab9c01937df5597b6893810b8ca369 \ + --hash=sha256:bca660b76672e15d70a7dba5e703e1ce451a0257b6bd2028e62b0487885e8ae9 \ + --hash=sha256:c24c01dcd03426a5fe5ee7af735906bec6084977b9027a3605d11d949a565c01 \ + --hash=sha256:c2f2114b0968a280f94deeeaa31cfbac9175e6ac7bd3058b3ce6e054ecd762b3 \ + --hash=sha256:c46f013ff31b80cbe36410281675e1fb4eaf3e25c284fd8a69981c73f6fa4cb4 \ + --hash=sha256:c757ba1279b85b3460e72e8b92239dae6f8b060a75fb24b3d9be984dd78cfa55 \ + --hash=sha256:cc6b21f19bc9d4cd77cbcba5f3b260436ce033f1053cea225b6efea2603d201e \ + --hash=sha256:dbf588ab09e522ac2cbd010919a592c6aae2f15ccc3cd9a96d01c42fbc13f63e \ + --hash=sha256:de996756d894a2d52c132742e3b6d64ecd37e0919ddadf4dc3981818777c7e67 \ + --hash=sha256:e700d48056475d077f867e6a36e58546de71bdb6fdc3d34b879e3240827fefab \ + --hash=sha256:f1e97c5f36b94542f72917b62f3a2f92be914b2cf33b80fa69cede7529241d2a \ + --hash=sha256:fb2aa74a6e3c2cebea38dd21633671841fbe70ea486053cba33d68e3e22ccc0a \ + --hash=sha256:ff8f91a7b1c4a1c7772caa9efe640f2768828897044748f2458b708f1026e2d4 + # via fastapi-utils +starlette==0.16.0 \ + --hash=sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f \ + --hash=sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870 + # via fastapi +typing-extensions==3.10.0.2 \ + --hash=sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e \ + --hash=sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7 \ + --hash=sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34 + # via + # mangum + # pydantic diff --git a/src/lambdas/api/watch_histories/routes.py b/src/lambdas/api/watch_histories/routes.py new file mode 100644 index 00000000..e427a328 --- /dev/null +++ b/src/lambdas/api/watch_histories/routes.py @@ -0,0 +1,243 @@ +import episodes_db +from fastapi import HTTPException +import dateutil.parser + +import logger +import tvmaze +import watch_history_db + +tvmaze_api = tvmaze.TvMazeApi() +log = logger.get_logger(__name__) + + +def get_item(username, api_name, api_id): + # try: + # if api_name == "tvmaze": + # tvmaze_api.get_show(api_id) + # else: + # raise HTTPException(status_code=501) + # except tvmaze.HTTPError as e: + # err_msg = f"Could not get item from {api_name} api with id: {api_id}" + # log.error(f"{err_msg}. Error: {str(e)}") + # raise HTTPException(status_code=e.code) + + try: + w_ret = watch_history_db.get_item_by_api_id( + username, + api_name, + api_id, + ) + return w_ret + except watch_history_db.NotFoundError: + raise HTTPException(status_code=404) + + +def add_item(username, api_name, api_id, data): + try: + if api_name == "tvmaze": + ep_count_res = tvmaze_api.get_show_episodes_count(api_id) + else: + raise HTTPException(status_code=501) + except tvmaze.HTTPError as e: + err_msg = f"Could not get show episodes in add_item func" \ + f" from {api_name} api with id: {api_id}" + log.error(f"{err_msg}. Error: {str(e)}") + raise HTTPException(status_code=e.code) + + try: + current_item = watch_history_db.get_item_by_api_id( + username, + api_name, + api_id, + include_deleted=True, + ) + except watch_history_db.NotFoundError: + current_item = {} + + if ep_count_res is not None: + data["ep_count"] = ep_count_res.get("ep_count", 0) + data["special_count"] = ep_count_res.get("special_count", 0) + data["ep_progress"] = current_item.get("ep_progress", 0) + data["special_progress"] = current_item.get("special_progress", 0) + data["watched_eps"] = current_item.get("watched_eps", 0) + data["watched_special"] = current_item.get("watched_special", 0) + + watch_history_db.add_item_v2( + username, + api_name, + api_id, + data + ) + + +def update_item(username, api_name, api_id, data): + all_none = True + for v in data.values(): + if v is not None: + all_none = False + if all_none: + raise HTTPException( + status_code=400, + detail="Please specify at least one of the optional fields" + ) + + watch_history_db.update_item_v2( + username, + api_name, + api_id, + data + ) + + +def delete_item(username, api_name, api_id): + watch_history_db.delete_item_v2(username, api_name, api_id) + + +def get_episodes(username, api_name, api_id): + return episodes_db.get_episodes( + username, + api_name, + api_id, + ) + + +def get_episode(username, api_name, item_api_id, episode_api_id): + try: + w_ret = episodes_db.get_episode_by_api_id( + username, + api_name, + item_api_id, + episode_api_id, + ) + return w_ret + except episodes_db.NotFoundError: + raise HTTPException(status_code=404) + + +def add_episode(username, api_name, item_api_id, episode_api_id, data): + try: + if api_name == "tvmaze": + api_res = tvmaze_api.get_episode(episode_api_id) + is_special = api_res["type"] != "regular" + else: + raise HTTPException(status_code=501) + except tvmaze.HTTPError as e: + err_msg = f"Could not get show episode in add_episode func" \ + f" from {api_name} api with id: {episode_api_id}" + log.error(f"{err_msg}. Error: {str(e)}") + raise HTTPException(status_code=e.code) + + try: + item = watch_history_db.get_item_by_api_id( + username, + api_name, + item_api_id, + ) + except watch_history_db.NotFoundError: + err_msg = f"Item with api_id: {item_api_id} not found. " \ + f"Please add it to the watch-history before posting episode" + raise HTTPException(status_code=404, detail=err_msg) + + episodes_db.add_episode_v2( + username, + api_name, + item_api_id, + episode_api_id, + data + ) + + watch_history_db.change_watched_eps_v2( + username, + api_name, + item_api_id, + 1, + special=is_special + ) + + if not data.get("dates_watched"): + return + + _update_latest_watch_date(item, data, username, api_name, item_api_id) + + +def update_episode(username, api_name, item_api_id, episode_api_id, data): + try: + if api_name == "tvmaze": + tvmaze_api.get_episode(episode_api_id) + else: + raise HTTPException(status_code=501) + except tvmaze.HTTPError as e: + err_msg = f"Could not get show episode in add_episode func" \ + f" from {api_name} api with id: {episode_api_id}" + log.error(f"{err_msg}. Error: {str(e)}") + raise HTTPException(status_code=e.code) + + try: + item = watch_history_db.get_item_by_api_id( + username, + api_name, + item_api_id, + ) + except watch_history_db.NotFoundError: + err_msg = f"Item with api_id: {item_api_id} not found. " \ + f"Please add it to the watch-history before posting episode" + raise HTTPException(status_code=404, detail=err_msg) + + episodes_db.update_episode_v2( + username, + api_name, + item_api_id, + episode_api_id, + data + ) + + if not data.get("dates_watched"): + return + + _update_latest_watch_date(item, data, username, api_name, item_api_id) + + +def _update_latest_watch_date(item, data, username, api_name, item_api_id): + # If episode watch date is changed check if its larger than current + # item latest date and update item if that's the case + ep_date = max([dateutil.parser.parse(d) for d in data["dates_watched"]]) + + if (item["latest_watch_date"] == "0" or + ep_date > dateutil.parser.parse(item["latest_watch_date"])): + ep_date = ep_date.strftime("%Y-%m-%dT%H:%M:%S.%fZ").replace("000Z", "Z") + watch_history_db.update_item_v2( + username, + api_name, + item_api_id, + {"latest_watch_date": f"{ep_date}"}, + clean_whitelist=[], + ) + + +def delete_episode(username, api_name, item_api_id, episode_api_id): + try: + if api_name == "tvmaze": + api_res = tvmaze_api.get_episode(episode_api_id) + is_special = api_res["type"] != "regular" + else: + raise HTTPException(status_code=501) + except tvmaze.HTTPError as e: + err_msg = f"Could not get show episode in add_episode func" \ + f" from {api_name} api with id: {episode_api_id}" + log.error(f"{err_msg}. Error: {str(e)}") + raise HTTPException(status_code=e.code) + + episodes_db.delete_episode_v2( + username, + api_name, + item_api_id, + episode_api_id, + ) + + watch_history_db.change_watched_eps_v2( + username, + api_name, + item_api_id, + -1, + special=is_special + ) diff --git a/src/lambdas/api/watch_history_by_collection/__init__.py b/src/lambdas/api/watch_history_by_collection/__init__.py index e8cadd11..8e5e72ff 100644 --- a/src/lambdas/api/watch_history_by_collection/__init__.py +++ b/src/lambdas/api/watch_history_by_collection/__init__.py @@ -8,7 +8,6 @@ import jwt_utils import movie_api import schema -import shows_api import watch_history_db import anime_api @@ -60,7 +59,8 @@ def _get_by_api_id(collection_name, api_name, api_id, username, token): if collection_name == "anime": s_ret = anime_api.get_anime_by_api_id(api_name, api_id, token) elif collection_name == "show": - s_ret = shows_api.get_show_by_api_id(api_name, api_id) + raise utils.HttpError("", 501) + # s_ret = shows_api.get_show_by_api_id(api_name, api_id) elif collection_name == "movie": s_ret = movie_api.get_movie_by_api_id(api_name, api_id, token) except utils.HttpError as e: @@ -147,7 +147,8 @@ def _post_collection_item(username, collection_name, body, token): if collection_name == "anime": res = anime_api.post_anime(api_body, token) elif collection_name == "show": - res = shows_api.post_show(api_body) + raise utils.HttpError("", 501) + # res = shows_api.post_show(api_body) elif collection_name == "movie": res = movie_api.post_movie(api_body, token) except utils.HttpError as e: diff --git a/src/lambdas/cron/show_updates/__init__.py b/src/lambdas/cron/show_updates/__init__.py new file mode 100644 index 00000000..e1d2dcae --- /dev/null +++ b/src/lambdas/cron/show_updates/__init__.py @@ -0,0 +1,19 @@ +import updates +import watch_history_db + +from tvmaze import TvMazeApi, HTTPError + + +def handle(event, context): + tvmaze_api = TvMazeApi() + tvmaze_updates = tvmaze_api.get_day_updates() + + for tvmaze_id in tvmaze_updates: + try: + watch_history_db.get_items_by_api_id("tvmaze", tvmaze_id) + except HTTPError: + # Show not present in db, exclude it from updates + continue + + # Post to SNS topic + updates.publish_show_update("tvmaze", tvmaze_id) diff --git a/src/lambdas/subscribers/show_updates/__init__.py b/src/lambdas/subscribers/show_updates/__init__.py index 5a371dc4..f4918276 100644 --- a/src/lambdas/subscribers/show_updates/__init__.py +++ b/src/lambdas/subscribers/show_updates/__init__.py @@ -1,18 +1,20 @@ import json -import shows_api +import tvmaze import watch_history_db +tvmaze_api = tvmaze.TvMazeApi() + def handle(event, context): message = json.loads(event["Records"][0]["Sns"]["Message"]) - show = shows_api.get_show_by_api_id( + items = watch_history_db.get_items_by_api_id( message["api_name"], - message["api_id"], + message["api_id"] ) + tvmaze_item = tvmaze_api.get_show_episodes_count(message["api_id"]) - items = watch_history_db.get_items_by_id(show["id"]) for item in items: print(f"Updating item: {item}") @@ -21,14 +23,14 @@ def handle(event, context): if "watched_specials" not in item: item["watched_specials"] = 0 - item["ep_count"] = show["ep_count"] + item["ep_count"] = tvmaze_item["ep_count"] if item["ep_count"] == 0: ep_progress = 0 else: ep_progress = item["watched_eps"] / item["ep_count"] item["ep_progress"] = round(ep_progress * 100, 2) - item["special_count"] = show["special_count"] + item["special_count"] = tvmaze_item["special_count"] if item["special_count"] == 0: special_progress = 0 else: diff --git a/src/layers/api/python/shows_api.py b/src/layers/api/python/shows_api.py deleted file mode 100644 index 48f7e671..00000000 --- a/src/layers/api/python/shows_api.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -import requests - -import utils - -SHOWS_API_URL = os.getenv("SHOWS_API_URL") - - -def get_show(show_id, api_name=None): - url = f"{SHOWS_API_URL}/shows/{show_id}" - if api_name is not None: - url += f"?api_name={api_name}" - - res = requests.get( - url, auth=utils.get_v4_signature_auth() - ) - if res.status_code != 200: - raise utils.HttpError("Invalid response in get_show", res.status_code) - - return res.json() - - -def get_show_by_api_id(api_name, api_id): - res = requests.get( - f"{SHOWS_API_URL}/shows?api_name={api_name}&api_id={api_id}", - auth=utils.get_v4_signature_auth(), - ) - if res.status_code != 200: - raise utils.HttpError( - "Invalid response in get_shows_by_api_id", res.status_code - ) - - return res.json() - - -def post_show(body): - res = requests.post( - f"{SHOWS_API_URL}/shows", json=body, auth=utils.get_v4_signature_auth() - ) - if res.status_code != 200: - raise utils.HttpError( - "Invalid response during show post", res.status_code - ) - - return res.json() - - -def get_episode(item_id, episode_id, api_name=None): - url = f"{SHOWS_API_URL}/shows/{item_id}/episodes/{episode_id}" - if api_name is not None: - url += f"?api_name={api_name}" - - res = requests.get( - url, - auth=utils.get_v4_signature_auth(), - ) - if res.status_code != 200: - raise utils.HttpError( - "Invalid response during show post", res.status_code - ) - - return res.json() - - -def get_episode_by_api_id(api_name, api_id): - res = requests.get( - f"{SHOWS_API_URL}/episodes?api_name={api_name}&api_id={api_id}", - auth=utils.get_v4_signature_auth(), - ) - if res.status_code != 200: - raise utils.HttpError( - "Invalid response in get_shows_by_api_id", res.status_code - ) - - return res.json() - - -def post_episode(item_id, body): - res = requests.post( - f"{SHOWS_API_URL}/shows/{item_id}/episodes", - json=body, - auth=utils.get_v4_signature_auth(), - ) - if res.status_code != 200: - raise utils.HttpError( - "Invalid response during show post", res.status_code - ) - - return res.json() diff --git a/src/layers/api/python/tvmaze.py b/src/layers/api/python/tvmaze.py new file mode 100644 index 00000000..0888da39 --- /dev/null +++ b/src/layers/api/python/tvmaze.py @@ -0,0 +1,69 @@ +import requests + +import logger + +log = logger.get_logger(__name__) + + +class Error(Exception): + pass + + +class HTTPError(Error): + + def __init__(self, code): + Error.__init__(self, f"Unexpected status code: {code}") + self.code = code + + +class TvMazeApi: + def __init__(self): + self.base_url = "https://api.tvmaze.com" + + log.debug("TvMazeApi base_url: {}".format(self.base_url)) + + def get_show(self, show_id): + res = requests.get(f"{self.base_url}/shows/{show_id}") + + if res.status_code != 200: + raise HTTPError(res.status_code) + return res.json() + + def get_episode(self, episode_id): + res = requests.get(f"{self.base_url}/episodes/{episode_id}") + + if res.status_code != 200: + raise HTTPError(res.status_code) + return res.json() + + def get_day_updates(self): + res = requests.get(f"{self.base_url}/updates/shows?since=day") + if res.status_code != 200: + raise HTTPError(res.status_code) + return res.json() + + def get_show_episodes(self, show_id): + res = requests.get( + f"{self.base_url}/shows/{show_id}/episodes?specials=1" + ) + + if res.status_code != 200: + raise HTTPError(res.status_code) + return res.json() + + def get_show_episodes_count(self, show_id): + episodes = self.get_show_episodes(show_id) + + ep_count = 0 + special_count = 0 + + for e in episodes: + if e["type"] == "regular": + ep_count += 1 + else: + special_count += 1 + + return { + "ep_count": ep_count, + "special_count": special_count, + } diff --git a/src/layers/api/python/updates.py b/src/layers/api/python/updates.py new file mode 100644 index 00000000..9bbd698f --- /dev/null +++ b/src/layers/api/python/updates.py @@ -0,0 +1,27 @@ +import json +import os + +import boto3 + +TOPIC_ARN = os.getenv("UPDATES_TOPIC_ARN") + +topic = None + + +def _get_topic(): + global topic + + if topic is None: + sns = boto3.resource("sns") + topic = sns.Topic(TOPIC_ARN) + + return topic + + +def publish_show_update(api_name, api_id): + _get_topic().publish( + Message=json.dumps({ + "api_name": api_name, + "api_id": api_id, + }) + ) diff --git a/src/layers/api/python/utils.py b/src/layers/api/python/utils.py index 214454ec..0f4f7bdb 100644 --- a/src/layers/api/python/utils.py +++ b/src/layers/api/python/utils.py @@ -4,9 +4,13 @@ from requests_aws4auth import AWS4Auth from threading import Lock, Thread +import tvmaze + items_lock = Lock() merged_items = [] +tvmaze_api = tvmaze.TvMazeApi() + class Error(Exception): pass @@ -46,13 +50,15 @@ def __init__(self, item, token, remove_status, show_api=None): def run(self): import anime_api import movie_api - import shows_api s_ret = None if self.collection_name == "movie": s_ret = movie_api.get_movie(self.item_id, self.token) if self.collection_name == "show": - s_ret = shows_api.get_show(self.item_id, self.show_api) + tvmaze_id = self.item["api_info"].split("_")[1] + s_ret = { + "tvmaze": tvmaze_api.get_show(tvmaze_id) + } elif self.collection_name == "anime": s_ret = anime_api.get_anime(self.item_id, self.token) diff --git a/src/layers/databases/python/episodes_db.py b/src/layers/databases/python/episodes_db.py index 91f9bfa0..02ce0d45 100644 --- a/src/layers/databases/python/episodes_db.py +++ b/src/layers/databases/python/episodes_db.py @@ -1,5 +1,6 @@ import os import time +import uuid from datetime import datetime import boto3 @@ -8,6 +9,7 @@ from dynamodb_json import json_util import logger +import watch_history_db DATABASE_NAME = os.getenv("EPISODES_DATABASE_NAME") OPTIONAL_FIELDS = [ @@ -60,7 +62,8 @@ def add_episode(username, collection_name, item_id, episode_id, data=None): except NotFoundError: data["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - update_episode(username, collection_name, episode_id, data, clean_whitelist=["deleted_at"]) + update_episode(username, collection_name, episode_id, data, + clean_whitelist=["deleted_at"]) def delete_episode(username, collection_name, episode_id): @@ -69,6 +72,18 @@ def delete_episode(username, collection_name, episode_id): clean_whitelist=[]) +def delete_episode_v2(username, api_name, item_api_id, episode_api_id): + # create legacy episode properties + collection_name, item_id = watch_history_db.get_collection_and_item_id( + api_name, + item_api_id, + ) + episode_id = get_episode_uuid(item_id, episode_api_id) + # --------------- + + delete_episode(username, collection_name, episode_id) + + def get_episode(username, collection_name, episode_id, include_deleted=False): filter_exp = Attr("collection_name").eq(collection_name) if not include_deleted: @@ -87,18 +102,48 @@ def get_episode(username, collection_name, episode_id, include_deleted=False): return res["Items"][0] +def get_episode_by_api_id(username, api_name, api_id, episode_api_id, + include_deleted=False): + api_info = f"{api_name}_{api_id}_{episode_api_id}" + + kwargs = { + "IndexName": "api_info", + "KeyConditionExpression": Key("username").eq(username) & + Key("api_info").eq(api_info) + } + if not include_deleted: + kwargs["FilterExpression"] = Attr("deleted_at").not_exists() + + res = _get_table().query(**kwargs) + + if not res["Items"]: + raise NotFoundError( + f"Episode with api id: {api_id} not found. Api name: {api_name}") + + return res["Items"][0] + + def update_episode(username, collection_name, episode_id, data, clean_whitelist=OPTIONAL_FIELDS): data["collection_name"] = collection_name data["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if "dates_watched" in data: + if data.get("dates_watched"): data["latest_watch_date"] = max(data["dates_watched"]) - items = ','.join(f'#{k}=:{k}' for k in data) - update_expression = f"SET {items}" - expression_attribute_names = {f'#{k}': k for k in data} - expression_attribute_values = {f':{k}': v for k, v in data.items()} + update_expression = "SET " + expression_attribute_names = {} + expression_attribute_values = {} + for k, v in data.items(): + if v is None: + continue + + update_expression += f"#{k}=:{k}," + expression_attribute_names[f"#{k}"] = k + expression_attribute_values[f":{k}"] = v + + # remove last comma + update_expression = update_expression[:-1] remove_names = [] for o in OPTIONAL_FIELDS: @@ -108,7 +153,7 @@ def update_episode(username, collection_name, episode_id, data, if len(remove_names) > 0: update_expression += f" REMOVE {','.join(remove_names)}" - log.debug("Running update_item") + log.debug("Running update_episode") log.debug(f"Update expression: {update_expression}") log.debug(f"Expression attribute names: {expression_attribute_names}") log.debug(f"Expression attribute values: {expression_attribute_values}") @@ -125,60 +170,86 @@ def update_episode(username, collection_name, episode_id, data, ) -def get_episodes(username, collection_name, item_id, limit=100, start=1): - start_page = 0 - res = [] - - if start <= 0: - raise InvalidStartOffset - - total_pages = 0 - for p in _episodes_generator(username, collection_name, item_id, - limit=limit): - total_pages += 1 - start_page += 1 - if start_page == start: - res = p - - if start > start_page: - raise InvalidStartOffset - - log.debug(f"get_episodes response: {res}") +def update_episode_v2(username, api_name, item_api_id, episode_api_id, data): + # create legacy episode properties + collection_name, item_id = watch_history_db.get_collection_and_item_id( + api_name, + item_api_id, + ) + episode_id = get_episode_uuid(item_id, episode_api_id) + # --------------- + + update_episode( + username, + collection_name, + episode_id, + data, + ) - if not res: - raise NotFoundError( - f"episodes for client with username: {username} and collection: {collection_name} not found") - return { - "episodes": res, - "total_pages": total_pages - } +def get_episodes(username, api_name, item_api_id): + item_api_info = f"{api_name}_{item_api_id}" - -def _episodes_generator(username, collection_name, item_id, limit): paginator = _get_client().get_paginator('query') query_kwargs = { "TableName": DATABASE_NAME, + "IndexName": "api_info", "KeyConditionExpression": "username = :username", "ExpressionAttributeValues": { ":username": {"S": username}, - ":item_id": {"S": item_id}, - ":collection_name": {"S": collection_name}, + ":api_info": {"S": item_api_info}, }, - "Limit": limit, "ScanIndexForward": False, - "FilterExpression": "attribute_not_exists(deleted_at) and item_id = :item_id and collection_name = :collection_name", + "FilterExpression": "attribute_not_exists(deleted_at) and begins_with(api_info, :api_info)", } log.debug(f"Query kwargs: {query_kwargs}") page_iterator = paginator.paginate(**query_kwargs) + res = [] for p in page_iterator: - items = {} for i in p["Items"]: - item = json_util.loads(i) - episode_id = item.pop("id") - items[episode_id] = item - yield items + i = json_util.loads(i) + res.append(i) + return res + + +def add_episode_v2(username, api_name, item_api_id, episode_api_id, data=None): + if data is None: + data = {} + data["api_info"] = f"{api_name}_{item_api_id}_{episode_api_id}" + + if data.get("dates_watched"): + data["latest_watch_date"] = "0" + try: + get_episode_by_api_id( + username, + api_name, + item_api_id, + episode_api_id, + include_deleted=True, + ) + except NotFoundError: + data["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # create legacy episode properties + collection_name, item_id = watch_history_db.get_collection_and_item_id( + api_name, + item_api_id, + ) + episode_id = get_episode_uuid(item_id, episode_api_id) + # --------------- + + update_episode( + username, + collection_name, + episode_id, + data, + clean_whitelist=["deleted_at"], + ) + + +def get_episode_uuid(show_uuid, api_id): + return str(uuid.uuid5(uuid.UUID(show_uuid), str(api_id))) diff --git a/src/layers/databases/python/watch_history_db.py b/src/layers/databases/python/watch_history_db.py index 835d93a6..f7f0078a 100644 --- a/src/layers/databases/python/watch_history_db.py +++ b/src/layers/databases/python/watch_history_db.py @@ -1,5 +1,6 @@ import os import time +import uuid from datetime import datetime import boto3 @@ -51,6 +52,53 @@ def _get_client(): return client +def add_item_v2(username, api_name, api_id, data=None): + if data is None: + data = {} + data["api_info"] = f"{api_name}_{api_id}" + + if data.get("dates_watched"): + data["latest_watch_date"] = "0" + try: + get_item_by_api_id( + username, + api_name, + api_id, + include_deleted=True, + ) + except NotFoundError: + data["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # create legacy item properties + collection_name, item_id = get_collection_and_item_id(api_name, api_id) + update_item( + username, + collection_name, + item_id, + data, + clean_whitelist=["deleted_at"], + ) + + +def update_item_v2(username, api_name, api_id, data, + clean_whitelist=OPTIONAL_FIELDS): + collection_name, item_id = get_collection_and_item_id(api_name, api_id) + update_item( + username, + collection_name, + item_id, + data, + clean_whitelist=clean_whitelist, + ) + + +def get_collection_and_item_id(api_name, api_id): + if api_name == "tvmaze": + show_namespace = uuid.UUID("6045673a-9dd2-451c-aa58-d94a217b993a") + api_uuid = uuid.uuid5(show_namespace, api_name) + return "show", str(uuid.uuid5(api_uuid, api_id)) + + def add_item(username, collection_name, item_id, data=None): if data is None: data = {} @@ -71,6 +119,15 @@ def delete_item(username, collection_name, item_id): update_item(username, collection_name, item_id, data, clean_whitelist=[]) +def delete_item_v2(username, api_name, api_id): + collection_name, item_id = get_collection_and_item_id(api_name, api_id) + delete_item( + username, + collection_name, + item_id, + ) + + def get_item(username, collection_name, item_id, include_deleted=False): filter_exp = Attr("collection_name").eq(collection_name) if not include_deleted: @@ -89,20 +146,61 @@ def get_item(username, collection_name, item_id, include_deleted=False): return res["Items"][0] +def get_item_by_api_id(username, api_name, api_id, include_deleted=False): + api_info = f"{api_name}_{api_id}" + + kwargs = { + "IndexName": "api_info", + "KeyConditionExpression": Key("username").eq(username) & + Key("api_info").eq(api_info) + } + if not include_deleted: + kwargs["FilterExpression"] = Attr("deleted_at").not_exists() + + res = _get_table().query(**kwargs) + + if not res["Items"]: + raise NotFoundError(f"Item with api_info: {api_info} not found") + + return res["Items"][0] + + +def get_items_by_api_id(api_name, api_id): + api_info = f"{api_name}_{api_id}" + res = _get_table().query( + IndexName="all_api_info", + KeyConditionExpression=Key("api_info").eq(api_info), + ) + + if not res["Items"]: + raise NotFoundError(f"Item with api_info: {api_info} not found.") + + return res["Items"] + + def update_item(username, collection_name, item_id, data, clean_whitelist=OPTIONAL_FIELDS): data["collection_name"] = collection_name data["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if "dates_watched" in data: + if data.get("dates_watched"): m_d = max([dateutil.parser.parse(d) for d in data["dates_watched"]]) m_d = m_d.strftime("%Y-%m-%dT%H:%M:%S.%fZ") data["latest_watch_date"] = m_d.replace("000Z", "Z") - items = ','.join(f'#{k}=:{k}' for k in data) - update_expression = f"SET {items}" - expression_attribute_names = {f'#{k}': k for k in data} - expression_attribute_values = {f':{k}': v for k, v in data.items()} + update_expression = "SET " + expression_attribute_names = {} + expression_attribute_values = {} + for k, v in data.items(): + if v is None: + continue + + update_expression += f"#{k}=:{k}," + expression_attribute_names[f"#{k}"] = k + expression_attribute_values[f":{k}"] = v + + # remove last comma + update_expression = update_expression[:-1] remove_names = [] for o in OPTIONAL_FIELDS: @@ -129,16 +227,18 @@ def update_item(username, collection_name, item_id, data, ) -def change_watched_eps(username, collection_name, item_id, change, special=False): +def change_watched_eps(username, collection_name, item_id, change, + special=False): field_name = "ep" if special: field_name = "special" item = get_item(username, collection_name, item_id) - if item[f"{field_name}_count"] == 0: + if f"{field_name}_count" not in item or item[f"{field_name}_count"] == 0: ep_progress = 0 else: - ep_progress = (item[f"watched_{field_name}s"] + (change)) / item[f"{field_name}_count"] + ep_progress = (item[f"watched_{field_name}s"] + (change)) / item[ + f"{field_name}_count"] ep_progress = round(ep_progress * 100, 2) _get_table().update_item( @@ -158,6 +258,17 @@ def change_watched_eps(username, collection_name, item_id, change, special=False ) +def change_watched_eps_v2(username, api_name, api_id, change, special=False): + collection_name, item_id = get_collection_and_item_id(api_name, api_id) + change_watched_eps( + username, + collection_name, + item_id, + change, + special=special + ) + + def get_watch_history(username, collection_name=None, index_name=None, status_filter=None): paginator = _get_client().get_paginator('query') diff --git a/test/unittest/conftest.py b/test/unittest/conftest.py index f25693e7..fd448106 100644 --- a/test/unittest/conftest.py +++ b/test/unittest/conftest.py @@ -1,7 +1,10 @@ import os from unittest.mock import MagicMock +import jwt import pytest +from starlette.testclient import TestClient +from api.watch_histories import app os.environ["LOG_LEVEL"] = "DEBUG" @@ -35,16 +38,6 @@ def mocked_episodes_db(): return episodes_db -@pytest.fixture(scope='function') -def mocked_show_api(): - import shows_api - - shows_api.SHOW_API_URL = "https://mocked" - shows_api.utils.get_v4_signature_auth = MagicMock() - - return shows_api - - @pytest.fixture(scope='function') def mocked_movie_api(): import movie_api @@ -52,3 +45,22 @@ def mocked_movie_api(): movie_api.MOVIE_API_URL = "https://mocked" return movie_api + + +@pytest.fixture(scope='session') +def client(): + return TestClient(app) + + +@pytest.fixture(scope='session') +def username(): + return "TEST_USER" + + +@pytest.fixture(scope='session') +def token(username): + return jwt.encode( + {"username": username}, + "secret", + algorithm="HS256" + ).decode("utf-8") diff --git a/test/unittest/test_episode_by_collection.py b/test/unittest/test_episode_by_collection.py index 3f74a62a..7d32a52f 100644 --- a/test/unittest/test_episode_by_collection.py +++ b/test/unittest/test_episode_by_collection.py @@ -116,51 +116,6 @@ def test_anime_by_api_id_success(self, mocked_get_anime, "statusCode": 200 } - @patch("api.episode_by_collection_item.episodes_db.get_episode") - @patch("api.episode_by_collection_item.shows_api.get_episode_by_api_id") - def test_show_by_api_id_success(self, mocked_get_shows, - mocked_get_episode): - w_ret = { - "collection_name": "show", - "item_id": 123, - "episode_id": 345, - } - s_ret = { - "id": 123 - } - mocked_get_shows.return_value = s_ret - mocked_get_episode.return_value = w_ret - event = copy.deepcopy(self.event) - event["queryStringParameters"] = { - "api_id": "123", - "api_name": "tvdb", - } - event["pathParameters"]["collection_name"] = "show" - - ret = handle(event, None) - exp_data = {**w_ret, **s_ret} - assert ret == { - "body": json.dumps(exp_data), - "statusCode": 200 - } - - @patch("api.episode_by_collection_item.shows_api.get_episode_by_api_id") - def test_show_by_api_id_http_error(self, mocked_get_shows): - mocked_get_shows.side_effect = HttpError("test-error", 409) - event = copy.deepcopy(self.event) - event["queryStringParameters"] = { - "api_id": "123", - "api_name": "tvdb", - } - event["pathParameters"]["collection_name"] = "show" - - ret = handle(event, None) - assert ret == { - "body": '{"message": "Could not get show episode"}', - "error": "test-error", - "statusCode": 409 - } - @patch("api.episode_by_collection_item.episodes_db.get_episode") @patch("api.episode_by_collection_item.anime_api.get_episode_by_api_id") def test_anime_by_api_id_not_found(self, mocked_get_anime, @@ -220,31 +175,6 @@ def test_success_anime(self, mocked_change_watched_eps, mocked_update_episode, m "statusCode": 200 } - @patch("api.episode_by_collection_item.episodes_db.add_episode") - @patch("api.episode_by_collection_item.shows_api.post_episode") - @patch("api.episode_by_collection_item.watch_history_db.get_item") - @patch("api.episode_by_collection_item.watch_history_db.update_item") - @patch("api.episode_by_collection_item.episodes_db.update_episode") - @patch("api.episode_by_collection_item.watch_history_db.change_watched_eps") - def test_success_show(self, mocked_change_watched_eps, mocked_update_episode, mocked_update_item, mocked_get_item, mocked_post_episode, mocked_post): - mocked_post.return_value = True - mocked_post_episode.return_value = { - "id": "123", - "is_special": False, - } - mocked_get_item.return_value = { - "latest_watch_date": "2030-01-01 10:00:00" - } - - event = copy.deepcopy(self.event) - event["pathParameters"]["collection_name"] = "show" - - ret = handle(event, None) - assert ret == { - "body": '{"id": "123"}', - "statusCode": 200 - } - @patch("api.episode_by_collection_item.episodes_db.add_episode") @patch("api.episode_by_collection_item.anime_api.post_episode") def test_api_error(self, mocked_post_episode, mocked_post): diff --git a/test/unittest/test_episode_by_id.py b/test/unittest/test_episode_by_id.py index 759d77de..7b924f73 100644 --- a/test/unittest/test_episode_by_id.py +++ b/test/unittest/test_episode_by_id.py @@ -37,19 +37,6 @@ def test_success_anime(self, mocked_anime_ep, mocked_get): '"item_id": 123, "mal_id": 345}', 'statusCode': 200} - @patch("api.episode_by_id.episodes_db.get_episode") - @patch("api.episode_by_id.shows_api.get_episode") - def test_success_show(self, mocked_shows_ep, mocked_get): - mocked_shows_ep.return_value = {"tvmaze_id": 345} - mocked_get.return_value = {"collection_name": "show", "item_id": 123} - event = copy.deepcopy(self.event) - event["pathParameters"]["collection_name"] = "show" - - ret = handle(event, None) - assert ret == {'body': '{"collection_name": "show", ' - '"item_id": 123, "tvmaze_id": 345}', - 'statusCode': 200} - @patch("api.episode_by_id.episodes_db.get_episode") @patch("api.episode_by_id.anime_api.get_episode") def test_anime_http_error(self, mocked_anime_ep, mocked_get): @@ -143,38 +130,6 @@ def test_success_anime(self, mocked_update_item, mocked_get_item, ret = handle(self.event, None) assert ret == {'statusCode': 204} - @patch("api.episode_by_id.episodes_db.update_episode") - @patch("api.episode_by_collection_item.shows_api.get_episode") - @patch("api.episode_by_id.watch_history_db.get_item") - def test_success_show(self, mocked_get_item, mocked_get_episode, - mocked_update_episode): - mocked_get_item.return_value = { - "latest_watch_date": "2021-01-01" - } - mocked_update_episode.return_value = "2020-01-01" - - event = copy.deepcopy(self.event) - event["pathParameters"]["collection_name"] = "show" - - ret = handle(event, None) - assert ret == {'statusCode': 204} - - @patch("api.episode_by_id.episodes_db.update_episode") - @patch("api.episode_by_collection_item.shows_api.get_episode") - @patch("api.episode_by_id.watch_history_db.get_item") - def test_missing_last_watch_date(self, mocked_get_item, mocked_get_episode, - mocked_update_episode): - mocked_get_item.return_value = { - "latest_watch_date": "2021-01-01" - } - mocked_update_episode.return_value = None - - event = copy.deepcopy(self.event) - event["pathParameters"]["collection_name"] = "show" - - ret = handle(event, None) - assert ret == {'statusCode': 204} - @patch("api.episode_by_id.episodes_db.update_episode") @patch("api.episode_by_collection_item.anime_api.get_episode") def test_api_error(self, mocked_get_episode, mocked_post): diff --git a/test/unittest/test_episodes_db.py b/test/unittest/test_episodes_db.py index 59f80fbb..75b419b5 100644 --- a/test/unittest/test_episodes_db.py +++ b/test/unittest/test_episodes_db.py @@ -16,119 +16,6 @@ def mock_func(**kwargs): return MOCK_RETURN -def test_get_episodes(mocked_episodes_db): - global MOCK_RETURN - MOCK_RETURN = [ - {"Items": [{"collection_name": "ANIME", "item_id": 123, "id": 345}]}, - {"Items": [{"collection_name": "MOVIE", "item_id": 123, "id": 345}]} - ] - m = MagicMock() - mocked_episodes_db.client.get_paginator.return_value = m - m.paginate = mock_func - - mocked_episodes_db.get_episodes(TEST_USERNAME, "anime", 123) - - assert UPDATE_VALUES == { - 'ExpressionAttributeValues': { - ':collection_name': {'S': "anime"}, - ':item_id': {'S': 123}, - ':username': {'S': 'TEST_USERNAME'} - }, - 'FilterExpression': 'attribute_not_exists(deleted_at) and item_id = :item_id ' - 'and collection_name = :collection_name', - 'KeyConditionExpression': 'username = :username', - 'Limit': 100, - 'ScanIndexForward': False, - 'TableName': None - } - - -def test_get_episodes_changed_limit(mocked_episodes_db): - global MOCK_RETURN - MOCK_RETURN = [ - {"Items": [{"collection_name": "ANIME", "item_id": 123, "id": 345}]}, - {"Items": [{"collection_name": "MOVIE", "item_id": 123, "id": 345}]} - ] - m = MagicMock() - mocked_episodes_db.client.get_paginator.return_value = m - m.paginate = mock_func - - mocked_episodes_db.get_episodes(TEST_USERNAME, "anime", 123, limit=10) - - assert UPDATE_VALUES == { - 'ExpressionAttributeValues': { - ':collection_name': {'S': 'anime'}, - ':item_id': {'S': 123}, - ':username': {'S': 'TEST_USERNAME'} - }, - 'FilterExpression': 'attribute_not_exists(deleted_at) and item_id = :item_id ' - 'and collection_name = :collection_name', - 'KeyConditionExpression': 'username = :username', - 'Limit': 10, - 'ScanIndexForward': False, - 'TableName': None - } - - -def test_get_episodes_by_with_start(mocked_episodes_db): - global MOCK_RETURN - MOCK_RETURN = [ - {"Items": [{"collection_name": "ANIME", "item_id": 123, "id": 345}]}, - {"Items": [{"collection_name": "ANIME", "item_id": 123, "id": 567}]} - ] - m = MagicMock() - mocked_episodes_db.client.get_paginator.return_value = m - m.paginate = mock_func - - ret = mocked_episodes_db.get_episodes(TEST_USERNAME, "ANIME", 123, limit=1, - start=2) - - assert UPDATE_VALUES == { - 'ExpressionAttributeValues': { - ':collection_name': {'S': 'ANIME'}, - ':item_id': {'S': 123}, - ':username': {'S': 'TEST_USERNAME'} - }, - 'FilterExpression': 'attribute_not_exists(deleted_at) and item_id = :item_id ' - 'and collection_name = :collection_name', - 'KeyConditionExpression': 'username = :username', - 'Limit': 1, - 'ScanIndexForward': False, - 'TableName': None - } - assert ret == { - 'episodes': {567: {"item_id": 123, 'collection_name': 'ANIME'}}, - "total_pages": 2 - } - - -def test_get_episodes_too_small_start_index(mocked_episodes_db): - with pytest.raises(mocked_episodes_db.InvalidStartOffset): - mocked_episodes_db.get_episodes(TEST_USERNAME, 123, "anime", start=0) - - -def test_get_episodes_too_large_start_index(mocked_episodes_db): - global MOCK_RETURN - MOCK_RETURN = [ - {"Items": [{"collection_name": "ANIME", "item_id": 123, "id": 345}]}, - {"Items": [{"collection_name": "MOVIE", "item_id": 123, "id": 567}]} - ] - m = MagicMock() - mocked_episodes_db.client.get_paginator.return_value = m - m.paginate = mock_func - with pytest.raises(mocked_episodes_db.InvalidStartOffset): - mocked_episodes_db.get_episodes(TEST_USERNAME, 123, "ANIME", start=10) - - -def test_get_episodes_not_found(mocked_episodes_db): - m = MagicMock() - mocked_episodes_db.client.get_paginator.return_value = m - m.paginate.return_value = [{"Items": []}] - - with pytest.raises(mocked_episodes_db.NotFoundError): - mocked_episodes_db.get_episodes(TEST_USERNAME, 123, "ANIME") - - def test_add_episode(mocked_episodes_db): global UPDATE_VALUES UPDATE_VALUES = {} diff --git a/test/unittest/test_item_by_collection.py b/test/unittest/test_item_by_collection.py index fba74544..46888fb7 100644 --- a/test/unittest/test_item_by_collection.py +++ b/test/unittest/test_item_by_collection.py @@ -39,21 +39,6 @@ def test_success_anime(self, mocked_anime_get, mocked_get): "statusCode": 200 } - @patch("api.item_by_collection.watch_history_db.get_item") - @patch("api.item_by_collection.shows_api.get_show") - def test_success_show(self, mocked_show_get, mocked_get): - mocked_show_get.return_value = {"tvdb_id": 564} - mocked_get.return_value = {"collection_name": "show", "item_id": 123} - event = copy.deepcopy(self.event) - event["pathParameters"]["collection_name"] = "show" - - ret = handle(event, None) - assert ret == { - "body": '{"collection_name": "show", ' - '"item_id": 123, "tvdb_id": 564}', - "statusCode": 200 - } - @patch("api.item_by_collection.watch_history_db.get_item") @patch("api.item_by_collection.movie_api.get_movie") def test_success_movie(self, mocked_movie_get, mocked_get): @@ -145,13 +130,12 @@ class TestPut: @pytest.mark.parametrize( "collection_name", - ["anime", "show", "movie"] + ["anime", "movie"] ) @patch("api.item_by_collection.anime_api.get_anime") - @patch("api.item_by_collection.shows_api.get_show") @patch("api.item_by_collection.movie_api.get_movie") @patch("api.item_by_collection.watch_history_db.update_item") - def test_success(self, a, s, m, mocked_post, collection_name): + def test_success(self, a, m, mocked_post, collection_name): mocked_post.return_value = True event = copy.deepcopy(self.event) event["pathParameters"]["collection_name"] = collection_name diff --git a/test/unittest/test_routes.py b/test/unittest/test_routes.py new file mode 100644 index 00000000..1a09a94c --- /dev/null +++ b/test/unittest/test_routes.py @@ -0,0 +1,151 @@ +from unittest.mock import patch + +import episodes_db +import tvmaze +import watch_history_db + +TEST_SHOW_ID = "123123" +TEST_EPISODE_ID = "456465" + + +@patch("watch_history_db.get_item_by_api_id") +def test_get_item(m_get_item, token, client, username): + response = client.get( + f"/watch-histories/items/tvmaze/{TEST_SHOW_ID}", + headers={"Authorization": token} + ) + + assert response.status_code == 200 + + +@patch("watch_history_db.get_item_by_api_id") +def test_not_found(m_get_item, client, token): + m_get_item.side_effect = watch_history_db.NotFoundError + + response = client.get( + f"/watch-histories/items/tvmaze/{TEST_SHOW_ID}", + headers={"Authorization": token} + ) + + assert response.status_code == 404 + + +@patch.object(tvmaze.TvMazeApi, "get_show_episodes_count") +@patch("watch_history_db.get_item_by_api_id") +@patch("watch_history_db.add_item_v2") +def test_post_item(m_add_item, m_get_item, mocked_ep_count, token, client): + mocked_ep_count.return_value = { + "ep_count": 1, + "special_count": 2, + } + + m_get_item.return_value = {} + + response = client.post( + "/watch-histories/items", + headers={"Authorization": token}, + json={ + "item_api_id": TEST_SHOW_ID, + "api_name": "tvmaze" + } + ) + + assert response.status_code == 204 + + +@patch.object(tvmaze.TvMazeApi, "get_show_episodes_count") +def test_post_item_tvmaze_error(m_ep_count, token, client): + m_ep_count.side_effect = tvmaze.HTTPError(503) + + response = client.post( + "/watch-histories/items", + headers={"Authorization": token}, + json={ + "item_api_id": TEST_SHOW_ID, + "api_name": "tvmaze" + } + ) + + assert response.status_code == 503 + + +@patch.object(tvmaze.TvMazeApi, "get_show_episodes_count") +@patch("watch_history_db.get_item_by_api_id") +@patch("watch_history_db.add_item_v2") +def test_post_item_not_found(m_add_item, m_get_item, mocked_ep_count, token, + client): + mocked_ep_count.return_value = { + "ep_count": 1, + "special_count": 2, + } + m_get_item.side_effect = [ + watch_history_db.NotFoundError, {"Items": []} + ] + + response = client.post( + "/watch-histories/items", + headers={"Authorization": token}, + json={ + "item_api_id": TEST_SHOW_ID, + "api_name": "tvmaze" + } + ) + + assert response.status_code == 204 + + +@patch("episodes_db.get_episode_by_api_id") +def test_get_episode(m_get_ep, token, client, username): + api_info = f"tvmaze_{TEST_SHOW_ID}_{TEST_EPISODE_ID}" + m_get_ep.return_value = { + "api_info": api_info + } + + response = client.get( + f"/watch-histories/items/tvmaze/{TEST_SHOW_ID}/episodes/{TEST_EPISODE_ID}", + headers={"Authorization": token} + ) + + assert response.status_code == 200 + assert response.json() == {"api_info": api_info} + + +@patch("episodes_db.get_episode_by_api_id") +def test_get_episode_not_found(m_get_ep, token, client, username): + m_get_ep.side_effect = episodes_db.NotFoundError + + response = client.get( + f"/watch-histories/items/tvmaze/{TEST_SHOW_ID}/episodes/{TEST_EPISODE_ID}", + headers={"Authorization": token} + ) + + assert response.status_code == 404 + + +@patch.object(tvmaze.TvMazeApi, "get_episode") +@patch("watch_history_db.get_item_by_api_id") +@patch("episodes_db.update_episode_v2") +def test_put_episode(m_update_ep, m_get_item, m_get_ep, token, client, + username): + response = client.put( + f"/watch-histories/items/tvmaze/{TEST_SHOW_ID}/episodes/{TEST_EPISODE_ID}", + headers={"Authorization": token}, + json={ + "review": "new_review" + } + ) + + assert response.status_code == 204 + + +@patch("episodes_db.get_episodes") +def test_get_episodes(m_get_eps, token, client, username): + m_get_eps.return_value = [1, 2, 3] + + response = client.get( + f"/watch-histories/items/tvmaze/{TEST_SHOW_ID}/episodes", + headers={"Authorization": token}, + ) + + assert response.status_code == 200 + assert response.json() == m_get_eps.return_value diff --git a/test/unittest/test_shows_api.py b/test/unittest/test_shows_api.py deleted file mode 100644 index ea37743b..00000000 --- a/test/unittest/test_shows_api.py +++ /dev/null @@ -1,143 +0,0 @@ -from unittest.mock import patch, MagicMock - -import pytest - -import utils - - -@patch("shows_api.requests.get") -def test_get_show(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 200 - m.json.return_value = {"show_id": "123"} - mocked_get.return_value = m - - ret = mocked_show_api.get_show("123") - - assert ret == {"show_id": "123"} - - -@patch("shows_api.requests.get") -def test_get_show_invalid_code(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 404 - mocked_get.return_value = m - - with pytest.raises(utils.HttpError): - mocked_show_api.get_show("123") - - -@patch("shows_api.requests.get") -def test_get_show_by_api_id(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 200 - m.json.return_value = {"show_id": "123"} - mocked_get.return_value = m - - ret = mocked_show_api.get_show_by_api_id("tvdb", "123") - - assert ret == {"show_id": "123"} - - -@patch("shows_api.requests.get") -def test_get_show_by_api_id_invalid_code(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 404 - mocked_get.return_value = m - - with pytest.raises(utils.HttpError): - mocked_show_api.get_show_by_api_id("tvdb", "123") - - -@patch("shows_api.requests.post") -def test_post_show(mocked_post, mocked_show_api): - m = MagicMock() - m.status_code = 200 - m.json.return_value = {"show_id": "123"} - mocked_post.return_value = m - - ret = mocked_show_api.post_show({"api_id": "123", "api_name": "tvmaze"}) - - assert ret == {"show_id": "123"} - - -@patch("shows_api.requests.post") -def test_post_show_invalid_code(mocked_post, mocked_show_api): - m = MagicMock() - m.status_code = 404 - mocked_post.return_value = m - - with pytest.raises(utils.HttpError): - mocked_show_api.post_show({"api_id": "123", "api_name": "tvmaze"}) - - -@patch("shows_api.requests.get") -def test_get_episode(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 200 - m.json.return_value = {"show_id": "123"} - mocked_get.return_value = m - - ret = mocked_show_api.get_episode("123", "ep_id") - - assert ret == {"show_id": "123"} - - -@patch("shows_api.requests.get") -def test_get_episode_invalid_code(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 404 - mocked_get.return_value = m - - with pytest.raises(utils.HttpError): - mocked_show_api.get_episode("item_id", "ep_id") - - -@patch("shows_api.requests.get") -def test_get_episode_by_api_id(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 200 - m.json.return_value = {"show_id": "123"} - mocked_get.return_value = m - - ret = mocked_show_api.get_episode_by_api_id("tvdb", "123") - - assert ret == {"show_id": "123"} - - -@patch("shows_api.requests.get") -def test_get_episode_by_api_id_invalid_code(mocked_get, mocked_show_api): - m = MagicMock() - m.status_code = 404 - mocked_get.return_value = m - - with pytest.raises(utils.HttpError): - mocked_show_api.get_episode_by_api_id("tvdb", "123") - - -@patch("shows_api.requests.post") -def test_post_episode(mocked_post, mocked_show_api): - m = MagicMock() - m.status_code = 200 - m.json.return_value = {"show_id": "123"} - mocked_post.return_value = m - - ret = mocked_show_api.post_episode( - "123", - {"api_id": "123", "api_name": "tvmaze"}, - ) - - assert ret == {"show_id": "123"} - - -@patch("shows_api.requests.post") -def test_post_episode_invalid_code(mocked_post, mocked_show_api): - m = MagicMock() - m.status_code = 404 - mocked_post.return_value = m - - with pytest.raises(utils.HttpError): - mocked_show_api.post_episode( - "123", - {"api_id": "123", "api_name": "tvmaze"}, - ) diff --git a/test/unittest/test_watch_history_by_collection.py b/test/unittest/test_watch_history_by_collection.py index 248d2e2a..4929e365 100644 --- a/test/unittest/test_watch_history_by_collection.py +++ b/test/unittest/test_watch_history_by_collection.py @@ -73,34 +73,6 @@ def test_success_by_api_id_anime(self, mocked_get_anime, "statusCode": 200 } - @patch("api.watch_history_by_collection.watch_history_db.get_item") - @patch("api.watch_history_by_collection.shows_api.get_show_by_api_id") - def test_success_by_api_id_shows(self, mocked_get_show, - mocked_get_watch_history): - w_ret = { - "collection_name": "anime", - "item_id": 123, - "username": "user", - } - s_ret = { - "id": 123 - } - mocked_get_show.return_value = s_ret - mocked_get_watch_history.return_value = w_ret - event = copy.deepcopy(self.event) - event["pathParameters"]["collection_name"] = "show" - event["queryStringParameters"] = { - "api_id": 123, - "api_name": "tvdb" - } - - ret = handle(event, None) - exp_data = {**w_ret, **s_ret} - assert ret == { - "body": json.dumps(exp_data), - "statusCode": 200 - } - @patch("api.watch_history_by_collection.watch_history_db.get_item") @patch("api.watch_history_by_collection.movie_api.get_movie_by_api_id") def test_success_by_api_id_movie(self, mocked_get_movie, @@ -268,24 +240,6 @@ def test_empty_body(self, mocked_post): "statusCode": 400 } - @patch("api.watch_history_by_collection.watch_history_db.add_item") - @patch("api.watch_history_by_collection.shows_api.post_show") - @patch("api.watch_history_by_collection.watch_history_db.get_item") - def test_show_success(self, mocked_get_item, mocked_post_show, mocked_post): - mocked_post_show.return_value = { - "id": "123" - } - mocked_post.return_value = True - event = copy.deepcopy(self.event) - event["pathParameters"]["collection_name"] = "show" - event["body"] = '{ "api_id": "123", "api_name": "tvmaze" }' - - ret = handle(event, None) - assert ret == { - "body": '{"id": "123"}', - "statusCode": 200 - } - @patch("api.watch_history_by_collection.watch_history_db.add_item") @patch("api.watch_history_by_collection.movie_api.post_movie") @patch("api.watch_history_by_collection.watch_history_db.get_item") @@ -355,21 +309,3 @@ def test_invalid_body_schema(self, mocked_post): "body": json.dumps(body), "statusCode": 400 } - - @patch("api.watch_history_by_collection.watch_history_db.add_item") - @patch("api.watch_history_by_collection.shows_api.get_show") - def test_missing_id(self, mocked_get_show, mocked_post): - mocked_get_show.return_value = True - mocked_post.return_value = True - event = copy.deepcopy(self.event) - event["body"] = '{"api_name": "mal"}' - - ret = handle(event, None) - body = { - "message": "Invalid post schema", - "error": "'api_id' is a required property" - } - assert ret == { - "body": json.dumps(body), - "statusCode": 400 - } diff --git a/test/unittest/test_watch_history_db.py b/test/unittest/test_watch_history_db.py index fc8a16cf..3f98f051 100644 --- a/test/unittest/test_watch_history_db.py +++ b/test/unittest/test_watch_history_db.py @@ -258,6 +258,38 @@ def test_update_item_dates_watched_one_date(mocked_watch_history_db): } +def test_update_item_none_data(mocked_watch_history_db): + mock_func = MockFunc() + mocked_watch_history_db.table.update_item = mock_func.f + + mocked_watch_history_db.update_item(TEST_USERNAME, "MOVIE", "123", + {"review": "review_text", "ignore": None}) + + assert mock_func.update_values == { + 'ExpressionAttributeNames': { + '#collection_name': 'collection_name', + '#dates_watched': 'dates_watched', + '#deleted_at': 'deleted_at', + '#overview': 'overview', + '#rating': 'rating', + '#review': 'review', + '#status': 'status', + '#updated_at': 'updated_at' + }, + 'ExpressionAttributeValues': { + ':collection_name': 'MOVIE', + ":updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ":review": "review_text" + }, + 'Key': { + 'username': TEST_USERNAME, + 'item_id': '123'}, + 'UpdateExpression': 'SET #review=:review,#collection_name=:collection_name,' + '#updated_at=:updated_at ' + 'REMOVE #deleted_at,#overview,#status,#rating,#dates_watched' + } + + def test_delete_item(mocked_watch_history_db): mock_func = MockFunc() mocked_watch_history_db.table.update_item = mock_func.f