diff --git a/app/config.py.example b/app/config.py.example index d296d02e..628c1c23 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -40,6 +40,7 @@ WEBSITE_LANGUAGE = "en" # Get a free API KEY for Astronomy feature @ www.weatherapi.com/signup.aspx ASTRONOMY_API_KEY = os.getenv('ASTRONOMY_API_KEY') WEATHER_API_KEY = os.getenv('WEATHER_API_KEY') +RAWG_API_KEY = os.getenv("RAWG_API_KEY") # https://developers.google.com/calendar/quickstart/python - # follow instracions and make an env variable with the path to the file. diff --git a/app/internal/game_releases_utils.py b/app/internal/game_releases_utils.py new file mode 100644 index 00000000..c06b645b --- /dev/null +++ b/app/internal/game_releases_utils.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +from functools import lru_cache +from typing import TYPE_CHECKING, Any, DefaultDict, Dict, List + +import httpx +from loguru import logger +from sqlalchemy.orm import Session + +from app import config +from app.database.models import UserSettings + +if TYPE_CHECKING: + from app.routers.calendar_grid import Day, Week + + +def is_user_signed_up_for_game_releases( + session: Session, + current_user_id: int, +) -> bool: + is_signed_up = bool( + session.query(UserSettings) + .filter(UserSettings.user_id == current_user_id) + .filter(UserSettings.video_game_releases.is_(True)) + .first(), + ) + + return is_signed_up + + +def add_game_events_to_weeks( + weeks: List["Week"], + is_active: bool = True, +) -> List["Week"]: + if not is_active: + return weeks + first_week: Week = weeks[0] + last_week: Week = weeks[-1] + first_day: Day = first_week.days[0] + last_day: Day = last_week.days[-1] + first_day_str = datetime.strptime(first_day.set_id(), "%d-%B-%Y") + last_day_str = datetime.strptime(last_day.set_id(), "%d-%B-%Y") + + output = get_games_data_by_dates_from_api( + start_date=first_day_str.strftime("%Y-%m-%d"), + end_date=last_day_str.strftime("%Y-%m-%d"), + ) + if not output["success"]: + logger.exception("Unsuccessful RAWG API call") + return weeks + games_by_dates = output["results"] + + unformatted_games_by_dates = get_games_data_separated_by_dates( + games_by_dates, + ) + formatted_games = get_formatted_games_in_days(unformatted_games_by_dates) + + return insert_formatted_games_to_weeks(weeks, formatted_games) + + +def insert_formatted_games_to_weeks( + weeks: List["Week"], + formatted_games: DefaultDict[List[str]], +) -> List["Week"]: + for week in weeks: + for day in week.days: + if day.set_id() in formatted_games.keys(): + for game in formatted_games[day.set_id()]: + day.dailyevents.append( + ( + f"GR!- {(game)[:10]}", + (game), + ), + ) + return weeks + + +@lru_cache(maxsize=128) +def get_games_data_by_dates_from_api( + start_date: str, + end_date: str, +) -> Dict[str, Any]: + API = "https://api.rawg.io/api/games" + NO_API_RESPONSE = "The RAWG server did not response" + input_query_string = { + "dates": f"{start_date},{end_date}", + "key": config.RAWG_API_KEY, + } + + output: Dict[str, Any] = {} + try: + response = httpx.get( + API, + params=input_query_string, + ) + except httpx.HTTPError: + output["success"] = False + output["error"] = NO_API_RESPONSE + return output + + if response.status_code != httpx.codes.OK: + output["success"] = False + output["error"] = NO_API_RESPONSE + return output + + output["success"] = True + try: + output.update(response.json()) + return output + except KeyError: + output["success"] = False + output["error"] = response.json()["error"]["message"] + return output + + +def get_games_data_separated_by_dates( + api_data: Dict[str, Any], +) -> DefaultDict[List]: + games_data = defaultdict(list) + for result in api_data: + current = { + "name": result["name"], + "platforms": [], + } + if result["platforms"]: + for platform in result["platforms"]: + current["platforms"].append(platform["platform"]["name"]) + ybd_release_date = translate_ymd_date_to_dby(result["released"]) + games_data[ybd_release_date].append(current) + return games_data + + +def get_formatted_games_in_days( + separated_games_dict: DefaultDict[List], + with_platforms: bool = False, +) -> DefaultDict[List[str]]: + formatted_games = defaultdict(list) + + for date, game_data in separated_games_dict.items(): + for game in game_data: + formatted_game_str = format_single_game(game, with_platforms) + formatted_games[date].append(formatted_game_str) + return formatted_games + + +def format_single_game(raw_game: Dict, with_platforms: bool = False) -> str: + formatted_game_str = "" + formatted_game_str += raw_game["name"] + if with_platforms: + formatted_game_str += "-Platforms-
" + for platform in raw_game["platforms"]: + formatted_game_str += f"{platform}," + return formatted_game_str + + +def translate_ymd_date_to_dby(ymd_str: str) -> str: + ymd_time = datetime.strptime(ymd_str, "%Y-%m-%d") + return ymd_time.strftime("%d-%B-%Y") + + +def translate_dby_date_to_ymd(dby_str: str) -> str: + dby_time = datetime.strptime(dby_str, "%d-%B-%Y") + return dby_time.strftime("%Y-%m-%d") diff --git a/app/main.py b/app/main.py index ccf4c30c..1a86d298 100644 --- a/app/main.py +++ b/app/main.py @@ -73,6 +73,7 @@ def create_tables(engine, psql_environment): features, four_o_four, friendview, + game_release_dates_service, google_connect, joke, login, @@ -145,6 +146,7 @@ async def swagger_ui_redirect(): weekview.router, weight.router, whatsapp.router, + game_release_dates_service.router, ] for router in routers_to_include: diff --git a/app/routers/calendar.py b/app/routers/calendar.py index dc1aeefa..2eed55d6 100644 --- a/app/routers/calendar.py +++ b/app/routers/calendar.py @@ -1,8 +1,7 @@ from http import HTTPStatus from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse -from starlette.responses import Response +from fastapi.responses import HTMLResponse, Response from app.dependencies import templates from app.routers import calendar_grid as cg @@ -11,7 +10,7 @@ prefix="/calendar/month", tags=["calendar"], responses={404: {"description": "Not found"}}, - include_in_schema=False + include_in_schema=False, ) @@ -25,18 +24,21 @@ async def calendar(request: Request) -> Response: "request": request, "day": day, "week_days": cg.Week.DAYS_OF_THE_WEEK, - "weeks_block": cg.get_month_block(day) - } + "weeks_block": cg.get_month_block(day), + }, ) @router.get("/add/{date}") async def update_calendar( - request: Request, date: str, days: int + request: Request, + date: str, + days: int, ) -> HTMLResponse: last_day = cg.Day.convert_str_to_date(date) next_weeks = cg.create_weeks(cg.get_n_days(last_day, days)) template = templates.get_template( - 'partials/calendar/monthly_view/add_week.html') + "partials/calendar/monthly_view/add_week.html", + ) content = template.render(weeks_block=next_weeks) return HTMLResponse(content=content, status_code=HTTPStatus.OK) diff --git a/app/routers/calendar_grid.py b/app/routers/calendar_grid.py index 9ef5202c..e43ad04b 100644 --- a/app/routers/calendar_grid.py +++ b/app/routers/calendar_grid.py @@ -6,6 +6,8 @@ import pytz +from app.internal.game_releases_utils import add_game_events_to_weeks + MONTH_BLOCK: int = 6 locale.setlocale(locale.LC_ALL, "en_US.UTF-8") @@ -190,7 +192,9 @@ def create_weeks( """Return lists of Weeks objects.""" ndays: List[Day] = list(days) num_days: int = len(ndays) - return [Week(ndays[i : i + length]) for i in range(0, num_days, length)] + _weeks = [Week(ndays[i : i + length]) for i in range(0, num_days, length)] + + return add_game_events_to_weeks(_weeks, is_active=True) def get_month_block(day: Day, n: int = MONTH_BLOCK) -> List[Week]: diff --git a/app/routers/game_release_dates_service.py b/app/routers/game_release_dates_service.py new file mode 100644 index 00000000..1db2c942 --- /dev/null +++ b/app/routers/game_release_dates_service.py @@ -0,0 +1,144 @@ +import datetime +from typing import Dict, List + +import requests +from fastapi import APIRouter, Depends, Request +from fastapi.responses import RedirectResponse, Response +from sqlalchemy.orm import Session +from starlette.status import HTTP_302_FOUND + +from app.database.models import UserSettings +from app.dependencies import get_db, templates +from app.internal.game_releases_utils import ( + is_user_signed_up_for_game_releases, +) +from app.internal.security.dependencies import current_user +from app.internal.security.schema import CurrentUser +from app.internal.utils import create_model +from app.routers.profile import router as profile_router + +router = APIRouter( + prefix="/game-releases", + tags=["game-releases"], + responses={404: {"description": "Not found"}}, +) + + +@router.post("/get_releases_by_dates") +async def fetch_released_games( + request: Request, + session=Depends(get_db), +) -> Response: + data = await request.form() + + from_date = data["from-date"] + to_date = data["to-date"] + + games = get_games_data(from_date, to_date) + + return templates.TemplateResponse( + "partials/calendar/feature_settings/games_list.html", + {"request": request, "games": games}, + ) + + +@router.get("/next-month") +def get_game_releases_month(request: Request) -> List: + today = datetime.datetime.today() + delta = datetime.timedelta(days=30) + today_str = today.strftime("%Y-%m-%d") + in_month_str = (today + delta).strftime("%Y-%m-%d") + + return get_games_data(today_str, in_month_str) + + +def get_games_data(start_date: datetime, end_date: datetime) -> List[Dict]: + API = "https://api.rawg.io/api/games" + + current_day_games = requests.get( + f"{API}?dates={start_date},{end_date}", + ) + current_day_games = current_day_games.json()["results"] + games_data = [] + for result in current_day_games: + current = { + "name": result["name"], + "slug": result["slug"], + "platforms": [], + } + + for platform in result["platforms"]: + current["platforms"].append(platform["platform"]["name"]) + current["release_date"] = result["released"] + games_data.append(current) + + return games_data + + +@router.post("/subscribe") +async def subscribe_game_release_service( + request: Request, + session: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +) -> Response: + if is_user_signed_up_for_game_releases(session, user.user_id): + return RedirectResponse( + profile_router.url_path_for("profile"), + status_code=HTTP_302_FOUND, + ) + games_setting_true_for_model = { + "user_id": user.user_id, + "video_game_releases": True, + } + current_user_settings = session.query(UserSettings).filter( + UserSettings.user_id == user.user_id, + ) + if current_user_settings: + # TODO: + # If all users are created with a UserSettings entry - + # unnecessary check + current_user_settings.update(games_setting_true_for_model) + session.commit() + else: + create_model(session, UserSettings, **games_setting_true_for_model) + return RedirectResponse( + profile_router.url_path_for("profile"), + status_code=HTTP_302_FOUND, + ) + + +@router.post("/unsubscribe") +async def unsubscribe_game_release_service( + request: Request, + session: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +) -> RedirectResponse: + current_user_id = user.user_id + + if not is_user_signed_up_for_game_releases(session, current_user_id): + return RedirectResponse( + profile_router.url_path_for("profile"), + status_code=HTTP_302_FOUND, + ) + else: + games_setting_false_for_model = { + "user_id": str(current_user_id), + "video_game_releases": False, + } + current_user_settings = session.query(UserSettings).filter( + UserSettings.user_id == current_user_id, + ) + if current_user_settings: + # TODO: + # If all users are created with a UserSettings entry - + # unnecessary check + current_user_settings.update(games_setting_false_for_model) + session.commit() + else: + create_model( + session, UserSettings, **games_setting_false_for_model + ) + return RedirectResponse( + profile_router.url_path_for("profile"), + status_code=HTTP_302_FOUND, + ) diff --git a/app/static/game_releases.css b/app/static/game_releases.css new file mode 100644 index 00000000..ee6bc0e9 --- /dev/null +++ b/app/static/game_releases.css @@ -0,0 +1,7 @@ +.game-title { + font-size: 1.1em; + font-weight: bolder; +} +.secondary-title { + font-weight: bold; +} diff --git a/app/static/js/game_releases.js b/app/static/js/game_releases.js new file mode 100644 index 00000000..a1130ac0 --- /dev/null +++ b/app/static/js/game_releases.js @@ -0,0 +1,19 @@ +document.addEventListener("DOMContentLoaded", () => { + get_games_btn = document.getElementById("get-games"); + get_games_btn.addEventListener("click", function (e) { + const formData = new FormData(); + formData.append("from-date", document.getElementById("from-date").value); + formData.append("to-date", document.getElementById("to-date").value); + + fetch("/game-releases/get_releases_by_dates", { + method: "post", + body: formData, + }) + .then(function (response) { + return response.text(); + }) + .then(function (body) { + document.querySelector("#content-div").innerHTML = body; + }); + }); +}); diff --git a/app/templates/partials/calendar/calendar_base.html b/app/templates/partials/calendar/calendar_base.html index a0090af6..e5621eee 100644 --- a/app/templates/partials/calendar/calendar_base.html +++ b/app/templates/partials/calendar/calendar_base.html @@ -18,6 +18,7 @@ {% endblock content %} +{% include 'partials/calendar/feature_settings/game_release_modal.html' %} diff --git a/app/templates/partials/calendar/feature_settings/game_release_modal.html b/app/templates/partials/calendar/feature_settings/game_release_modal.html new file mode 100644 index 00000000..9a9b7083 --- /dev/null +++ b/app/templates/partials/calendar/feature_settings/game_release_modal.html @@ -0,0 +1,55 @@ + + + + + + + diff --git a/app/templates/partials/calendar/feature_settings/games_list.html b/app/templates/partials/calendar/feature_settings/games_list.html new file mode 100644 index 00000000..94984372 --- /dev/null +++ b/app/templates/partials/calendar/feature_settings/games_list.html @@ -0,0 +1,10 @@ +{% for game in games %} +
"{{ game['name'] }}"
+
Platforms
+{% for platform in game['platforms']%} +
{{ platform }}
+{% endfor %} +
Release date
+
{{ game['release_date'] }}
+
+{% endfor %} diff --git a/app/templates/partials/calendar/navigation.html b/app/templates/partials/calendar/navigation.html index 0677bc87..3a6efc31 100644 --- a/app/templates/partials/calendar/navigation.html +++ b/app/templates/partials/calendar/navigation.html @@ -39,6 +39,9 @@
+
+ +
diff --git a/tests/test_calendar_grid.py b/tests/test_calendar_grid.py index e5442b02..cf1353a3 100644 --- a/tests/test_calendar_grid.py +++ b/tests/test_calendar_grid.py @@ -80,15 +80,19 @@ def test_create_weeks(): @staticmethod def test_get_month_block(Calendar): + dates_iterator = Calendar.itermonthdates(1988, 5) month_weeks = cg.create_weeks( - Calendar.itermonthdates(1988, 5), + (cg.Day(date) for date in dates_iterator), WEEK_DAYS, ) get_block = cg.get_month_block(cg.Day(DATE), n=len(month_weeks)) for i in range(len(month_weeks)): for j in range(cg.Week.WEEK_DAYS): - assert get_block[i].days[j].date == month_weeks[i].days[j] + assert ( + get_block[i].days[j].set_id() + == month_weeks[i].days[j].set_id() + ) @staticmethod def test_get_user_local_time(): diff --git a/tests/test_game_releases.py b/tests/test_game_releases.py new file mode 100644 index 00000000..b2ddf8e2 --- /dev/null +++ b/tests/test_game_releases.py @@ -0,0 +1,132 @@ +from collections import defaultdict + +from app.internal.game_releases_utils import ( + get_formatted_games_in_days, + get_games_data_by_dates_from_api, + get_games_data_separated_by_dates, + translate_dby_date_to_ymd, + translate_ymd_date_to_dby, +) +from app.routers.game_release_dates_service import ( + router as game_release_router, +) +from tests.test_login import test_login_successfull + +REGISTER_DETAIL = { + "username": "correct_user", + "full_name": "full_name", + "password": "correct_password", + "confirm_password": "correct_password", + "email": "example@email.com", + "description": "", +} + + +class TestGameReleases: + @staticmethod + def test_subscribe_not_logged(client): + response = client.post( + game_release_router.url_path_for("subscribe_game_release_service"), + ) + assert response.ok + + @staticmethod + def test_subscribe_logged(session, security_test_client): + test_login_successfull(session, security_test_client) + response = security_test_client.post( + game_release_router.url_path_for("subscribe_game_release_service"), + ) + assert response.ok + + @staticmethod + def test_unsubscribe_not_logged(client): + response = client.post( + game_release_router.url_path_for("subscribe_game_release_service"), + ) + assert response.ok + response = client.post( + game_release_router.url_path_for( + "unsubscribe_game_release_service", + ), + ) + assert response.ok + + @staticmethod + def test_unsubscribe_logged(session, security_test_client): + test_login_successfull(session, security_test_client) + + response = security_test_client.post( + game_release_router.url_path_for("subscribe_game_release_service"), + ) + assert response.ok + response = security_test_client.post( + game_release_router.url_path_for( + "unsubscribe_game_release_service", + ), + ) + assert response.ok + + @staticmethod + def test_get_game_releases_month(client): + response = client.get( + game_release_router.url_path_for("get_game_releases_month"), + ) + assert response.ok + assert b"name" in response.content + + @staticmethod + def test_get_game_releases(client): + day_1 = "2020-12-10" + day_2 = "2020-12-20" + dates = {"from-date": day_1, "to-date": day_2} + response = client.post( + game_release_router.url_path_for("fetch_released_games"), + data=dates, + ) + assert response.ok + assert b"Xbox" in response.content + + @staticmethod + def test_get_games_data_separated_by_days(): + day_1 = "2020-12-10" + day_2 = "2020-12-20" + output = get_games_data_by_dates_from_api( + start_date=day_1, + end_date=day_2, + ) + games_by_dates = output["results"] + unformatted_games_by_dates = get_games_data_separated_by_dates( + games_by_dates, + ) + formatted_games = get_formatted_games_in_days( + unformatted_games_by_dates, + ) + + assert isinstance(unformatted_games_by_dates, defaultdict) + assert isinstance(formatted_games, defaultdict) + assert ( + "platforms" + in unformatted_games_by_dates["10-December-2020"][0].keys() + ) + + @staticmethod + def test_ymd_to_dby(): + ymd_date = "2020-12-12" + assert translate_ymd_date_to_dby(ymd_date) == "12-December-2020" + + @staticmethod + def test_dby_to_ymd(): + dby_date = "12-December-2020" + assert translate_dby_date_to_ymd(dby_date) == "2020-12-12" + + @staticmethod + def test_get_game_releases_async(client): + day_1 = "2020-12-10" + day_2 = "2020-12-20" + dates = {"from-date": day_1, "to-date": day_2} + response = client.post( + game_release_router.url_path_for("fetch_released_games"), + data=dates, + ) + assert response.ok + assert b"Xbox" in response.content