diff --git a/README.md b/README.md index 771a94f6..53e5f016 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,6 @@ export COOKIE_SECRET=$(openssl rand -hex 50) export OIDC_SERVER=https://auth.cern.ch/auth export OIDC_REALM=CERN export CLIENT_ID=caimira-test -export CLIENT_SECRET ``` Run docker-compose: @@ -324,6 +323,38 @@ Please note that there is no need for keys on this API call. It is **free-of-cha - **Humidity and Inside Temperature:** There is the possibility of using one external API call to fetch information related to a location specified in the UI. The data is related to the inside temperature and humidity taken from an indoor measurement device. Note that the API currently used from ARVE is only available for the `CERN theme` as the authorised sensors are installed at CERN." +- **ARVE:** + +The ARVE Swiss Air Quality System provides trusted air data for commercial buildings in real-time and analyzes it with the help of AI and machine learning algorithms to create actionable insights. + +Create secret: + +```console +$ read ARVE_CLIENT_ID +$ read ARVE_CLIENT_SECRET +$ read ARVE_API_KEY +$ oc create secret generic \ + --from-literal="ARVE_CLIENT_ID=$ARVE_CLIENT_ID" \ + --from-literal="ARVE_CLIENT_SECRET=$ARVE_CLIENT_SECRET" \ + --from-literal="ARVE_API_KEY=$ARVE_API_KEY" \ + arve-api +``` + +- **CERN Data Service:** + +The CERN data service collects data from various sources and expose them via a REST API endpoint. + +Create secret: + +```console +$ read DATA_SERVICE_CLIENT_EMAIL +$ read DATA_SERVICE_CLIENT_PASSWORD +$ oc create secret generic \ + --from-literal="DATA_SERVICE_CLIENT_EMAIL=$DATA_SERVICE_CLIENT_EMAIL" \ + --from-literal="DATA_SERVICE_CLIENT_PASSWORD=$DATA_SERVICE_CLIENT_PASSWORD" \ + data-service-api +``` + ## Update configuration If you need to **update** existing configuration, then modify this repository and after having logged in, run: diff --git a/app-config/calculator-app/app.sh b/app-config/calculator-app/app.sh index 15c94234..8bf1d5c0 100755 --- a/app-config/calculator-app/app.sh +++ b/app-config/calculator-app/app.sh @@ -23,6 +23,7 @@ if [[ "$APP_NAME" == "calculator-app" ]]; then export "EXTRA_PAGES"="$EXTRA_PAGES" + export "DATA_SERVICE_ENABLED"="${DATA_SERVICE_ENABLED:=False}" export "DATA_SERVICE_CLIENT_EMAIL"="$DATA_SERVICE_CLIENT_EMAIL" export "DATA_SERVICE_CLIENT_PASSWORD"="$DATA_SERVICE_CLIENT_PASSWORD" diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index d9ddb34a..1be361b2 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -20,6 +20,7 @@ services: - APPLICATION_ROOT=/ - CAIMIRA_CALCULATOR_PREFIX=/calculator-cern - CAIMIRA_THEME=caimira/apps/templates/cern + - DATA_SERVICE_ENABLED=False user: ${CURRENT_UID} calculator-open-app: @@ -29,6 +30,7 @@ services: - APP_NAME=calculator-app - APPLICATION_ROOT=/ - CAIMIRA_CALCULATOR_PREFIX=/calculator-open + - DATA_SERVICE_ENABLED=False user: ${CURRENT_UID} auth-service: diff --git a/app-config/openshift/deploymentconfig.yaml b/app-config/openshift/deploymentconfig.yaml index e96d65a1..43357153 100644 --- a/app-config/openshift/deploymentconfig.yaml +++ b/app-config/openshift/deploymentconfig.yaml @@ -283,6 +283,18 @@ secretKeyRef: key: ARVE_API_KEY name: arve-api + - name: DATA_SERVICE_ENABLED + value: 'False' + - name: DATA_SERVICE_CLIENT_EMAIL + valueFrom: + secretKeyRef: + key: DATA_SERVICE_CLIENT_EMAIL + name: data-service-api + - name: DATA_SERVICE_CLIENT_PASSWORD + valueFrom: + secretKeyRef: + key: DATA_SERVICE_CLIENT_PASSWORD + name: data-service-api image: '${PROJECT_NAME}/calculator-app' ports: - containerPort: 8080 @@ -360,6 +372,8 @@ value: / - name: CAIMIRA_CALCULATOR_PREFIX value: /calculator-open + - name: DATA_SERVICE_ENABLED + value: 'False' image: '${PROJECT_NAME}/calculator-app' ports: - containerPort: 8080 diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py index b3954128..0b014d59 100644 --- a/caimira/apps/calculator/__init__.py +++ b/caimira/apps/calculator/__init__.py @@ -107,15 +107,16 @@ class ConcentrationModel(BaseRequestHandler): start = datetime.datetime.now() # Data Service API Integration + fetched_service_data = None 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) - + if self.settings["data_service"]: + try: + fetched_service_data = await data_service.fetch() + 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: @@ -134,7 +135,7 @@ class ConcentrationModel(BaseRequestHandler): timeout=300, ) # Re-generate the report with the conditional probability of infection plot - if self.get_cookie('conditional_plot'): + if self.get_cookie('conditional_plot'): form.conditional_probability_plot = True if self.get_cookie('conditional_plot') == '1' else False self.clear_cookie('conditional_plot') # Clears cookie after changing the form value. @@ -260,7 +261,7 @@ class ArveData(BaseRequestHandler): http_client = AsyncHTTPClient() URL = 'https://arveapi.auth.eu-central-1.amazoncognito.com/oauth2/token' - headers = { "Content-Type": "application/x-www-form-urlencoded", + headers = { "Content-Type": "application/x-www-form-urlencoded", "Authorization": b"Basic " + base64.b64encode(f'{client_id}:{client_secret}'.encode()) } @@ -390,7 +391,7 @@ def make_app( 'active_page': 'about', 'filename': 'about.html.j2'}), (get_root_calculator_url(r'/user-guide'), GenericExtraPage, { - 'active_page': 'calculator/user-guide', + 'active_page': 'calculator/user-guide', 'filename': 'userguide.html.j2'}), ] @@ -407,7 +408,7 @@ def make_app( pass for extra in pages: - urls.append((get_root_url(r'%s' % extra['url_path']), + urls.append((get_root_url(r'%s' % extra['url_path']), GenericExtraPage, { 'active_page': extra['url_path'].strip('/'), 'filename': extra['filename'], })) @@ -433,6 +434,9 @@ def make_app( 'data_service_client_email': os.environ.get('DATA_SERVICE_CLIENT_EMAIL', None), 'data_service_client_password': os.environ.get('DATA_SERVICE_CLIENT_PASSWORD', None), } + data_service = None + if bool(os.environ.get('DATA_SERVICE_ENABLED', False)): + data_service = DataService(data_service_credentials) if debug: tornado.log.enable_pretty_logging() @@ -452,7 +456,7 @@ def make_app( arve_api_key=os.environ.get('ARVE_API_KEY', None), # Data Service Integration - data_service = DataService(data_service_credentials), + data_service=data_service, # Process parallelism controls. There is a balance between serving a single report # requests quickly or serving multiple requests concurrently. diff --git a/caimira/apps/calculator/data_service.py b/caimira/apps/calculator/data_service.py index 2d480780..f3705970 100644 --- a/caimira/apps/calculator/data_service.py +++ b/caimira/apps/calculator/data_service.py @@ -1,6 +1,7 @@ import dataclasses import json import logging +import typing from tornado.httpclient import AsyncHTTPClient, HTTPRequest @@ -10,8 +11,8 @@ LOG = logging.getLogger(__name__) @dataclasses.dataclass class DataService(): ''' - Responsible for establishing a connection to a - database through a REST API by handling authentication + 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. ''' @@ -20,8 +21,20 @@ class DataService(): # Host URL for the CAiMIRA Data Service API host: str = 'https://caimira-data-api.app.cern.ch' - - async def login(self): + + # Cached access token + _access_token: typing.Optional[str] = None + + def _is_valid(self, access_token): + # decode access_token + # check validity + return False + + async def _login(self): + if self._is_valid(self._access_token): + return self._access_token + + # invalid access_token, fetch it again client_email = self.credentials["data_service_client_email"] client_password = self.credentials['data_service_client_password'] @@ -41,9 +54,12 @@ class DataService(): ), raise_error=True) - return json.loads(response.body)['access_token'] - - async def fetch(self, access_token: str): + self._access_token = json.loads(response.body)['access_token'] + return self._access_token + + async def fetch(self): + access_token = await self._login() + http_client = AsyncHTTPClient() headers = {'Authorization': f'Bearer {access_token}'} diff --git a/caimira/tests/test_data_service.py b/caimira/tests/test_data_service.py index be2cb106..01cf176c 100644 --- a/caimira/tests/test_data_service.py +++ b/caimira/tests/test_data_service.py @@ -27,7 +27,7 @@ class DataServiceTests(unittest.TestCase): mock_http_client.return_value.fetch = mock_fetch # Call the login method - access_token = await self.data_service.login() + access_token = await self.data_service._login() # Assert that the access token is returned correctly self.assertEqual(access_token, "dummy_token") @@ -60,8 +60,8 @@ class DataServiceTests(unittest.TestCase): 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) + self.data_service._access_token = "dummy_token" + data = await self.data_service.fetch() # Assert that the data is returned correctly self.assertEqual(data, {"data": "dummy_data"}) @@ -80,8 +80,8 @@ class DataServiceTests(unittest.TestCase): 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) + self.data_service._access_token = "dummy_token" + data = await self.data_service.fetch() # Assert that the fetch method returns None in case of an error self.assertIsNone(data)