From b98e2e0ead75688946aac76e502cb625db39d81b Mon Sep 17 00:00:00 2001 From: Andrew Hosgood Date: Thu, 12 Sep 2024 14:46:20 +0100 Subject: [PATCH] Initial commit --- .dockerignore | 3 +- .gitignore | 3 +- app/__init__.py | 16 ++-- app/greetings/routes.py | 26 ------ app/{greetings => main}/__init__.py | 2 +- app/main/routes.py | 135 ++++++++++++++++++++++++++++ config.py | 3 + docker-compose.yml | 5 ++ poetry.lock | 19 +++- pyproject.toml | 3 +- test/test_greetings.py | 33 ------- test/test_main.py | 47 ++++++++++ 12 files changed, 225 insertions(+), 70 deletions(-) delete mode 100644 app/greetings/routes.py rename app/{greetings => main}/__init__.py (50%) create mode 100644 app/main/routes.py delete mode 100644 test/test_greetings.py create mode 100644 test/test_main.py diff --git a/.dockerignore b/.dockerignore index 3e6b5f4..52be658 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,5 @@ docker-compose.yml Dockerfile README.md -.gitignore \ No newline at end of file +.gitignore +.env \ No newline at end of file diff --git a/.gitignore b/.gitignore index 25053bf..e06cd29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store __pycache__ -.pytest_cache \ No newline at end of file +.pytest_cache +.env \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index e6b37b2..37edc77 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -7,20 +7,24 @@ def create_app(config_class): config = get_config(config_class) app = FastAPI( - title="TNA FastAPI Application", log_level=config.get("LOG_LEVEL") + title="The National Archives opening times", + log_level=config.get("LOG_LEVEL"), ) - app.state.config = {"BASE_URI": config.get("BASE_URI")} + app.state.config = { + "BASE_URI": config.get("BASE_URI"), + "GOOGLE_MAPS_API_KEY": config.get("GOOGLE_MAPS_API_KEY"), + "GOOGLE_MAPS_PLACE_ID": config.get("GOOGLE_MAPS_PLACE_ID"), + } if config.get("FORCE_HTTPS"): app.add_middleware(HTTPSRedirectMiddleware) - from .greetings import routes as greetings_routes from .healthcheck import routes as healthcheck_routes + from .main import routes as main_routes app.include_router(healthcheck_routes.router, prefix="/healthcheck") app.include_router( - greetings_routes.router, - prefix=f"{config.get('BASE_URI')}/greetings", - tags=["Examples"], + main_routes.router, + prefix=config.get("BASE_URI"), ) return app diff --git a/app/greetings/routes.py b/app/greetings/routes.py deleted file mode 100644 index 34a6aa2..0000000 --- a/app/greetings/routes.py +++ /dev/null @@ -1,26 +0,0 @@ -from app.greetings import router -from pydantic import BaseModel - - -class GreetingResponse(BaseModel): - message: str - - def __init__(self, greeting: str, name: str): - super().__init__(message=f"{greeting}, {name}") - - -@router.get("/hello/") -async def hello( - name: str, -) -> GreetingResponse: - response = GreetingResponse(greeting="Hello", name=name) - return response - - -@router.get("/{greeting}/") -async def greeting( - greeting: str, - name: str, -) -> GreetingResponse: - response = GreetingResponse(greeting=greeting, name=name) - return response diff --git a/app/greetings/__init__.py b/app/main/__init__.py similarity index 50% rename from app/greetings/__init__.py rename to app/main/__init__.py index 35f91a9..88ca7f8 100644 --- a/app/greetings/__init__.py +++ b/app/main/__init__.py @@ -2,4 +2,4 @@ router = APIRouter() -from app.greetings import routes # noqa: E402,F401 +from app.main import routes # noqa: E402,F401 diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..94403ce --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,135 @@ +import datetime + +import requests +from app.main import router +from fastapi import Request +from pydantic import BaseModel +from pydash import objects + + +class ClosingTime(BaseModel): + close: str + + +class OpeningTimes(ClosingTime): + open: str + + +class OpeningTimesDay(OpeningTimes): + day: str + day_alt: str + + def __init__(self, open: str, close: str, day_number: int): + days = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ] + today_dow = (datetime.datetime.today().weekday() + 1) % 7 + day_alt = "" + if today_dow == day_number: + day_alt = "today" + elif (today_dow + 1) % 7 == day_number: + day_alt = "tomorrow" + super().__init__( + open=open, close=close, day=days[day_number], day_alt=day_alt + ) + + +@router.get("/is-open-now/") +async def is_open_now( + request: Request, +) -> bool: + config = request.app.state.config + url = f"https://maps.googleapis.com/maps/api/place/details/json?fields=current_opening_hours%2Copening_hours%2Csecondary_opening_hours&place_id={config['GOOGLE_MAPS_PLACE_ID']}&key={config['GOOGLE_MAPS_API_KEY']}" + response = requests.get(url) + if response.status_code == 404: + raise Exception("Resource not found") + if response.status_code != requests.codes.ok: + raise ConnectionError("Request to API failed") + try: + result = response.json() + except ValueError: + raise ConnectionError("Cannot parse JSON") + return objects.get(result, "result.current_opening_hours.open_now") or False + + +@router.get("/today/") +async def today( + request: Request, +) -> OpeningTimes: + config = request.app.state.config + url = f"https://maps.googleapis.com/maps/api/place/details/json?fields=current_opening_hours%2Copening_hours%2Csecondary_opening_hours&place_id={config['GOOGLE_MAPS_PLACE_ID']}&key={config['GOOGLE_MAPS_API_KEY']}" + response = requests.get(url) + if response.status_code == 404: + raise Exception("Resource not found") + if response.status_code != requests.codes.ok: + raise ConnectionError("Request to API failed") + try: + result = response.json() + except ValueError: + raise ConnectionError("Cannot parse JSON") + today_dow = (datetime.datetime.today().weekday() + 1) % 7 + if current_opening_hours := objects.get( + result, "result.current_opening_hours" + ): + if periods := objects.get(current_opening_hours, "periods"): + today_opening_times = ( + next( + period + for period in periods + if period["open"]["day"] == today_dow + ) + or periods[0] + ) + return OpeningTimes( + open=today_opening_times["open"]["time"], + close=today_opening_times["close"]["time"], + ) + raise ConnectionError("Cannot parse data") + + +@router.get("/next-open/") +async def next_open( + request: Request, +) -> OpeningTimesDay: + config = request.app.state.config + url = f"https://maps.googleapis.com/maps/api/place/details/json?fields=current_opening_hours%2Copening_hours%2Csecondary_opening_hours&place_id={config['GOOGLE_MAPS_PLACE_ID']}&key={config['GOOGLE_MAPS_API_KEY']}" + response = requests.get(url) + if response.status_code == 404: + raise Exception("Resource not found") + if response.status_code != requests.codes.ok: + raise ConnectionError("Request to API failed") + try: + result = response.json() + except ValueError: + raise ConnectionError("Cannot parse JSON") + print(result) + if current_opening_hours := objects.get( + result, "result.current_opening_hours" + ): + if periods := objects.get(current_opening_hours, "periods"): + today_dow = (datetime.datetime.today().weekday() + 1) % 7 + now = datetime.datetime.now().strftime("%H%M") + next_opening_times = ( + next( + period + for period in periods + if period["open"]["day"] > today_dow + or ( + period["open"]["day"] == today_dow + and int(period["open"]["time"]) > int(now) + ) + ) + or periods[0] + ) + return OpeningTimesDay( + open=next_opening_times["open"]["time"], + close=next_opening_times["close"]["time"], + day_number=next_opening_times["open"]["day"], + ) + raise ConnectionError("Cannot parse data") diff --git a/config.py b/config.py index c2120ca..888a451 100644 --- a/config.py +++ b/config.py @@ -19,6 +19,9 @@ class Base(object): FORCE_HTTPS: bool = strtobool(os.getenv("FORCE_HTTPS", "True")) + GOOGLE_MAPS_API_KEY: str = os.getenv("GOOGLE_MAPS_API_KEY", "") + GOOGLE_MAPS_PLACE_ID: str = os.getenv("GOOGLE_MAPS_PLACE_ID", "") + class Production(Base, Features): pass diff --git a/docker-compose.yml b/docker-compose.yml index 5de5f9b..25d870e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,16 +5,21 @@ services: args: IMAGE: ghcr.io/nationalarchives/tna-python IMAGE_TAG: preview + env_file: .env environment: - ENVIRONMENT=develop - CONFIG=config.Develop - SECRET_KEY=abc123 + - GOOGLE_MAPS_PLACE_ID=ChIJYe6r5ngOdkgRf6SVcbMsQ0k ports: - 65531:8080 volumes: - ./:/app dev: image: ghcr.io/nationalarchives/tna-python-dev:preview + env_file: .env + environment: + - GOOGLE_MAPS_PLACE_ID=ChIJYe6r5ngOdkgRf6SVcbMsQ0k volumes: - ./:/app diff --git a/poetry.lock b/poetry.lock index 52d2e1e..c5ee634 100644 --- a/poetry.lock +++ b/poetry.lock @@ -399,6 +399,23 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pydash" +version = "8.0.3" +description = "The kitchen sink of Python utility libraries for doing \"stuff\" in a functional way. Based on the Lo-Dash Javascript library." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydash-8.0.3-py3-none-any.whl", hash = "sha256:c16871476822ee6b59b87e206dd27888240eff50a7b4cd72a4b80b43b6b994d7"}, + {file = "pydash-8.0.3.tar.gz", hash = "sha256:1b27cd3da05b72f0e5ff786c523afd82af796936462e631ffd1b228d91f8b9aa"}, +] + +[package.dependencies] +typing-extensions = ">3.10,<4.6.0 || >4.6.0" + +[package.extras] +dev = ["build", "coverage", "furo", "invoke", "mypy", "pytest", "pytest-cov", "pytest-mypy-testing", "ruff", "sphinx", "sphinx-autodoc-typehints", "tox", "twine", "wheel"] + [[package]] name = "pytest" version = "8.3.2" @@ -516,4 +533,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "d96285cb42f2978314c2204dd1ffc0fa16e15a7048129cfd0015e5a1cb108bfb" +content-hash = "4588921c99dc835d8286af024328ba41e2efdbf91d76cfcb26d7c034365d4683" diff --git a/pyproject.toml b/pyproject.toml index bf2b8ad..6fba486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "tna-fastapi-application" +name = "ds-opening-times-api" version = "0.1.0" description = "" authors = ["Andrew Hosgood "] @@ -10,6 +10,7 @@ packages = [{include = "app"}] python = "^3.12" requests = "^2.31.0" fastapi = "^0.112.0" +pydash = "^8.0.3" [tool.poetry.group.dev] optional = true diff --git a/test/test_greetings.py b/test/test_greetings.py deleted file mode 100644 index 9c4a35b..0000000 --- a/test/test_greetings.py +++ /dev/null @@ -1,33 +0,0 @@ -import json -import unittest - -from fastapi.testclient import TestClient - -from app import create_app - - -class HealthcheckBlueprintTestCase(unittest.TestCase): - def setUp(self): - self.app = create_app("config.Test") - self.client = TestClient(self.app) - self.domain = "http://localhost" - - def test_hello(self): - rv = self.client.get( - f"{self.app.state.config.get('BASE_URI')}/greetings/hello/", - params={"name": "John Smith"}, - ) - self.assertEqual(rv.status_code, 200) - response_json = json.loads(rv.text) - self.assertIn("message", response_json) - self.assertEqual(response_json["message"], "Hello, John Smith") - - def test_custom_greeting(self): - rv = self.client.get( - f"{self.app.state.config.get('BASE_URI')}/greetings/Heya/", - params={"name": "John Smith"}, - ) - self.assertEqual(rv.status_code, 200) - response_json = json.loads(rv.text) - self.assertIn("message", response_json) - self.assertEqual(response_json["message"], "Heya, John Smith") diff --git a/test/test_main.py b/test/test_main.py new file mode 100644 index 0000000..63ff0ea --- /dev/null +++ b/test/test_main.py @@ -0,0 +1,47 @@ +import json +import unittest + +from fastapi.testclient import TestClient + +from app import create_app + + +class MainBlueprintTestCase(unittest.TestCase): + def setUp(self): + self.app = create_app("config.Test") + self.client = TestClient(self.app) + self.domain = "http://localhost" + + def test_is_open_now(self): + rv = self.client.get( + f"{self.app.state.config.get('BASE_URI')}/is-open-now/", + ) + self.assertEqual(rv.status_code, 200) + response_json = json.loads(rv.text) + self.assertIsInstance(response_json, bool) + + def test_today(self): + rv = self.client.get( + f"{self.app.state.config.get('BASE_URI')}/today/", + ) + self.assertEqual(rv.status_code, 200) + response_json = json.loads(rv.text) + self.assertIn("open", response_json) + self.assertIsInstance(response_json["open"], str) + self.assertIn("close", response_json) + self.assertIsInstance(response_json["close"], str) + + def test_next_open(self): + rv = self.client.get( + f"{self.app.state.config.get('BASE_URI')}/next-open/", + ) + self.assertEqual(rv.status_code, 200) + response_json = json.loads(rv.text) + self.assertIn("open", response_json) + self.assertIsInstance(response_json["open"], str) + self.assertIn("close", response_json) + self.assertIsInstance(response_json["close"], str) + self.assertIn("day", response_json) + self.assertIsInstance(response_json["day"], str) + self.assertIn("day_alt", response_json) + self.assertIsInstance(response_json["day_alt"], str)