diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3be94888..9284c75f 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,7 +1,6 @@ name: Run API and App Tests on: push: - branches: [main] pull_request: branches: [main] # This is also a reusable workflow that can be called from other workflows @@ -28,10 +27,10 @@ jobs: - name: Install API dev Dependencies run: | pip install -r requirements-dev.txt -# - name: Test with tox -# run: | -# # Use tox because it is configured to test against the same package type being deployed -# tox + - name: Test with tox + run: | + # Use tox because it is configured to test against the same package type being deployed + tox test-app: runs-on: ubuntu-latest defaults: diff --git a/README.md b/README.md index eb48c277..c9ebb93b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ In the production environment, each of these services along with `nginx` are dep ## Build Instructions -Before you can build the project, you will require a `.env` file containing access keys to the application third party services. Please message a team member on the [#home-unite-us slack channel](https://hackforla.slack.com/archives/CRWUG7X0C) once you've completed onboarding. +Before you can build the project, you will require a `.env` file containing access keys to the application third party services. Please message a team member on the [#home-unite-us slack channel](https://hackforla.slack.com/archives/CRWUG7X0C) once you've completed onboarding. See the [api](./api/README.md) and [app](./app/README.md) READMEs for more information about the required and optional environment variables. Since this project is dockerized, you can choose to either build the backend and frontend apps as docker containers or directly onto your local machine. This guide will focus on docker builds, but full local build and deployment instructions can be found in the [api](./api/README.md) and [app](./app/README.md) READMEs. diff --git a/api/.env.dev.example b/api/.env.dev.example new file mode 100644 index 00000000..efd709b5 --- /dev/null +++ b/api/.env.dev.example @@ -0,0 +1,5 @@ +# The development configuration uses 3rd party service +# API mocking, and enables debugging and server hot reloading +# by default. No additional environment variables are required, +# but see DevelopmentHUUConfig for the full range of options. +ENV='development' \ No newline at end of file diff --git a/api/.env.example b/api/.env.example deleted file mode 100644 index 943e035f..00000000 --- a/api/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -COGNITO_CLIENT_ID= -COGNITO_CLIENT_SECRET= -COGNITO_REGION= -COGNITO_REDIRECT_URI=http://localhost:4040/signin -COGNITO_USER_POOL_ID= -SECRET_KEY= -COGNITO_ACCESS_ID= -COGNITO_ACCESS_KEY= -ROOT_URL=http://localhost:4040 \ No newline at end of file diff --git a/api/.env.prod.example b/api/.env.prod.example new file mode 100644 index 00000000..74c6e44a --- /dev/null +++ b/api/.env.prod.example @@ -0,0 +1,15 @@ +# Production configuration does not allow mocking, debugging +# or testing. Configuration values are strictly validated where possible. +# The following values are required for staging config. +# See ProductionHUUConfig for all options. +ENV='production' +COGNITO_CLIENT_ID= +COGNITO_CLIENT_SECRET= +COGNITO_REGION= +COGNITO_REDIRECT_URI=http://localhost:4040/signin +COGNITO_USER_POOL_ID= +SECRET_KEY= +COGNITO_ACCESS_ID= +COGNITO_ACCESS_KEY= +DATABASE_URL= +ROOT_URL=https://homeunite.us \ No newline at end of file diff --git a/api/.env.staging.example b/api/.env.staging.example new file mode 100644 index 00000000..6abd8142 --- /dev/null +++ b/api/.env.staging.example @@ -0,0 +1,15 @@ +# Staging configuration does not allow mocking, but +# does allow debugging and testing. +# The following values are required for staging config. +# See StagingHUUConfig for all options. +ENV='staging' +COGNITO_CLIENT_ID= +COGNITO_CLIENT_SECRET= +COGNITO_REGION= +COGNITO_REDIRECT_URI=http://localhost:4040/signin +COGNITO_USER_POOL_ID= +SECRET_KEY= +COGNITO_ACCESS_ID= +COGNITO_ACCESS_KEY= +DATABASE_URL= +ROOT_URL=https://dev.homeunite.us \ No newline at end of file diff --git a/api/README.md b/api/README.md index ce5fc7bd..fbd6f32e 100644 --- a/api/README.md +++ b/api/README.md @@ -20,7 +20,22 @@ Run `python -V` to check the Python version. ### Getting Started -For development purposes, run the following commands in the `api` directory to get started: +#### Configuration + +The API application configuration must be specified before running the application. Configuration variables can be specified either as environment variables, or as entries within a `.env` file located within the `api` directory. To get started, copy the values from one of these configurations into a `.env` file: + +* `.env.dev.example` + * [Recommended] This is the easiest configuration to use. It enables debugging, and mocks third party API calls so no secrets are needed to run the API. +* `.env.staging.example` + * This configuration strips all mocking from the application, but allows debugging and other testing related features. +* `.env.prod.example` + * This configuration strips all mocking and strictly validates the configuration to ensure that debugging is disabled and that the deployment is production-ready. + +Using the `staging` or `production` configurations will require access to deployment secrets. If access to these secrets are needed to test a feature locally then you can request the secrets in the HUU slack channel. + +#### Setup and Run + +Once the `.env` file has been configured using the instructions outlined above, run the following commands in the `api` directory to install the required development dependencies and run the application. ```shell python -m venv .venv # Creates a virtual environment in the `.venv` directory @@ -61,7 +76,7 @@ The `tox` tool was installed into your virtual environment using the `pip instal To launch the tests, run `tox`. -`tox` will run the tests for this (`api`) project using `pytest` in an isolated virtual environment that is distinct from the virtual environment that you created following the instructions from [Usage - Development](#usage---development). +`tox` will run the tests for this (`api`) project using `pytest` in an isolated virtual environment that is distinct from the virtual environment that you created following the instructions from [Usage - Development](#usage---development). By default `tox` will invoke `pytest` in `debug` mode, which uses 3rd party API mocking. The test cases can optionally be run without mocking by testing the application in `release` mode, using `tox -e releasetest` or `pytest --mode=release`. ### Alembic migrations @@ -145,7 +160,7 @@ SELECT * FROM public.user; to end the session just type `\q`. -If you want to clear the databse and start from scratch you can run: +If you want to clear the database and start from scratch you can run: ``` docker compose down -v @@ -174,53 +189,27 @@ docker exec $api_container_id pytest docker exec `docker ps -qf "ancestor=homeuniteus-api"` pytest ``` -### Configuration Profile -For local development, you can create your own set of configurations by doing the following: -- Add the variable below to your `.env` located in `/api`. -``` -CONFIG_PROFILE="personal" -``` -- Create the file `personal.py` at `/api/configs` with the following, -``` -from configs.configs import Config -class PersonalConfig(Config): - # Example config to override HOST - HOST = 8082 -``` -To reference configs in other modules you can do the following if it doesn't exist already, -``` -from flask import current_app -current_app.config['CONFIG'] -``` -If you create any new configuration properties, please add an associative enum to `/api/configs/configuration_properties.py`. - ### Debugging +Debugging is enabled when using the `development` configuration. It can also be enabled on the `staging` configuration by setting the `FLASK_DEBUG` environment variable to `True`, or adding a `FLASK_DEBUG=True` to your local `.env` file. When debugging is enabled, the API server will automatically reload each time you save a change to the source code. + For Visual Studio Code users: -- Set breakpoint(s). -- Add the following config below to `api/openapi_server/.vscode/launch.json` and replace "absolute path to folder" accordingly. -``` +* Set breakpoint(s). +* Add the following config below to your `/.vscode/launch.json` configuration. + +```json { - "name": "Python: Connexion", - "type": "python", - "request": "launch", - "module": "connexion", - "cwd": "${workspaceFolder}", - "env": { - "FLASK_APP": "openapi_server.__main__.py", - "FLASK_ENV": "development", - "FLASK_DEBUG": "1" - }, - "args": [ - "run", - "< absolute path to folder >/openapi_server", - "--port", - "8080" - ], - } -``` -- Go to `openapi_server/__main__.py` and select "Run" -> "Start with Debugging". + "name": "Openapi_server module", + "type": "python", + "request": "launch", + "module": "openapi_server", + "justMyCode": false, + "cwd": "${workspaceFolder}/api" +} +``` + +With this configuration selecting "Run" -> "Start with Debugging" will start the API with the debugger enabled. ## Usage - Production @@ -251,4 +240,3 @@ The demo environment is an AWS EC2 instance running Ubuntu. On the Ubuntu EC2 in For this API, a GitHub deployment workflow (found in .github/workflows) creates a Python `sdist` (Source Distribution) containing only the required files necessary for deployment, uploads it, installs the API, and restarts the `dev-homeuniteus-api` service. The Swagger UI for the API on the demo environment is at https://dev.homeunite.us/api/ui - diff --git a/api/configs/config_properties.py b/api/configs/config_properties.py deleted file mode 100644 index d46de6b8..00000000 --- a/api/configs/config_properties.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import Enum - -class ConfigProperties(Enum): - HOST = 'HOST' - PORT = 'PORT' - DEBUG = 'DEBUG' - USE_RELOADER = 'USE_RELOADER' \ No newline at end of file diff --git a/api/configs/configs.py b/api/configs/configs.py deleted file mode 100644 index 5c5b7924..00000000 --- a/api/configs/configs.py +++ /dev/null @@ -1,32 +0,0 @@ -import importlib - -''' -**READ ME** -Never put any sensitive information here i.e. credentials, secrets, etc. -''' - -class Config(): - HOST = '0.0.0.0' - PORT = 8080 - DEBUG = True - USE_RELOADER = True - - -# add class for new configuration profile here -# i.e. class ProductionConfig(Config) ... - - -def compile_config(profile: str, mod: str = 'configs.personal', clazz: str = 'PersonalConfig') -> Config: - config = Config() - - if profile == 'personal': - try: - personal_mod = importlib.import_module(mod) - personal_config = getattr(personal_mod, clazz) - config = personal_config - except ModuleNotFoundError: - raise - - # handle other profiles here - - return config \ No newline at end of file diff --git a/api/configs/personal_config_example.py b/api/configs/personal_config_example.py deleted file mode 100644 index 931c1fbe..00000000 --- a/api/configs/personal_config_example.py +++ /dev/null @@ -1,11 +0,0 @@ -from configs.configs import Config - -''' -README: -- Name your config 'personal.py' -- Name your class 'PersonalConfig'. And inherit from the Config class i.e. PersonalConfig(Config). -''' -class PersonalConfigExample(Config): - PORT = 8081 - DEBUG = False - USE_RELOADER = False \ No newline at end of file diff --git a/api/openapi_server/__main__.py b/api/openapi_server/__main__.py index fa991a19..4f95b8b1 100644 --- a/api/openapi_server/__main__.py +++ b/api/openapi_server/__main__.py @@ -1,103 +1,15 @@ -# Standard Lib -from os import environ as env -from pathlib import Path -from typing import Dict, Any - -# Third Party -import connexion -from dotenv import load_dotenv, find_dotenv, get_key -import prance - -# Local -from openapi_server.models.database import DataAccessLayer -from openapi_server.exceptions import AuthError, handle_auth_error -from configs.configs import compile_config -from configs.config_properties import ConfigProperties as cp - - -DataAccessLayer.db_init() - - -def get_bundled_specs(main_file: Path) -> Dict[str, Any]: - ''' - Prance is able to resolve references to local *.yaml files. - - Use prance to parse the api specification document. Connexion's - default parser is not able to handle local file references, but - our api specification is split across multiple files for readability. - - Args: - main_file (Path): Path to a api specification .yaml file - - Returns: - Dict[str, Any]: Parsed specification file, stored in a dict - ''' - parser = prance.ResolvingParser(str(main_file.absolute()), lazy=True, strict=True) - parser.parse() - - return parser.specification - -def get_version(): - from importlib.metadata import version, PackageNotFoundError - - try: - return version("homeuniteus-api") - except PackageNotFoundError: - # package is not installed - return "0.1.0.dev0" - -def create_app(): - ''' - Creates a configured application that is ready to be run. - - The application is configured from external environment variables stored - in either a local `.env` in a development environment or from environment - variables that are configured prior to running the application. - - This function is made available at the module level so that it can - be loaded by a WSGI server in a production-like environment with - `openapi_server.__main__:create_app()`. - ''' - - # `connexion.App` is aliased to `connexion.FlaskApp` (as of connexion v2.13.1) - # which is the connexion layer built on top of Flask. So we think of it as - # the connexion application. - connexion_app = connexion.App(__name__) - api_spec_path = connexion_app.get_root_path() / Path('./openapi/openapi.yaml') - parsed_specs = get_bundled_specs(api_spec_path) - - parsed_specs['info']['version'] = get_version() - - connexion_app.add_api(parsed_specs, pythonic_params=True) - connexion_app.add_error_handler(AuthError, handle_auth_error) - - ENV_FILE = find_dotenv() - if ENV_FILE: - load_dotenv(ENV_FILE) - SECRET_KEY = env.get("SECRET_KEY") - env_config_profile = get_key(find_dotenv(), "CONFIG_PROFILE") - - # The underlying instance of Flask is stored in `connexion_app.app`. - # This is an instance of `flask.Flask`. - flask_app = connexion_app.app - flask_app.secret_key = SECRET_KEY - - # Below, the Flask configuration handler is loaded with the - # application configuration settings - flask_app.config.from_object(compile_config(env_config_profile)) - - return connexion_app - +from openapi_server.app import create_app if __name__ == "__main__": - # This will/should be only run in a development environment - connexion_app = create_app() flask_app = connexion_app.app - + if flask_app.environment == "production": + raise EnvironmentError("The production configuration must be run on a " + "production server. Connexion's app.run() method " + "starts a development server that should be used " + "for testing purposes only.") connexion_app.run( - host=flask_app.config[cp.HOST.name], - port=flask_app.config[cp.PORT.name], - debug=flask_app.config[cp.DEBUG.name], - use_reloader=flask_app.config[cp.USE_RELOADER.name], + host=flask_app.config["HOST"], + port=flask_app.config["PORT"], + load_dotenv=False ) diff --git a/api/openapi_server/app.py b/api/openapi_server/app.py new file mode 100644 index 00000000..78565fcd --- /dev/null +++ b/api/openapi_server/app.py @@ -0,0 +1,162 @@ +import os +import hmac +import base64 +from pathlib import Path +from typing import Dict, Any +from importlib.metadata import version, PackageNotFoundError +from dotenv import load_dotenv, find_dotenv + +import prance +from flask import Flask +from connexion.apps.flask_app import ( + FlaskApp, + FlaskJSONEncoder, + NumberConverter, + IntegerConverter +) + +from openapi_server.models.database import DataAccessLayer +from openapi_server.exceptions import AuthError, handle_auth_error +from openapi_server.configs.registry import HUUConfigRegistry, HUUConfig + +class HUUFlaskApp(Flask): + ''' + The HUUFlaskApp can be accessed anywhere that the Flask application context + is active, by using the Flask `current_app` proxy. + ''' + def __init__(self,*args, **kwargs): + super().__init__(*args, **kwargs) + self._boto_client = None + + @property + def environment(self) -> str: + return self.config["ENV"] + + @property + def is_debug_app(self) -> bool: + return self.config["DEBUG"] + + @property + def root_url(self) -> str: + return self.config["ROOT_URL"] + + @property + def supports_aws_cognito(self) -> bool: + return all(key in self.config for key in [ + "COGNITO_REGION", + "COGNITO_ACCESS_ID", + "COGNITO_ACCESS_KEY" + ]) + + def assert_support_aws_cognito(self): + if not self.supports_aws_cognito: + raise NotImplementedError("The current application configuration does " + "not support AWS cognito. In the future we will " + "mock this functionality to enable for all " + "configurations. This feature is planned " + "in Issue #577") + + @property + def boto_client(self): + if self._boto_client: + return self._boto_client + + self.assert_support_aws_cognito() + + import boto3 + self._boto_client = boto3.client('cognito-idp', + region_name=self.config["COGNITO_REGION"], + aws_access_key_id=self.config["COGNITO_ACCESS_ID"], + aws_secret_access_key=self.config["COGNITO_ACCESS_KEY"] + ) + return self._boto_client + + def calc_secret_hash(self, username: str) -> str: + self.assert_support_aws_cognito() + message = username + self.config["COGNITO_CLIENT_ID"] + secret = bytearray(self.config["COGNITO_CLIENT_SECRET"], 'utf-8') + dig = hmac.new(secret, msg=message.encode('utf-8'), digestmod='sha256').digest() + return base64.b64encode(dig).decode() + +class HUUConnexionApp(FlaskApp): + def __init__(self, app_package_name: str, api_spec_rel_path: Path, *args, **kwargs): + super().__init__(app_package_name, *args, **kwargs) + + api_spec_path = self.get_root_path() / api_spec_rel_path + parsed_specs = self._get_bundled_specs(api_spec_path) + parsed_specs['info']['version'] = self._get_version() + + self.add_api(parsed_specs, pythonic_params=True) + + def create_app(self) -> HUUFlaskApp: + # Do not call super() here. We are overriding the default + # connexion create_app method, to return our HUUFlaskApp type + app = HUUFlaskApp(self.import_name, **self.server_args) + app.url_map.converters['float'] = NumberConverter + app.url_map.converters['int'] = IntegerConverter + return app + + @staticmethod + def _get_bundled_specs(spec_file: Path) -> Dict[str, Any]: + ''' + Prance is able to resolve references to local *.yaml files. + + Use prance to parse the api specification document. Connexion's + default parser is not able to handle local file references, but + our api specification is split across multiple files for readability. + + Args: + main_file (Path): Path to a api specification .yaml file + + Returns: + Dict[str, Any]: Parsed specification file, stored in a dict + ''' + parser = prance.ResolvingParser(str(spec_file.absolute()), lazy=True, strict=True) + parser.parse() + + return parser.specification + + @staticmethod + def _get_version(): + try: + return version("homeuniteus-api") + except PackageNotFoundError: + # package is not installed + return "0.1.0.dev0" + +def create_app(test_config: HUUConfig = None): + ''' + Creates a configured application that is ready to be run. + + The application is configured from external environment variables stored + in either a local `.env` in a development environment or from environment + variables that are configured prior to running the application. + + This function is made available at the module level so that it can + be loaded by a WSGI server in a production-like environment with + `openapi_server.app:create_app()`. + ''' + if test_config: + config = test_config + else: + env_file = find_dotenv() + if env_file: + # Load variables from a .env file and store as environment vars + load_dotenv(env_file) + + app_environment = os.getenv("ENV") + if not app_environment: + raise EnvironmentError("The ENV variable or a test configuration must be provided. This variable " + "is used to select the application configuration " + "at runtime. Available options are " + f"{HUUConfigRegistry.available_environments()}") + config = HUUConfigRegistry.load_config(app_environment) + + connexion_app = HUUConnexionApp(__name__, './openapi/openapi.yaml') + connexion_app.add_error_handler(AuthError, handle_auth_error) + + flask_app = connexion_app.app + flask_app.config.from_object(config) + DataAccessLayer.db_init(flask_app.config["DATABASE_URL"]) + + return connexion_app \ No newline at end of file diff --git a/api/configs/__init__.py b/api/openapi_server/configs/__init__.py similarity index 100% rename from api/configs/__init__.py rename to api/openapi_server/configs/__init__.py diff --git a/api/openapi_server/configs/development.py b/api/openapi_server/configs/development.py new file mode 100644 index 00000000..e23f431a --- /dev/null +++ b/api/openapi_server/configs/development.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from openapi_server.configs.huu_config import HUUConfig + +@dataclass(frozen=True) +class DevelopmentHUUConfig(HUUConfig): + ENV: str = "development" + FLASK_DEBUG: bool = True + PORT: int = 8080 + HOST: str = "127.0.0.1" + TESTING: bool = False + SECRET_KEY: str = "unsecurekey" + ROOT_URL: str = "http://localhost:4040" + DATABASE_URL: str = "sqlite:///./homeuniteus.db" + + def post_validate(self): + super().post_validate() + if (self.PORT < 0 or self.PORT > 65535): + raise ValueError("Port must be in the range 0-65535.") \ No newline at end of file diff --git a/api/openapi_server/configs/huu_config.py b/api/openapi_server/configs/huu_config.py new file mode 100644 index 00000000..1d1593e2 --- /dev/null +++ b/api/openapi_server/configs/huu_config.py @@ -0,0 +1,97 @@ +import os +from dataclasses import dataclass, field, fields +from typing import Any + +def secret_str_field() -> field: + """ + Used to identify configuration secrets. Secrets must be loaded from the + environment. Attempts to hard code these values will throw an exception. + """ + return field(default='', metadata={'is_secret': True}) + +@dataclass(frozen=True) +class HUUConfig: + """ + Specifies the configuration settings needed for all HUU application environments. + + Environment variables from the system can override all preset values. + """ + FLASK_DEBUG: bool + TESTING: bool + SECRET_KEY: str + ROOT_URL: str + DATABASE_URL: str + + # Define convenience aliases for FLASK_DEBUG. + # This configuration option is treated specially by Flask. It must + # be loaded as an environment variable before constructing the Flask + # application instance in order to work properly + + @property + def DEBUG(self): + return self.FLASK_DEBUG + + def __post_init__(self): + ''' + Each time a configuration object is initialized, __post_init__ + will read the configuration options from the environment, + override the field values with the available environment values, + and validate the options using the pre_validate() and post_validate(). + ''' + self.pre_validate() + + for field in fields(self): + env_value = os.environ.get(field.name) + if env_value is not None: + cast_value = self.parse_env_variable(field, env_value) + object.__setattr__(self, field.name, cast_value) + + self.post_validate() + + @staticmethod + def parse_env_variable(field: field, env_var: str) -> Any: + if (field.type == str): + return env_var + elif (field.type == bool): + if env_var == '': + raise ValueError(f"Failed to parse {field.name}. " + "Boolean vars must not be an empty string") + return env_var.strip().lower() not in {"0", "false"} + elif (field.type == int): + try: + return int(env_var) + except ValueError: + raise ValueError(f"Failed to parse {field.name}. " + "This env var must be set to a valid integer") + else: + raise NotImplementedError("Unrecognized configuration field type") + + def pre_validate(self): + ''' + Validate the configuration options before they are loaded + from the process environment variables. + + All fields marked with a SecretStr type must be loaded from + the environment. Attempts to hard code these values will result + in a ValueError here. + ''' + for field in fields(self): + if (field.metadata.get("is_secret")): + value = getattr(self, field.name) + if (value != ''): + raise ValueError(f"Secret field {field.name} cannot have hard-coded values. " + "These must be loaded directly from an " + "environment variable.") + if (os.environ.get(field.name) is None): + raise ValueError(f"Configuration option {field.name} must " + "be specified as an environment variable.") + + def post_validate(self): + ''' + Validate the final configuration options, after overwriting + the options using the process environment variables. + ''' + if not self.ROOT_URL: + raise ValueError('ROOT_URL is not defined in the application configuration.') + if not self.DATABASE_URL: + raise ValueError('DATABASE_URL is not defined in the application configuration.') \ No newline at end of file diff --git a/api/openapi_server/configs/production.py b/api/openapi_server/configs/production.py new file mode 100644 index 00000000..a25fe3e7 --- /dev/null +++ b/api/openapi_server/configs/production.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +import re + +from openapi_server.configs.huu_config import HUUConfig, secret_str_field + +@dataclass(frozen=True) +class ProductionHUUConfig(HUUConfig): + ENV: str = "production" + FLASK_DEBUG: bool = False + TESTING: bool = False + SECRET_KEY: str = secret_str_field() + DATABASE_URL: str = "" + ROOT_URL: str = "homeunite.us" + COGNITO_CLIENT_ID: str = secret_str_field() + COGNITO_CLIENT_SECRET: str = secret_str_field() + COGNITO_REGION: str = secret_str_field() + COGNITO_REDIRECT_URI: str = secret_str_field() + COGNITO_USER_POOL_ID: str = secret_str_field() + COGNITO_ACCESS_ID: str = secret_str_field() + COGNITO_ACCESS_KEY: str = secret_str_field() + + def post_validate(self): + super().post_validate() + self.validate_secret_key(self.SECRET_KEY) + if (self.FLASK_DEBUG): + raise ValueError("Debug mode is not supported by the production configuration.") + if (self.TESTING): + raise ValueError("Testing is not supported by the production configuration.") + if (not self.DATABASE_URL): + raise ValueError("DATABASE_URL must be specified as an environment variable.") + + def validate_secret_key(self, key): + "Soft check to ensure the key is at least 32 characters long." + if len(key) < 32: + raise ValueError(f"Production secret key '{key}' is not strong enough. " + "The key must be at least 16 characters long.") \ No newline at end of file diff --git a/api/openapi_server/configs/registry.py b/api/openapi_server/configs/registry.py new file mode 100644 index 00000000..8f6deff7 --- /dev/null +++ b/api/openapi_server/configs/registry.py @@ -0,0 +1,43 @@ +from enum import Enum, auto +from typing import Union + +from openapi_server.configs.huu_config import HUUConfig + +class HUUConfigRegistry(Enum): + DEVELOPMENT = auto() + STAGING = auto() + PRODUCTION = auto() + + @classmethod + def available_environments(cls) -> str: + return ",".join((env.name.lower() for env in cls)) + + @classmethod + def from_string(cls, parse_str: str) -> 'HUUConfigRegistry': + try: + return cls[parse_str.upper()] + except KeyError: + raise EnvironmentError(f"{parse_str} is not a valid environment. " + "Select one of the available options: " + f"{cls.available_environments()}") + + @classmethod + def load_config(cls, env: Union['HUUConfigRegistry', str]) -> HUUConfig: + if isinstance(env, str): + env = cls.from_string(env) + + match env: + case HUUConfigRegistry.DEVELOPMENT: + from .development import DevelopmentHUUConfig + return DevelopmentHUUConfig() + case HUUConfigRegistry.STAGING: + from .staging import StagingHUUConfig + return StagingHUUConfig() + case HUUConfigRegistry.PRODUCTION: + from .production import ProductionHUUConfig + return ProductionHUUConfig() + case _: + raise EnvironmentError(f"{env} does not have a registered " + "configuration type. Please update the " + "load_config method to register this new " + "environment type.") \ No newline at end of file diff --git a/api/openapi_server/configs/staging.py b/api/openapi_server/configs/staging.py new file mode 100644 index 00000000..37198f3e --- /dev/null +++ b/api/openapi_server/configs/staging.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from openapi_server.configs.huu_config import HUUConfig, secret_str_field + +@dataclass(frozen=True) +class StagingHUUConfig(HUUConfig): + ENV: str = "staging" + FLASK_DEBUG: bool = False + TESTING: bool = False + SECRET_KEY: str = secret_str_field() + DATABASE_URL: str = "sqlite:///./homeuniteus.db" + ROOT_URL: str = "dev.homeunite.us" + PORT: int = 8080 + HOST: str = "127.0.0.1" + COGNITO_CLIENT_ID: str = secret_str_field() + COGNITO_CLIENT_SECRET: str = secret_str_field() + COGNITO_REGION: str = secret_str_field() + COGNITO_REDIRECT_URI: str = secret_str_field() + COGNITO_USER_POOL_ID: str = secret_str_field() + COGNITO_ACCESS_ID: str = secret_str_field() + COGNITO_ACCESS_KEY: str = secret_str_field() + + def post_validate(self): + super().post_validate() + if (self.PORT < 0 or self.PORT > 65535): + raise ValueError("Port must be in the range 0-65535.") \ No newline at end of file diff --git a/api/openapi_server/controllers/admin_controller.py b/api/openapi_server/controllers/admin_controller.py index 52f20ff6..da84607a 100644 --- a/api/openapi_server/controllers/admin_controller.py +++ b/api/openapi_server/controllers/admin_controller.py @@ -1,31 +1,9 @@ import connexion -import boto3 - from openapi_server.controllers import auth_controller -from os import environ as env -from dotenv import load_dotenv, find_dotenv -from flask import redirect, request, session, redirect +from flask import session, current_app from openapi_server.exceptions import AuthError -from openapi_server.models import database as db -from sqlalchemy.orm import Session -from sqlalchemy.exc import IntegrityError -from functools import wraps - -# Define env variables -COGNITO_REGION=env.get('COGNITO_REGION') -COGNITO_CLIENT_ID=env.get('COGNITO_CLIENT_ID') -COGNITO_CLIENT_SECRET=env.get('COGNITO_CLIENT_SECRET') -COGNITO_USER_POOL_ID=env.get('COGNITO_USER_POOL_ID') -COGNITO_REDIRECT_URI = env.get('COGNITO_REDIRECT_URI') -COGNITO_ACCESS_ID = env.get('COGNITO_ACCESS_ID') -COGNITO_ACCESS_KEY = env.get('COGNITO_ACCESS_KEY') -SECRET_KEY=env.get('SECRET_KEY') - - - -userClient = boto3.client('cognito-idp', region_name=COGNITO_REGION, aws_access_key_id = COGNITO_ACCESS_ID, aws_secret_access_key = COGNITO_ACCESS_KEY) def initial_sign_in_reset_password(): """Sets initial password. @@ -42,11 +20,11 @@ def initial_sign_in_reset_password(): sessionId = body['sessionId'] try: - secret_hash = auth_controller.get_secret_hash(userId) + secret_hash = current_app.calc_secret_hash(userId) # call forgot password method - response = userClient.respond_to_auth_challenge( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.respond_to_auth_challenge( + ClientId=current_app.config['COGNITO_CLIENT_ID'], ChallengeName = 'NEW_PASSWORD_REQUIRED', Session=sessionId, ChallengeResponses = { @@ -63,7 +41,7 @@ def initial_sign_in_reset_password(): access_token = response['AuthenticationResult']['AccessToken'] refresh_token = response['AuthenticationResult']['RefreshToken'] - user_data = userClient.get_user(AccessToken=access_token) + user_data = current_app.boto_client.get_user(AccessToken=access_token) user = auth_controller.get_user_attr(user_data) session['refresh_token'] = refresh_token diff --git a/api/openapi_server/controllers/auth_controller.py b/api/openapi_server/controllers/auth_controller.py index 39c26a58..84420b6f 100644 --- a/api/openapi_server/controllers/auth_controller.py +++ b/api/openapi_server/controllers/auth_controller.py @@ -1,47 +1,20 @@ import connexion -import boto3 import botocore -import hmac -import base64 import requests -from os import environ as env -from dotenv import load_dotenv, find_dotenv -from flask import redirect, request, session +from flask import ( + redirect, + request, + session, + current_app # type: openapi_server.app.HUUFlaskApp +) from openapi_server.exceptions import AuthError from openapi_server.models.database import DataAccessLayer, User -from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from sqlalchemy import select -# Load .env file -ENV_FILE = find_dotenv() -if ENV_FILE: - load_dotenv(ENV_FILE) - -# Define env variables -COGNITO_REGION=env.get('COGNITO_REGION') -COGNITO_CLIENT_ID=env.get('COGNITO_CLIENT_ID') -COGNITO_CLIENT_SECRET=env.get('COGNITO_CLIENT_SECRET') -COGNITO_USER_POOL_ID=env.get('COGNITO_USER_POOL_ID') -COGNITO_REDIRECT_URI = env.get('COGNITO_REDIRECT_URI') -COGNITO_ACCESS_ID = env.get('COGNITO_ACCESS_ID') -COGNITO_ACCESS_KEY = env.get('COGNITO_ACCESS_KEY') -SECRET_KEY=env.get('SECRET_KEY') -ROOT_URL=env.get('ROOT_URL') -cognito_client_url = 'https://homeuniteus.auth.us-east-1.amazoncognito.com' - -if(ROOT_URL == None): - raise Exception('ROOT_URL is not defined in .env file') - -# Initialize Cognito clients -userClient = boto3.client('cognito-idp', region_name=COGNITO_REGION, aws_access_key_id = COGNITO_ACCESS_ID, aws_secret_access_key = COGNITO_ACCESS_KEY) -# Get secret hash -def get_secret_hash(username): - message = username + COGNITO_CLIENT_ID - dig = hmac.new(bytearray(COGNITO_CLIENT_SECRET, 'utf-8'), msg=message.encode('utf-8'), digestmod='sha256').digest() - return base64.b64encode(dig).decode() +cognito_client_url = 'https://homeuniteus.auth.us-east-1.amazoncognito.com' # Get user attributes from Cognito response def get_user_attr(user_data): @@ -91,7 +64,7 @@ def signUpHost(): # noqa: E501 if connexion.request.is_json: body = connexion.request.get_json() - secret_hash = get_secret_hash(body['email']) + secret_hash = current_app.calc_secret_hash(body['email']) # Signup user with DataAccessLayer.session() as session: @@ -106,13 +79,13 @@ def signUpHost(): # noqa: E501 }, 422) try: - response = userClient.sign_up( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.sign_up( + ClientId=current_app.config['COGNITO_CLIENT_ID'], SecretHash=secret_hash, Username=body['email'], Password=body['password'], ClientMetadata={ - 'url': ROOT_URL + 'url': current_app.root_url } ) @@ -146,7 +119,7 @@ def signUpCoordinator(): # noqa: E501 if connexion.request.is_json: body = connexion.request.get_json() - secret_hash = get_secret_hash(body['email']) + secret_hash = current_app.calc_secret_hash(body['email']) # Signup user with DataAccessLayer.session() as session: @@ -161,13 +134,13 @@ def signUpCoordinator(): # noqa: E501 }, 422) try: - response = userClient.sign_up( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.sign_up( + ClientId=current_app.config['COGNITO_CLIENT_ID'], SecretHash=secret_hash, Username=body['email'], Password=body['password'], ClientMetadata={ - 'url': ROOT_URL + 'url': current_app.root_url } ) @@ -201,12 +174,12 @@ def signin(): if connexion.request.is_json: body = connexion.request.get_json() - secret_hash = get_secret_hash(body['email']) + secret_hash = current_app.calc_secret_hash(body['email']) # initiate authentication try: - response = userClient.initiate_auth( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.initiate_auth( + ClientId=current_app.config['COGNITO_CLIENT_ID'], AuthFlow='USER_PASSWORD_AUTH', AuthParameters={ 'USERNAME': body['email'], @@ -227,13 +200,13 @@ def signin(): if(response.get('ChallengeName') and response['ChallengeName'] == 'NEW_PASSWORD_REQUIRED'): userId = response['ChallengeParameters']['USER_ID_FOR_SRP'] sessionId = response['Session'] - return redirect(f"{ROOT_URL}/create-password?userId={userId}&sessionId={sessionId}") + return redirect(f"{current_app.root_url}/create-password?userId={userId}&sessionId={sessionId}") access_token = response['AuthenticationResult']['AccessToken'] refresh_token = response['AuthenticationResult']['RefreshToken'] # retrieve user data - user_data = userClient.get_user(AccessToken=access_token) + user_data = current_app.boto_client.get_user(AccessToken=access_token) # create user object from user data user = get_user_attr(user_data) @@ -259,16 +232,16 @@ def resend_confirmation_code(): if "email" not in body: raise AuthError({"message": "email invalid"}, 400) - secret_hash = get_secret_hash(body['email']) + secret_hash = current_app.calc_secret_hash(body['email']) try: email = body['email'] - userClient.resend_confirmation_code( - ClientId=COGNITO_CLIENT_ID, + current_app.boto_client.resend_confirmation_code( + ClientId=current_app.config['COGNITO_CLIENT_ID'], SecretHash=secret_hash, Username=email, ClientMetadata={ - 'url': ROOT_URL + 'url': current_app.root_url } ) message = "A confirmation code is being sent again." @@ -294,11 +267,11 @@ def confirm(): if connexion.request.is_json: body = connexion.request.get_json() - secret_hash = get_secret_hash(body['email']) + secret_hash = current_app.calc_secret_hash(body['email']) try: - response = userClient.confirm_sign_up( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.confirm_sign_up( + ClientId=current_app.config['COGNITO_CLIENT_ID'], SecretHash=secret_hash, Username=body['email'], ConfirmationCode=body['code'], @@ -317,7 +290,7 @@ def signout(): access_token = get_token_auth_header() # Signout user - response = userClient.global_sign_out( + response = current_app.boto_client.global_sign_out( AccessToken=access_token ) @@ -330,13 +303,13 @@ def signout(): def token(): # get code from body code = request.get_json()['code'] - client_id = COGNITO_CLIENT_ID - client_secret = COGNITO_CLIENT_SECRET + client_id = current_app.config['COGNITO_CLIENT_ID'] + client_secret = current_app.config['COGNITO_CLIENT_SECRET'] callback_uri = request.args['callback_uri'] token_url = f"{cognito_client_url}/oauth2/token" auth = requests.auth.HTTPBasicAuth(client_id, client_secret) - redirect_uri = f"{ROOT_URL}{callback_uri}" + redirect_uri = f"{current_app.root_url}{callback_uri}" params = { 'grant_type': 'authorization_code', @@ -353,7 +326,7 @@ def token(): # retrieve user data try: - user_data = userClient.get_user(AccessToken=access_token) + user_data = current_app.boto_client.get_user(AccessToken=access_token) except Exception as e: code = e.response['Error']['Code'] message = e.response['Error']['Message'] @@ -396,12 +369,12 @@ def current_session(): # Refresh tokens try: - response = userClient.initiate_auth( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.initiate_auth( + ClientId=current_app.config['COGNITO_CLIENT_ID'], AuthFlow='REFRESH_TOKEN', AuthParameters={ 'REFRESH_TOKEN': refreshToken, - 'SECRET_HASH': COGNITO_CLIENT_SECRET + 'SECRET_HASH': current_app.config['COGNITO_CLIENT_SECRET'] } ) except Exception as e: @@ -415,7 +388,7 @@ def current_session(): accessToken = response['AuthenticationResult']['AccessToken'] # retrieve user data - user_data = userClient.get_user(AccessToken=accessToken) + user_data = current_app.boto_client.get_user(AccessToken=accessToken) # create user object from user data user = get_user_attr(user_data) @@ -438,12 +411,12 @@ def refresh(): # Refresh tokens try: - response = userClient.initiate_auth( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.initiate_auth( + ClientId=current_app.config['COGNITO_CLIENT_ID'], AuthFlow='REFRESH_TOKEN', AuthParameters={ 'REFRESH_TOKEN': refreshToken, - 'SECRET_HASH': COGNITO_CLIENT_SECRET + 'SECRET_HASH': current_app.config['COGNITO_CLIENT_SECRET'] } ) except Exception as e: @@ -466,12 +439,12 @@ def forgot_password(): if connexion.request.is_json: body = connexion.request.get_json() - secret_hash = get_secret_hash(body['email']) + secret_hash = current_app.calc_secret_hash(body['email']) # call forgot password method try: - response = userClient.forgot_password( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.forgot_password( + ClientId=current_app.config['COGNITO_CLIENT_ID'], SecretHash=secret_hash, Username=body['email'] ) @@ -490,12 +463,12 @@ def confirm_forgot_password(): if connexion.request.is_json: body = connexion.request.get_json() - secret_hash = get_secret_hash(body['email']) + secret_hash = current_app.calc_secret_hash(body['email']) # call forgot password method try: - response = userClient.confirm_forgot_password( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.confirm_forgot_password( + ClientId=current_app.config['COGNITO_CLIENT_ID'], SecretHash=secret_hash, Username=body['email'], ConfirmationCode=body['code'], @@ -522,29 +495,31 @@ def private(token_info): return {'message': 'Success - private'} def google(): + client_id = current_app.config['COGNITO_CLIENT_ID'] + root_url = current_app.root_url redirect_uri = request.args['redirect_uri'] - print(f"{cognito_client_url}/oauth2/authorize?client_id={COGNITO_CLIENT_ID}&response_type=code&scope=email+openid+profile+phone+aws.cognito.signin.user.admin&redirect_uri={ROOT_URL}{redirect_uri}&identity_provider=Google") + print(f"{cognito_client_url}/oauth2/authorize?client_id={client_id}&response_type=code&scope=email+openid+profile+phone+aws.cognito.signin.user.admin&redirect_uri={root_url}{redirect_uri}&identity_provider=Google") - return redirect(f"{cognito_client_url}/oauth2/authorize?client_id={COGNITO_CLIENT_ID}&response_type=code&scope=email+openid+profile+phone+aws.cognito.signin.user.admin&redirect_uri={ROOT_URL}{redirect_uri}&identity_provider=Google") + return redirect(f"{cognito_client_url}/oauth2/authorize?client_id={client_id}&response_type=code&scope=email+openid+profile+phone+aws.cognito.signin.user.admin&redirect_uri={root_url}{redirect_uri}&identity_provider=Google") def confirm_signup(): code = request.args['code'] email = request.args['email'] client_id = request.args['clientId'] - secret_hash = get_secret_hash(email) + secret_hash = current_app.calc_secret_hash(email) try: - userClient.confirm_sign_up( + current_app.boto_client.confirm_sign_up( ClientId=client_id, SecretHash=secret_hash, Username=email, ConfirmationCode=code ) - return redirect(f"{ROOT_URL}/email-verification-success") + return redirect(f"{current_app.root_url}/email-verification-success") except Exception as e: - return redirect(f"{ROOT_URL}/email-verification-error") + return redirect(f"{current_app.root_url}/email-verification-error") # What comes first invite or adding the user #Do I have an oauth token @@ -557,11 +532,12 @@ def invite(): try: email = body['email'] - response = userClient.admin_create_user( - UserPoolId=COGNITO_USER_POOL_ID, + + response = current_app.boto_client.admin_create_user( + UserPoolId=current_app.config['COGNITO_USER_POOL_ID'], Username=email, ClientMetadata={ - 'url': ROOT_URL + 'url': current_app.config['ROOT_URL'] }, DesiredDeliveryMediums=["EMAIL"] ) @@ -584,11 +560,11 @@ def confirm_invite(): email = request.args['email'] password = request.args['password'] - secret_hash = get_secret_hash(email) + secret_hash = current_app.calc_secret_hash(email) try: - response = userClient.initiate_auth( - ClientId=COGNITO_CLIENT_ID, + response = current_app.boto_client.initiate_auth( + ClientId=current_app.config['COGNITO_CLIENT_ID'], AuthFlow='USER_PASSWORD_AUTH', AuthParameters={ 'USERNAME': email, @@ -601,9 +577,9 @@ def confirm_invite(): userId = response['ChallengeParameters']['USER_ID_FOR_SRP'] sessionId = response['Session'] - return redirect(f"{ROOT_URL}/create-password?userId={userId}&sessionId={sessionId}") + return redirect(f"{current_app.config['ROOT_URL']}/create-password?userId={userId}&sessionId={sessionId}") else: - return redirect(f"{ROOT_URL}/create-password?error=There was an unexpected error. Please try again.") + return redirect(f"{current_app.config['ROOT_URL']}/create-password?error=There was an unexpected error. Please try again.") except botocore.exceptions.ClientError as error: print(error) @@ -617,10 +593,10 @@ def confirm_invite(): msg = "Too many attempts to use invite in a short amount of time." case _: msg = error.response['Error']['Message'] - return redirect(f"{ROOT_URL}/create-password?error={msg}") + return redirect(f"{current_app.config['ROOT_URL']}/create-password?error={msg}") except botocore.exceptions.ParamValidationError as error: msg = f"The parameters you provided are incorrect: {error}" - return redirect(f"{ROOT_URL}/create-password?error={msg}") + return redirect(f"{current_app.config['ROOT_URL']}/create-password?error={msg}") diff --git a/api/openapi_server/controllers/security_controller.py b/api/openapi_server/controllers/security_controller.py index 5b85b4b7..eadfbf7f 100644 --- a/api/openapi_server/controllers/security_controller.py +++ b/api/openapi_server/controllers/security_controller.py @@ -1,27 +1,12 @@ -import boto3 -from dotenv import load_dotenv, find_dotenv -from os import environ as env +from flask import current_app from openapi_server.exceptions import AuthError - -# Load .env file -ENV_FILE = find_dotenv() -if ENV_FILE: - load_dotenv(ENV_FILE) - -# Define env variables -COGNITO_REGION=env.get('COGNITO_REGION') - -# Initialize Cognito client -userClient = boto3.client('cognito-idp', region_name=COGNITO_REGION) - - def requires_auth(token): # Check if token is valid try: # Get user info from token - userInfo = userClient.get_user( + userInfo = current_app.boto_client.get_user( AccessToken=token ) diff --git a/api/openapi_server/controllers/users_controller.py b/api/openapi_server/controllers/users_controller.py index fb02aea4..21d1ccd8 100644 --- a/api/openapi_server/controllers/users_controller.py +++ b/api/openapi_server/controllers/users_controller.py @@ -1,42 +1,21 @@ import string -import boto3 from openapi_server.controllers.auth_controller import get_token_auth_header -from os import environ as env from openapi_server.models.database import DataAccessLayer, User -from dotenv import load_dotenv, find_dotenv -from flask import session +from flask import session, current_app from sqlalchemy import delete -from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from openapi_server.exceptions import AuthError -# Load .env file -ENV_FILE = find_dotenv() -if ENV_FILE: - load_dotenv(ENV_FILE) - -# Define env variables -COGNITO_REGION=env.get('COGNITO_REGION') -COGNITO_CLIENT_ID=env.get('COGNITO_CLIENT_ID') -COGNITO_CLIENT_SECRET=env.get('COGNITO_CLIENT_SECRET') -COGNITO_USER_POOL_ID=env.get('COGNITO_USER_POOL_ID') -COGNITO_REDIRECT_URI = env.get('COGNITO_REDIRECT_URI') -SECRET_KEY=env.get('SECRET_KEY') - -# Initialize Cognito clients -userClient = boto3.client('cognito-idp', region_name=COGNITO_REGION) - - def delete_user(user_id: string): # get access token from header access_token = get_token_auth_header() # delete user from cognito try: - response = userClient.delete_user( + response = current_app.boto_client.delete_user( AccessToken=access_token ) except Exception as e: diff --git a/api/openapi_server/models/database.py b/api/openapi_server/models/database.py index c51ccf05..f9e07369 100644 --- a/api/openapi_server/models/database.py +++ b/api/openapi_server/models/database.py @@ -2,9 +2,6 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, declarative_base from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, LargeBinary -from os import environ as env - -DATABASE_URL = env.get('DATABASE_URL') Base = declarative_base() @@ -239,24 +236,12 @@ class ProgramCaseStatusLog(Base): class DataAccessLayer: _engine: Engine = None - - # temporary local sqlite DB, replace with conn str for postgres container port for real e2e - _conn_string: str = DATABASE_URL if DATABASE_URL else "sqlite:///./homeuniteus.db" @classmethod - def db_init(cls, conn_string=None): - Base.metadata.create_all(bind=cls.get_engine(conn_string)) - - @classmethod - def connect(cls): - return cls.get_engine().connect() - - @classmethod - def get_engine(cls, conn_string=None) -> Engine: - if cls._engine == None: - cls._engine = create_engine(conn_string or cls._conn_string, echo=True, future=True) - return cls._engine + def db_init(cls, conn_string): + cls._engine = create_engine(conn_string, echo=True, future=True) + Base.metadata.create_all(bind=cls._engine) @classmethod def session(cls) -> Session: - return Session(cls.get_engine()) \ No newline at end of file + return Session(cls._engine) \ No newline at end of file diff --git a/api/openapi_server/openapi/openapi.yaml b/api/openapi_server/openapi/openapi.yaml index 8933e2ca..260dc986 100644 --- a/api/openapi_server/openapi/openapi.yaml +++ b/api/openapi_server/openapi/openapi.yaml @@ -79,4 +79,4 @@ components: title: message type: string title: ApiResponse - type: object + type: object \ No newline at end of file diff --git a/api/openapi_server/openapi/paths/host.yaml b/api/openapi_server/openapi/paths/host.yaml index b23b31e2..f5890697 100644 --- a/api/openapi_server/openapi/paths/host.yaml +++ b/api/openapi_server/openapi/paths/host.yaml @@ -9,7 +9,7 @@ get: content: application/json: schema: - $ref: "../openapi.yaml#/components/schemas/ApiResponse" + $ref: "../schemas/_index.yaml#/HostResponse" x-openapi-router-controller: openapi_server.controllers.host_controller post: summary: Create a host @@ -28,9 +28,9 @@ post: - name responses: "200": - description: Succes created host + description: Success created host content: application/json: schema: - $ref: "../openapi.yaml#/components/schemas/ApiResponse" + $ref: "../schemas/_index.yaml#/HostResponse" x-openapi-router-controller: openapi_server.controllers.host_controller \ No newline at end of file diff --git a/api/openapi_server/openapi/schemas/_index.yaml b/api/openapi_server/openapi/schemas/_index.yaml index da223956..01440bd0 100644 --- a/api/openapi_server/openapi/schemas/_index.yaml +++ b/api/openapi_server/openapi/schemas/_index.yaml @@ -104,3 +104,15 @@ IntakeResponseValue: required: - response_text - intake_question +HostResponse: + type: object + properties: + id: + title: id + type: integer + name: + title: name + type: string + required: + - id + - name \ No newline at end of file diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index 80bafcf0..3bfc065a 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -198,7 +198,7 @@ typing-extensions==4.7.1 # via # alembic # sqlalchemy -urllib3==1.26.16 +urllib3==1.26.18 # via # botocore # requests diff --git a/api/requirements.txt b/api/requirements.txt index 36a39426..d7e115a7 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -131,7 +131,7 @@ typing-extensions==4.7.1 # via # alembic # sqlalchemy -urllib3==1.26.16 +urllib3==1.26.18 # via # botocore # requests diff --git a/api/tests/__init__.py b/api/tests/__init__.py index f31bf4b3..e833a31b 100644 --- a/api/tests/__init__.py +++ b/api/tests/__init__.py @@ -1,21 +1,19 @@ +import pytest import logging -from pathlib import Path +from typing import List -import connexion from flask_testing import TestCase -from typing import List from openapi_server.models.database import DataAccessLayer -from openapi_server.__main__ import get_bundled_specs from openapi_server.repositories.service_provider_repository import HousingProviderRepository +from openapi_server.app import create_app - +@pytest.mark.usefixtures("pass_app_config") class BaseTestCase(TestCase): def create_app(self): ''' - Create a temporary, empty database for testing purposes and return - a new instance of our Flask App to the base class for testing purposes. + Create a instance of our Flask App, configured for testing purposes. The base class will never start the Flask App. It instead create a mock self.client class that is used to simulate requests to the WSGI server. @@ -23,18 +21,10 @@ def create_app(self): https://flask.palletsprojects.com/en/2.2.x/testing/ https://werkzeug.palletsprojects.com/en/2.3.x/test/ ''' - # Create a temporary, memory only database. This temp db will be - # automatically destroyed when the refcount drops to zero - DataAccessLayer._engine = None - DataAccessLayer._conn_string = "sqlite:///:memory:" - DataAccessLayer.db_init() self.provider_repo = HousingProviderRepository() logging.getLogger('connexion.operation').setLevel('ERROR') - app = connexion.App(__name__) - app.add_api(get_bundled_specs(Path('openapi_server/openapi/openapi.yaml')), - pythonic_params=True) - return app.app + return create_app(self.app_config).app def tearDown(self): ''' diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 00000000..1e8d7d83 --- /dev/null +++ b/api/tests/conftest.py @@ -0,0 +1,95 @@ +import os + +import pytest +from pytest import MonkeyPatch + +from openapi_server.configs.staging import StagingHUUConfig +from openapi_server.configs.development import DevelopmentHUUConfig + +def pytest_addoption(parser: pytest.Parser) -> None: + ''' + pytest hook used to register argparse-style options and ini-style config values, + called once at the beginning of a test run. + ''' + parser.addoption( + "--mode", + action="store", + default="debug", + help="run tests in debug or release mode", + ) + +def pytest_configure(config: pytest.Config) -> None: + ''' + pytest hook used to perform initial test application configuration, + called at the beginning of a test run, within conftest.py file. + ''' + mode = config.getoption("mode", default='debug').lower() + if mode == 'debug': + # All application configurations are defined explicitly in code. The + # system environment is not used. All resources that can be safely + # mocked, will be mocked (e.g. mock AWS cognito API calls) + with MonkeyPatch().context() as m: + for env_var in os.environ.keys(): + m.delenv(env_var) + app_config = DevelopmentHUUConfig( + TESTING=True, + FLASK_DEBUG=True, + DATABASE_URL = 'sqlite:///:memory:' + ) + elif mode == 'release': + # Load configuration from the environment, to allow the use of + # secrets, and disable the mocking of any resources + from dotenv import load_dotenv, find_dotenv + dot_env = find_dotenv() + if dot_env: + load_dotenv(dot_env) + app_config = StagingHUUConfig( + TESTING=True, + FLASK_DEBUG=True, + DATABASE_URL = 'sqlite:///:memory:' + ) + else: + raise KeyError(f"pytest application configuration mode {mode} not" + "recognized. Only debug and release modes supported.") + + config.app_config = app_config + +@pytest.fixture(scope='class') +def pass_app_config(request): + ''' + Attach the pytest configuration to the decorated class as a field. + This fixutre is needed to pass pytest configurations to + unittest.TestCase classes. + ''' + setattr(request.cls, 'app_config', request.config.app_config) + +@pytest.fixture +def empty_environment(monkeypatch: MonkeyPatch) -> MonkeyPatch: + ''' + Create an isolated environment for testing purposes. + The environment variables are cleared to ensure the + configuration object is not dependent on the machine configuration. + ''' + for env_var in os.environ.keys(): + monkeypatch.delenv(env_var) + return monkeypatch + +@pytest.fixture +def fake_prod_env(empty_environment: MonkeyPatch) -> MonkeyPatch: + ''' + Define a fake production environment by setting each of the required + production configuration variables with fake values. + ''' + empty_environment.setenv("ENV", "production") + empty_environment.setenv("FLASK_DEBUG", "False") + empty_environment.setenv("TESTING", "False") + empty_environment.setenv("SECRET_KEY", "A completely made up fake secret !@#$12234") + empty_environment.setenv("DATABASE_URL", "sqlite:///:memory:") + empty_environment.setenv("COGNITO_CLIENT_ID", "Totally fake client id") + empty_environment.setenv("COGNITO_CLIENT_SECRET", "Yet another fake secret12") + empty_environment.setenv("COGNITO_REGION", "Not even the region actually exists") + empty_environment.setenv("COGNITO_REDIRECT_URI", "Redirect your way back to writing more test cases") + empty_environment.setenv("COGNITO_USER_POOL_ID", "Water's warm. IDs are fake") + empty_environment.setenv("COGNITO_ACCESS_ID", "If you need fake access, use this ID") + empty_environment.setenv("COGNITO_ACCESS_KEY", "WARNING: This is a real-ly fake key 12345a6sdf") + return empty_environment diff --git a/api/tests/test_configs.py b/api/tests/test_configs.py index fe89cc8f..75cb3967 100644 --- a/api/tests/test_configs.py +++ b/api/tests/test_configs.py @@ -1,32 +1,217 @@ -# Third Party import pytest +from pytest import MonkeyPatch +from sqlalchemy.engine import make_url -# Local -from configs.configs import Config, compile_config +from openapi_server.app import create_app, HUUFlaskApp, HUUConnexionApp +from openapi_server.configs.production import ProductionHUUConfig +from openapi_server.configs.development import DevelopmentHUUConfig +from openapi_server.models.database import DataAccessLayer -class TestConfigs: +def create_dev_app() -> HUUConnexionApp: + ''' + Create our app without reading the .env file. The DevelopmentHUUConfig + will read values from the environment, so monkey patching can be used + to set the values. + ''' + return create_app(DevelopmentHUUConfig()) - def test_base_config(self): - EXPECTED_NUM_CONFIGS = 4 +def create_prod_app() -> HUUConnexionApp: + ''' + Create the production app without reading the .env file. + Fake production secrets must be set using monkey patching, otherwise + the production configuration will raise errors during its + internal validation. + ''' + return create_app(ProductionHUUConfig()) - configs = Config() - config_variables = [ attr for attr in dir(configs) if not callable(getattr(configs, attr)) and not attr.startswith("__") ] +def test_create_app_default_dev(empty_environment: MonkeyPatch): + ''' + Test that create_app with development config creates a Flask app with + a default development configuration, available as app.config. + ''' + connexion_app = create_app(DevelopmentHUUConfig()) + config = connexion_app.app.config + + assert "DATABASE_URL" in config + assert "PORT" in config + assert "HOST" in config + assert "TESTING" in config + assert "SECRET_KEY" in config + assert "ROOT_URL" in config + + for key in config: + assert "cognito" not in key.lower() - assert EXPECTED_NUM_CONFIGS == len(config_variables) - assert configs.HOST == '0.0.0.0' - assert configs.PORT == 8080 - assert configs.DEBUG == True - assert configs.USE_RELOADER == True + assert make_url(config["DATABASE_URL"]) is not None + assert isinstance(config["PORT"], int) + assert config["PORT"] > 0 and config["PORT"] <= 65535 + assert config["ROOT_URL"] - def test_compile_config_no_personal(self): - with pytest.raises(ModuleNotFoundError): - compile_config('personal', 'configs.doesnt_exist', 'PersonalConfigExample') - - def test_compile_config_some_personal(self): - configs = compile_config('personal', 'configs.personal_config_example', 'PersonalConfigExample') +def test_flask_app_override(empty_environment: MonkeyPatch): + ''' + Test that the create_app properly overrides the connexion app constructor + to return our custom application type that contains global configuration. + ''' + connexion_app = create_app(DevelopmentHUUConfig()) + assert isinstance(connexion_app, HUUConnexionApp) + assert isinstance(connexion_app.app, HUUFlaskApp) - assert configs.HOST == '0.0.0.0' - assert configs.PORT == 8081 - assert configs.DEBUG == False - assert configs.USE_RELOADER == False +def test_dev_app_disables_AWS_Cognito(empty_environment: MonkeyPatch): + app = create_app(DevelopmentHUUConfig()).app + assert not app.supports_aws_cognito + with pytest.raises(NotImplementedError): + app.boto_client +def test_prod_app_enables_AWS_Cognito(fake_prod_env: MonkeyPatch): + app = create_app(ProductionHUUConfig()).app + assert app.supports_aws_cognito + +def test_missing_secret_throws_err(fake_prod_env: MonkeyPatch): + ''' + Test that failing to set a configuration field that is marked as a + secret field throws an error. + ''' + fake_prod_env.delenv("SECRET_KEY") + with pytest.raises(ValueError): + create_app(ProductionHUUConfig()) + +def test_hardcoding_secret_throws_err(fake_prod_env: MonkeyPatch): + def check_with_hardcoded_secret(**kwargs): + with pytest.raises(ValueError): + ProductionHUUConfig(**kwargs) + + check_with_hardcoded_secret(SECRET_KEY="My Hard Coded Fake Secret") + check_with_hardcoded_secret(COGNITO_CLIENT_ID="My Hard Coded Fake Secret") + check_with_hardcoded_secret(COGNITO_CLIENT_SECRET="My Hard Coded Fake Secret") + check_with_hardcoded_secret(COGNITO_REGION="My Hard Coded Fake Secret") + check_with_hardcoded_secret(COGNITO_REDIRECT_URI="My Hard Coded Fake Secret") + check_with_hardcoded_secret(COGNITO_USER_POOL_ID="My Hard Coded Fake Secret") + check_with_hardcoded_secret(COGNITO_ACCESS_ID="My Hard Coded Fake Secret") + check_with_hardcoded_secret(COGNITO_ACCESS_KEY="My Hard Coded Fake Secret") + +def test_config_reads_from_env(empty_environment: MonkeyPatch): + ''' + Test that hard-coded values are overwritten using values from the system + environment variables. + ''' + env_port = 9000 + hardcoded_port = 7777 + env_DEBUG = False + hardcoded_DEBUG = True + env_secret = "Extremely Cryptographically Insecure Key" + hardcoded_secret = "Equally Insecure Key" + + empty_environment.setenv("FLASK_DEBUG", str(env_DEBUG)) + empty_environment.setenv("PORT", str(env_port)) + empty_environment.setenv("SECRET_KEY", env_secret) + + config = DevelopmentHUUConfig( + FLASK_DEBUG=hardcoded_DEBUG, + PORT=hardcoded_port, + SECRET_KEY=hardcoded_secret + ) + + assert config.FLASK_DEBUG == env_DEBUG + assert config.PORT == env_port + assert config.SECRET_KEY == env_secret + + app = create_app(config).app + app_config = app.config + + assert app_config["DEBUG"] == env_DEBUG + assert app_config["PORT"] == env_port + assert app_config["SECRET_KEY"] == env_secret + assert app.is_debug_app == env_DEBUG + +def test_invalid_port_throws(empty_environment: MonkeyPatch): + empty_environment.setenv("PORT", "-1") + with pytest.raises(ValueError): + create_dev_app() + empty_environment.setenv("PORT", "66000") + with pytest.raises(ValueError): + create_dev_app() + +def test_env_var_bool_parsing(empty_environment: MonkeyPatch): + def check_bool_parsing(actual: str, expected: bool, msg: str): + empty_environment.setenv("FLASK_DEBUG", actual) + assert create_dev_app().app.config["FLASK_DEBUG"] == expected, msg + + check_bool_parsing("True", True, "match case") + check_bool_parsing("true", True, "lower case") + check_bool_parsing("1", True, "one") + check_bool_parsing("tRuE", True, "mixed case") + check_bool_parsing(" True ", True, "extra padding") + + check_bool_parsing("False", False, "match case") + check_bool_parsing("false", False, "lower case") + check_bool_parsing("0", False, "zero") + check_bool_parsing("fAlSe", False, "mixed case") + check_bool_parsing(" False ", False, "extra padding") + + empty_environment.setenv("FLASK_DEBUG", "") + with pytest.raises(ValueError): + create_dev_app() + +def test_database_url_config(empty_environment: MonkeyPatch): + ''' + Test that setting the DATABASE_URL initializes the database + using the specified URL. + ''' + empty_environment.setenv("DATABASE_URL", "sqlite:///:memory:") + create_dev_app() + db_engine = DataAccessLayer._engine + assert db_engine is not None + assert db_engine.url.database == ":memory:" + +def test_root_url_required(empty_environment: MonkeyPatch): + with pytest.raises(ValueError, match="ROOT_URL"): + create_app(DevelopmentHUUConfig( + ROOT_URL="" + )) + + with pytest.raises(ValueError, match="ROOT_URL"): + create_app(DevelopmentHUUConfig( + ROOT_URL=None + )) + + empty_environment.setenv("ROOT_URL", "") + with pytest.raises(ValueError, match="ROOT_URL"): + create_app(DevelopmentHUUConfig()) + +def test_prod_app_disables_development(fake_prod_env: MonkeyPatch): + def check_development_disabled(enable_testing: bool, enable_debug: bool): + fake_prod_env.setenv("FLASK_DEBUG", str(enable_debug)) + fake_prod_env.setenv("TESTING", str(enable_testing)) + if enable_debug or enable_testing: + with pytest.raises(ValueError): + create_prod_app() + else: + create_prod_app() + + check_development_disabled(True, True) + check_development_disabled(True, False) + check_development_disabled(False, True) + check_development_disabled(False, False) + +def test_prod_secret_key_requirements(fake_prod_env: MonkeyPatch): + def check_insecure_secret(secret: str): + fake_prod_env.setenv("SECRET_KEY", secret) + with pytest.raises(ValueError): + create_prod_app() + def check_secure_secret(secret: str): + fake_prod_env.setenv("SECRET_KEY", secret) + create_prod_app() + + check_insecure_secret("hi") + check_insecure_secret("") + check_insecure_secret("aaaaaaaaaaaaaaaaaaaaaaaaaa") + check_insecure_secret("asdfasdfasdfasdfasdfasdfa") + check_insecure_secret("12312132132132132132132132") + check_insecure_secret("123456789asdfqwe") + check_insecure_secret("123456789ASDFQWERTG") + + check_secure_secret("3-nTeYX6Zi2T6XlvN2m93cNdDHSB6NC0") + check_secure_secret("QiWYHC1St0pPOEXY1ChiwKrYLJQr9yWH") + check_secure_secret("wd-4FBhuf2TYP4T6FrAxaCvRLItXlIK5") + check_secure_secret("omMTDTPUXTcizyka2AtOg570XqWFlFfP") + check_secure_secret("iEIGSrC6jSh6QdLNib0io8sz_60lZ_BE") \ No newline at end of file diff --git a/api/tests/test_host_controller.py b/api/tests/test_host_controller.py new file mode 100644 index 00000000..4a333f02 --- /dev/null +++ b/api/tests/test_host_controller.py @@ -0,0 +1,87 @@ +from tests import BaseTestCase +import pytest + +# Local +from openapi_server.models.database import DataAccessLayer, Host + +class TestHostController(BaseTestCase): + """TestHostController integration test stubs""" + + def test_create_host(self): + """ + Test creating a new host using a + simulated post request. Verify that the + response is correct, and that the app + database was properly updated. + """ + + NEW_HOST = { + "name" : "new_host" + } + + response = self.client.post( + '/api/host', + json=NEW_HOST) + + self.assertStatus(response, 201, + f'Response body is: {response.json}') + assert 'name' in response.json + assert 'id' in response.json + assert response.json['name'] == NEW_HOST['name'] + + with DataAccessLayer.session() as session: + test_host = session.query(Host).filter_by(name=NEW_HOST['name']).first() + + assert test_host is not None + assert test_host.name == NEW_HOST['name'] + + def test_create_host_empty_body(self): + """ + Test creating a new host with an empty JSON body. + This should return an error response. + """ + + response = self.client.post( + '/api/host', + json={}) + + self.assertStatus(response, 400) + + + def test_create_host_invalid_data(self): + """ + Test creating a new host with invalid data in the request body. + This should return an error response (e.g., 400 Bad Request). + """ + invalid_host_data = {"invalid_field": "value"} + + response = self.client.post('/api/host', json=invalid_host_data) + + self.assertStatus(response, 400) + + + def test_get_hosts(self): + """ + Test that checks if a list of 5 Hosts are returned from a GET request. + The 5 test Hosts are created by this test. + """ + + with DataAccessLayer.session() as session: + host1 = Host(name="host1") + host2 = Host(name="host2") + host3 = Host(name="host3") + host4 = Host(name="host4") + host5 = Host(name="host5") + session.add_all([host1, host2, host3, host4, host5]) + session.commit() + + response = self.client.get('/api/host') + + self.assertStatus(response, 200, + f'Response body is: {response.json}') + + assert isinstance(response.json, list) + assert len(response.json) == 5 + for i in range(1, len(response.json) + 1): + assert response.json[i - 1]['name'] == f"host{i}" + assert response.json[i - 1]['id'] == i \ No newline at end of file diff --git a/api/tests/test_service_provider_repository.py b/api/tests/test_service_provider_repository.py index 70db6c85..209e262d 100644 --- a/api/tests/test_service_provider_repository.py +++ b/api/tests/test_service_provider_repository.py @@ -12,8 +12,7 @@ def empty_housing_repo() -> HousingProviderRepository: testing purposes. ''' DataAccessLayer._engine = None - DataAccessLayer._conn_string = "sqlite:///:memory:" - DataAccessLayer.db_init() + DataAccessLayer.db_init("sqlite:///:memory:") yield HousingProviderRepository() diff --git a/api/tox.ini b/api/tox.ini index 1b36683f..46fce682 100644 --- a/api/tox.ini +++ b/api/tox.ini @@ -4,10 +4,14 @@ env_list = minversion = 4.6.4 [testenv] -description = run the tests with pytest +description = run tests with mocking using pytest package = sdist deps = -r{toxinidir}/requirements-dev.txt commands = - pytest {tty:--color=yes} {posargs} --cov=openapi_server + pytest {tty:--color=yes} {posargs} --cov=openapi_server --mode=debug +[testenv:releasetest] +description = run tests without mocking using pytest +commands = + pytest {tty:--color=yes} {posargs} --cov=openapi_server --mode=release \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 055c276c..baab68d1 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -202,11 +202,11 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "dependencies": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -338,9 +338,9 @@ } }, "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", "engines": { "node": ">=6.9.0" } @@ -358,12 +358,12 @@ } }, "node_modules/@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "dependencies": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" }, "engines": { "node": ">=6.9.0" @@ -518,9 +518,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", "engines": { "node": ">=6.9.0" } @@ -575,9 +575,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.22.14", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.14.tgz", - "integrity": "sha512-1KucTHgOvaw/LzCVrEOAyXkr9rQlp0A1HiHRYnSUE9dmb8PvPW7o5sscg+5169r54n3vGlbx6GevTE/Iw/P3AQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", "bin": { "parser": "bin/babel-parser.js" }, @@ -2120,31 +2120,31 @@ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" }, "node_modules/@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz", - "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==", - "dependencies": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.11", - "@babel/types": "^7.22.11", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -2153,12 +2153,12 @@ } }, "node_modules/@babel/types": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz", - "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "dependencies": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, "engines": { @@ -9376,20 +9376,23 @@ } }, "node_modules/browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dev": true, "dependencies": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 4" } }, "node_modules/browserify-sign/node_modules/safe-buffer": { @@ -21148,9 +21151,9 @@ } }, "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "dependencies": { "inherits": "^2.0.3", @@ -26748,11 +26751,11 @@ } }, "@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", "requires": { - "@babel/types": "^7.22.10", + "@babel/types": "^7.23.0", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -26858,9 +26861,9 @@ } }, "@babel/helper-environment-visitor": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", - "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==" + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==" }, "@babel/helper-explode-assignable-expression": { "version": "7.18.6", @@ -26872,12 +26875,12 @@ } }, "@babel/helper-function-name": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", - "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", "requires": { - "@babel/template": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" } }, "@babel/helper-hoist-variables": { @@ -26987,9 +26990,9 @@ "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==" }, "@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==" + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==" }, "@babel/helper-validator-option": { "version": "7.22.5", @@ -27029,9 +27032,9 @@ } }, "@babel/parser": { - "version": "7.22.14", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.14.tgz", - "integrity": "sha512-1KucTHgOvaw/LzCVrEOAyXkr9rQlp0A1HiHRYnSUE9dmb8PvPW7o5sscg+5169r54n3vGlbx6GevTE/Iw/P3AQ==" + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==" }, "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.18.6", @@ -28065,39 +28068,39 @@ } }, "@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", "requires": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" } }, "@babel/traverse": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.11.tgz", - "integrity": "sha512-mzAenteTfomcB7mfPtyi+4oe5BZ6MXxWcn4CX+h4IRJ+OOGXBrWU6jDQavkQI9Vuc5P+donFabBfFCcmWka9lQ==", - "requires": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.11", - "@babel/types": "^7.22.11", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.22.11", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.11.tgz", - "integrity": "sha512-siazHiGuZRz9aB9NpHy9GOs9xiQPKnMzgdr493iI1M67vRXpnEq8ZOOKzezC5q7zwuQ6sDhdSp4SD9ixKSqKZg==", + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", "requires": { "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" } }, @@ -33430,20 +33433,20 @@ } }, "browserify-sign": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz", - "integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.2.tgz", + "integrity": "sha512-1rudGyeYY42Dk6texmv7c4VcQ0EsvVbLwZkA+AQB7SxvXxmcD93jcHie8bzecJ+ChDlmAm2Qyu0+Ccg5uhZXCg==", "dev": true, "requires": { - "bn.js": "^5.1.1", - "browserify-rsa": "^4.0.1", + "bn.js": "^5.2.1", + "browserify-rsa": "^4.1.0", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", - "elliptic": "^6.5.3", + "elliptic": "^6.5.4", "inherits": "^2.0.4", - "parse-asn1": "^5.1.5", - "readable-stream": "^3.6.0", - "safe-buffer": "^5.2.0" + "parse-asn1": "^5.1.6", + "readable-stream": "^3.6.2", + "safe-buffer": "^5.2.1" }, "dependencies": { "safe-buffer": { @@ -42459,9 +42462,9 @@ } }, "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "requires": { "inherits": "^2.0.3", diff --git a/app/src/components/dashboard/CoordinatorContact.tsx b/app/src/components/dashboard/CoordinatorContact.tsx new file mode 100644 index 00000000..f9faba7d --- /dev/null +++ b/app/src/components/dashboard/CoordinatorContact.tsx @@ -0,0 +1,75 @@ +import {EmailOutlined, PhoneOutlined} from '@mui/icons-material'; +import {Box, Stack, Typography} from '@mui/material'; +import {styled} from '@mui/material/styles'; + +interface CoordinatorContactProps { + image: string; + name: string; + email: string; + phone: string; +} + +export const CoordinatorContact = ({ + image, + name, + email, + phone, +}: CoordinatorContactProps) => { + return ( + + + coordinator profile image + + + + {name} + + + Coordinator + + + + + {email} + + + + + + {phone} + + + + + ); +}; + +const StyledContainer = styled(Stack)(({theme}) => ({ + boxShadow: theme.shadows[19], + borderRadius: '4px', + gap: theme.spacing(3), + padding: '16px', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.palette.common.white, +})); + +const StyledImageContainer = styled(Box)(({theme}) => ({ + borderRadius: '50%', + overflow: 'hidden', + width: theme.spacing(10.5), + height: theme.spacing(10.5), + justifyContent: 'center', + alignItems: 'center', +})); + +const StyledLink = styled('a')(({theme}) => ({ + fontSize: 14, + color: theme.palette.text.secondary, + textDecorationColor: 'inherit', +})); diff --git a/app/src/components/dashboard/DashboardTask.tsx b/app/src/components/dashboard/DashboardTask.tsx index 080c2998..8dea0523 100644 --- a/app/src/components/dashboard/DashboardTask.tsx +++ b/app/src/components/dashboard/DashboardTask.tsx @@ -42,7 +42,7 @@ export const DashboardTask = ({ > {title} - {description} + {description} ({ + boxShadow: theme.shadows[19], borderRadius: '4px', '&:before': { display: 'none', }, -}); +})); const StyledAccordionSummary = styled(AccordionSummary)({ padding: '4px 32px', diff --git a/app/src/components/dashboard/__tests__/CoordinatorContact.test.tsx b/app/src/components/dashboard/__tests__/CoordinatorContact.test.tsx new file mode 100644 index 00000000..8417e51e --- /dev/null +++ b/app/src/components/dashboard/__tests__/CoordinatorContact.test.tsx @@ -0,0 +1,27 @@ +import {render, screen} from '../../../utils/test/test-utils'; +import {CoordinatorContact} from '../CoordinatorContact'; + +const setup = () => { + const props = { + image: 'https://placekitten.com/100/100', + name: 'John Doe', + email: 'johndoe@email.com', + phone: '555-555-5555', + }; + + render(); + + return { + props, + }; +}; + +describe('CoordinatorContact', () => { + it('should render the coordinator information', () => { + const {props} = setup(); + + expect(screen.getByText(props.name)).toBeInTheDocument(); + expect(screen.getByText(props.email)).toBeInTheDocument(); + expect(screen.getByText(props.phone)).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx b/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx index 3df83118..7f76e0c4 100644 --- a/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx +++ b/app/src/components/dashboard/__tests__/DashboardTaskAccordion.test.tsx @@ -1,6 +1,9 @@ import {BrowserRouter} from 'react-router-dom'; import {render, screen, within} from '../../../utils/test/test-utils'; -import {TaskAccordion, TaskAccordionProps} from '../DashboardTaskAccordion'; +import { + DashboardTaskAccordion, + TaskAccordionProps, +} from '../DashboardTaskAccordion'; const task: TaskAccordionProps = { taskOrder: 1, @@ -43,7 +46,7 @@ const setup = (props?: Partial) => { render( - + , ); diff --git a/app/src/components/dashboard/index.ts b/app/src/components/dashboard/index.ts new file mode 100644 index 00000000..d41d4d2a --- /dev/null +++ b/app/src/components/dashboard/index.ts @@ -0,0 +1,3 @@ +export {DashboardTask} from './DashboardTask'; +export {DashboardTaskAccordion} from './DashboardTaskAccordion'; +export {CoordinatorContact} from './CoordinatorContact'; diff --git a/app/src/components/layout/DashboardLayout.tsx b/app/src/components/layout/DashboardLayout.tsx index 087f3956..4d39afb5 100644 --- a/app/src/components/layout/DashboardLayout.tsx +++ b/app/src/components/layout/DashboardLayout.tsx @@ -93,7 +93,7 @@ export function DashboardLayout({window, navItems}: OwnProps) { {navListItems} - + diff --git a/app/src/services/host.ts b/app/src/services/host.ts index b284d9d2..9358e9d8 100644 --- a/app/src/services/host.ts +++ b/app/src/services/host.ts @@ -28,8 +28,7 @@ export type CreateHostApiArg = { }; }; export type ApiResponse = { - code?: number; - message?: string; - type?: string; + id?: number; + name?: string; }; export const {useGetHostsQuery, useCreateHostMutation} = injectedRtkApi; diff --git a/app/src/theme/theme.ts b/app/src/theme/theme.ts index d053297f..4992963e 100644 --- a/app/src/theme/theme.ts +++ b/app/src/theme/theme.ts @@ -1,6 +1,7 @@ import type {} from '@mui/x-data-grid/themeAugmentation'; import {createTheme} from '@mui/material/styles'; import {componentOverrides} from './overrides'; +import {Shadows} from '@mui/material/styles/shadows'; declare module '@mui/material/styles' { interface Theme { @@ -16,7 +17,9 @@ declare module '@mui/material/styles' { } } -export const HomeUniteUsTheme = createTheme({ +export let HomeUniteUsTheme = createTheme(); + +HomeUniteUsTheme = createTheme({ palette: { primary: { dark: '#196ca0', @@ -31,7 +34,19 @@ export const HomeUniteUsTheme = createTheme({ 500: '#B7B6B6', 300: '#E6E6E6', }, + text: { + secondary: '#777777', + }, }, + shadows: [ + // composing shadows from the default theme. 25 shadows are required + ...HomeUniteUsTheme.shadows.slice(0, 19), + // add custom shadows here + '0px 4px 10px rgba(0, 0, 0, 0.25)', + // spread the rest of the default shadows, make sure to decrement from this array if adding more shadows. + // I chose the 20th shadow since I believe these are less commonly used and didn't want to override shadows that are used in other components + ...HomeUniteUsTheme.shadows.slice(20), + ] as Shadows, shape: { borderRadius: 4, }, diff --git a/app/src/views/CoordinatorDashboard.tsx b/app/src/views/CoordinatorDashboard.tsx index 48ae5115..e8c75aff 100644 --- a/app/src/views/CoordinatorDashboard.tsx +++ b/app/src/views/CoordinatorDashboard.tsx @@ -1,13 +1,5 @@ import {useState} from 'react'; -import { - Container, - Box, - Tabs, - Tab, - Typography, - Pagination, - Stack, -} from '@mui/material'; +import {Box, Tabs, Tab, Typography, Pagination, Stack} from '@mui/material'; import { DataGrid, GridRowsProp, @@ -39,9 +31,13 @@ const buildRow = () => { const rows: GridRowsProp = Array.from(Array(30), () => buildRow()); const columns: GridColDef[] = [ - {field: 'applicant', headerName: 'Applicant', flex: 1}, - {field: 'type', headerName: 'Type', flex: 1}, - {field: 'status', headerName: 'Status', flex: 1}, + { + field: 'applicant', + headerName: 'Applicant', + flex: 1, + }, + {field: 'type', headerName: 'Type'}, + {field: 'status', headerName: 'Status'}, {field: 'coordinator', headerName: 'Coordinator', flex: 1}, {field: 'updated', headerName: 'Updated', flex: 1}, { @@ -80,81 +76,91 @@ export const CoordinatorDashboard = () => { }; return ( - - + - - Dashboard - - - - - - - - - {rows.length} - - - } - iconPosition="end" - label="All" - {...a11yProps(0)} - /> - - - {totalGuests} - - - } - iconPosition="end" - label="Guests" - {...a11yProps(1)} - /> - - - {totalHosts} - - - } - iconPosition="end" - label="Hosts" - {...a11yProps(2)} - /> - - - + Dashboard + + + + + + + + + {rows.length} + + + } + iconPosition="end" + label="All" + {...a11yProps(0)} + /> + + + {totalGuests} + + + } + iconPosition="end" + label="Guests" + {...a11yProps(1)} + /> + + + {totalHosts} + + + } + iconPosition="end" + label="Hosts" + {...a11yProps(2)} + /> + + + - + }} + slots={{ + pagination: CustomPagination, + }} + /> + + ); }; @@ -174,6 +180,22 @@ const CustomPagination = () => { ); }; +const StyledPageContainer = styled(Box)(({theme}) => ({ + backgroundColor: theme.palette.grey[50], + display: 'grid', + padding: `${theme.spacing(6)} ${theme.spacing(2)}`, + [theme.breakpoints.up('sm')]: { + gridTemplateColumns: 'repeat(4, 1fr)', + }, + [theme.breakpoints.up('md')]: { + gridTemplateColumns: 'repeat(8, 1fr)', + }, + [theme.breakpoints.up('lg')]: { + gridTemplateColumns: 'repeat(12, 1fr)', + }, + gridAutoRows: 'min-content', +})); + const StyledTabs = styled(Tabs)({ '& .MuiTabs-indicator': { display: 'none', @@ -216,6 +238,7 @@ const StyledUserCount = styled(Stack)(({theme}) => ({ const StyledDataGrid = styled(DataGrid)({ border: 'none', + width: '100%', '& .MuiDataGrid-main': { border: '1px solid #E8E8E8', borderRadius: '0 0 4px 4px', diff --git a/app/src/views/GuestApplicationTracker.tsx b/app/src/views/GuestApplicationTracker.tsx index af37ff25..5f056513 100644 --- a/app/src/views/GuestApplicationTracker.tsx +++ b/app/src/views/GuestApplicationTracker.tsx @@ -1,6 +1,9 @@ import {Divider, Box, Typography, Stack, useTheme} from '@mui/material'; import {styled} from '@mui/system'; -import {TaskAccordion} from '../components/dashboard/DashboardTaskAccordion'; +import { + DashboardTaskAccordion, + CoordinatorContact, +} from '../components/dashboard'; export type TaskStatus = 'inProgress' | 'complete' | 'locked'; @@ -94,6 +97,13 @@ const tasks: Task[] = [ }, ]; +const coordinatorInfo = { + image: 'https://placekitten.com/100/100', + name: 'John Doe', + email: 'johndoe@email.com', + phone: '555-555-5555', +}; + export function GuestApplicationTracker() { const theme = useTheme(); const toolbarHeight = Number(theme.mixins.toolbar.minHeight); @@ -111,7 +121,7 @@ export function GuestApplicationTracker() { mb: 5, }} > - + Welcome, Jane Doe @@ -126,14 +136,14 @@ export function GuestApplicationTracker() { }, }} > - + My Tasks {tasks.map(({id, title, status, subTasks}, index) => { return ( - - + Contacts - +