diff --git a/app-config/calculator-app/app.sh b/app-config/calculator-app/app.sh index 6a808f8c..15c94234 100755 --- a/app-config/calculator-app/app.sh +++ b/app-config/calculator-app/app.sh @@ -23,6 +23,9 @@ if [[ "$APP_NAME" == "calculator-app" ]]; then export "EXTRA_PAGES"="$EXTRA_PAGES" + export "DATA_SERVICE_CLIENT_EMAIL"="$DATA_SERVICE_CLIENT_EMAIL" + export "DATA_SERVICE_CLIENT_PASSWORD"="$DATA_SERVICE_CLIENT_PASSWORD" + echo "Starting the caimira webservice with: python -m caimira.apps.calculator ${args[@]}" python -m caimira.apps.calculator "${args[@]}" elif [[ "$APP_NAME" == "caimira-voila" ]]; then diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py index b7e11c4c..b3954128 100644 --- a/caimira/apps/calculator/__init__.py +++ b/caimira/apps/calculator/__init__.py @@ -19,7 +19,6 @@ import typing import uuid import zlib - import jinja2 import loky from tornado.web import Application, RequestHandler, StaticFileHandler @@ -29,6 +28,7 @@ import tornado.log from . import markdown_tools from . import model_generator from .report_generator import ReportGenerator, calculate_report_data +from .data_service import DataService from .user import AuthenticatedUser, AnonymousUser # The calculator version is based on a combination of the model version and the @@ -38,12 +38,13 @@ from .user import AuthenticatedUser, AnonymousUser # calculator version. If the calculator needs to make breaking changes (e.g. change # form attributes) then it can also increase its MAJOR version without needing to # increase the overall CAiMIRA version (found at ``caimira.__version__``). -__version__ = "4.11" +__version__ = "4.12" LOG = logging.getLogger(__name__) - + class BaseRequestHandler(RequestHandler): + async def prepare(self): """Called at the beginning of a request before `get`/`post`/etc.""" @@ -104,7 +105,17 @@ class ConcentrationModel(BaseRequestHandler): from pprint import pprint pprint(requested_model_config) start = datetime.datetime.now() - + + # Data Service API Integration + data_service: DataService = self.settings["data_service"] + try: + access_token = await data_service.login() + service_data = await data_service.fetch(access_token) + except Exception as err: + error_message = f"Something went wrong with the data service: {str(err)}" + LOG.error(error_message, exc_info=True) + self.send_error(500, reason=error_message) + try: form = model_generator.FormData.from_dict(requested_model_config) except Exception as err: @@ -417,6 +428,11 @@ def make_app( ) template_environment.globals['get_url']=get_root_url template_environment.globals['get_calculator_url']=get_root_calculator_url + + data_service_credentials = { + 'data_service_client_email': os.environ.get('DATA_SERVICE_CLIENT_EMAIL', None), + 'data_service_client_password': os.environ.get('DATA_SERVICE_CLIENT_PASSWORD', None), + } if debug: tornado.log.enable_pretty_logging() @@ -435,6 +451,9 @@ def make_app( arve_client_secret=os.environ.get('ARVE_CLIENT_SECRET', None), arve_api_key=os.environ.get('ARVE_API_KEY', None), + # Data Service Integration + data_service = DataService(data_service_credentials), + # Process parallelism controls. There is a balance between serving a single report # requests quickly or serving multiple requests concurrently. # The defaults are: handle one report at a time, and allow parallelism diff --git a/caimira/apps/calculator/data_service.py b/caimira/apps/calculator/data_service.py new file mode 100644 index 00000000..2d480780 --- /dev/null +++ b/caimira/apps/calculator/data_service.py @@ -0,0 +1,58 @@ +import dataclasses +import json +import logging + +from tornado.httpclient import AsyncHTTPClient, HTTPRequest + +LOG = logging.getLogger(__name__) + + +@dataclasses.dataclass +class DataService(): + ''' + Responsible for establishing a connection to a + database through a REST API by handling authentication + and fetching data. It utilizes the Tornado web framework + for asynchronous HTTP requests. + ''' + # Credentials used for authentication + credentials: dict + + # Host URL for the CAiMIRA Data Service API + host: str = 'https://caimira-data-api.app.cern.ch' + + async def login(self): + client_email = self.credentials["data_service_client_email"] + client_password = self.credentials['data_service_client_password'] + + if (client_email == None or client_password == None): + # If the credentials are not defined, an exception is raised. + raise Exception("DataService credentials not set") + + http_client = AsyncHTTPClient() + headers = {'Content-type': 'application/json'} + json_body = { "email": f"{client_email}", "password": f"{client_password}"} + + response = await http_client.fetch(HTTPRequest( + url=self.host + '/login', + method='POST', + headers=headers, + body=json.dumps(json_body), + ), + raise_error=True) + + return json.loads(response.body)['access_token'] + + async def fetch(self, access_token: str): + http_client = AsyncHTTPClient() + headers = {'Authorization': f'Bearer {access_token}'} + + response = await http_client.fetch(HTTPRequest( + url=self.host + '/data', + method='GET', + headers=headers, + ), + raise_error=True) + + return json.loads(response.body) + \ No newline at end of file diff --git a/caimira/tests/test_data_service.py b/caimira/tests/test_data_service.py new file mode 100644 index 00000000..be2cb106 --- /dev/null +++ b/caimira/tests/test_data_service.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass + +import unittest +from unittest.mock import patch, MagicMock +from tornado.httpclient import HTTPError + +from caimira.apps.calculator.data_service import DataService + +@dataclass +class MockResponse: + body: str + +class DataServiceTests(unittest.TestCase): + def setUp(self): + # Set up any necessary test data or configurations + self.credentials = { + "data_service_client_email": "test@example.com", + "data_service_client_password": "password123" + } + self.data_service = DataService(self.credentials) + + @patch('caimira.apps.calculator.data_service.AsyncHTTPClient') + async def test_login_successful(self, mock_http_client): + # Mock successful login response + mock_response = MockResponse('{"access_token": "dummy_token"}') + mock_fetch = MagicMock(return_value=mock_response) + mock_http_client.return_value.fetch = mock_fetch + + # Call the login method + access_token = await self.data_service.login() + + # Assert that the access token is returned correctly + self.assertEqual(access_token, "dummy_token") + + # Verify that the fetch method was called with the expected arguments + mock_fetch.assert_called_once_with( + url='https://caimira-data-api.app.cern.ch/login', + method='POST', + headers={'Content-type': 'application/json'}, + body='{"email": "test@example.com", "password": "password123"}' + ) + + @patch('caimira.apps.calculator.data_service.AsyncHTTPClient') + async def test_login_error(self, mock_http_client): + # Mock login error response + mock_fetch = MagicMock(side_effect=HTTPError(500)) + mock_http_client.return_value.fetch = mock_fetch + + # Call the login method + access_token = await self.data_service.login() + + # Assert that the login method returns None in case of an error + self.assertIsNone(access_token) + + @patch('caimira.apps.calculator.data_service.AsyncHTTPClient') + async def test_fetch_successful(self, mock_http_client): + # Mock successful fetch response + mock_response = MockResponse('{"data": "dummy_data"}') + mock_fetch = MagicMock(return_value=mock_response) + mock_http_client.return_value.fetch = mock_fetch + + # Call the fetch method with a mock access token + access_token = "dummy_token" + data = await self.data_service.fetch(access_token) + + # Assert that the data is returned correctly + self.assertEqual(data, {"data": "dummy_data"}) + + # Verify that the fetch method was called with the expected arguments + mock_fetch.assert_called_once_with( + url='https://caimira-data-api.app.cern.ch/data', + method='GET', + headers={'Authorization': 'Bearer dummy_token'} + ) + + @patch('caimira.apps.calculator.data_service.AsyncHTTPClient') + async def test_fetch_error(self, mock_http_client): + # Mock fetch error response + mock_fetch = MagicMock(side_effect=HTTPError(404)) + mock_http_client.return_value.fetch = mock_fetch + + # Call the fetch method with a mock access token + access_token = "dummy_token" + data = await self.data_service.fetch(access_token) + + # Assert that the fetch method returns None in case of an error + self.assertIsNone(data)