diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..6fbe9f8 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,40 @@ +FROM continuumio/miniconda:4.6.14 + +ENV SERVER_PORT 8000 +ENV SRC_DIR /opt/waisn-tech-tools/ + +# copy source code +RUN mkdir -p $SRC_DIR +COPY ./environment-docker.yml $SRC_DIR +COPY ./waisntechtools/waisntechtools/ $SRC_DIR/waisntechtools/waisntechtools/ +COPY ./waisntechtools/alerts/ $SRC_DIR/waisntechtools/alerts +COPY ./waisntechtools/manage.py $SRC_DIR/waisntechtools/manage.py + +# set up conda env +WORKDIR $SRC_DIR +RUN conda env create -f environment-docker.yml + +# run tests to sanity check image build +WORKDIR $SRC_DIR/waisntechtools +RUN . /opt/conda/etc/profile.d/conda.sh && \ + conda activate waisn-tech-tools && \ + python manage.py test + +# expose runtime port - exposing debug port for now until creating production configuration +EXPOSE $SERVER_PORT + +# set up some environment variables +ENV AUTH0_DOMAIN AUTH0_DOMAIN +ENV AUTH0_KEY AUTH0_KEY +ENV AUTH0_SECRET AUTH0_SECRET +ENV WAISN_AUTH_DISABLED TRUE + +# run migrations +RUN . /opt/conda/etc/profile.d/conda.sh && \ + conda activate waisn-tech-tools && \ + python manage.py migrate + +# run server +ENTRYPOINT . /opt/conda/etc/profile.d/conda.sh && \ + conda activate waisn-tech-tools && \ + python manage.py runserver 0.0.0.0:$SERVER_PORT diff --git a/README.md b/README.md index 00ca01f..67d2969 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,15 @@ setting the environment variable: # disable Auth0 login requirement export WAISN_AUTH_DISABLED='TRUE' ``` + +# Docker + +You can build the web application using the provided Docker file. Currently, only an image that can be used for testing +has been created: + +``` +# build the Docker image +docker build -f Dockerfile.test . +# run the docker file +docker run -p 8000:8000 --rm [environment variables to enable Auth0] ${IMAGE_ID} +``` diff --git a/environment-docker.yml b/environment-docker.yml new file mode 100644 index 0000000..2b56954 --- /dev/null +++ b/environment-docker.yml @@ -0,0 +1,56 @@ +name: waisn-tech-tools +channels: + - defaults +dependencies: + - _libgcc_mutex=0.1=main + - asn1crypto=0.24.0=py37_0 + - beautifulsoup4=4.7.1=py37_1 + - ca-certificates=2019.5.15=0 + - certifi=2019.6.16=py37_0 + - cffi=1.12.3=py37h2e261b9_0 + - cryptography=2.7=py37h1ba5d50_0 + - django=2.2.1=py37_0 + - ecdsa=0.13=py37_1 + - factory_boy=2.12.0=py37_0 + - faker=1.0.7=py37_0 + - future=0.17.1=py37_0 + - gmp=6.1.2=h6c8ec71_1 + - libedit=3.1.20181209=hc058e9b_0 + - libffi=3.2.1=hd88cf55_4 + - libgcc-ng=9.1.0=hdf63c60_0 + - libstdcxx-ng=9.1.0=hdf63c60_0 + - ncurses=6.1=he6710b0_1 + - openssl=1.1.1c=h7b6447c_1 + - pip=19.1.1=py37_0 + - pyasn1=0.4.5=py_0 + - pycparser=2.19=py37_0 + - pycryptodome=3.7.3=py37hb69a4c5_0 + - python=3.7.3=h0371630_0 + - python-dateutil=2.8.0=py37_0 + - python-jose=3.0.1=py_0 + - pytz=2019.1=py_0 + - readline=7.0=h7b6447c_5 + - setuptools=41.0.1=py37_0 + - six=1.12.0=py37_0 + - soupsieve=1.8=py37_0 + - sqlite=3.28.0=h7b6447c_0 + - sqlparse=0.3.0=py_0 + - text-unidecode=1.2=py37_0 + - tk=8.6.8=hbc83047_0 + - wheel=0.33.4=py37_0 + - xz=5.2.4=h14c3975_4 + - zlib=1.2.11=h7b6447c_3 + - pip: + - chardet==3.0.4 + - defusedxml==0.6.0 + - idna==2.8 + - oauthlib==3.0.2 + - pyjwt==1.7.1 + - python3-openid==3.1.0 + - requests==2.22.0 + - requests-oauthlib==1.2.0 + - social-auth-app-django==3.1.0 + - social-auth-core==3.2.0 + - transitions==0.6.9 + - urllib3==1.25.3 +prefix: /opt/conda/envs/waisn-tech-tools diff --git a/environment-mac.yml b/environment-mac.yml index b28c6b3..dcbed06 100644 --- a/environment-mac.yml +++ b/environment-mac.yml @@ -50,4 +50,5 @@ dependencies: - requests-oauthlib==1.2.0 - social-auth-app-django==3.1.0 - social-auth-core==3.1.0 + - transitions==0.6.9 - urllib3==1.25.3 diff --git a/waisntechtools/alerts/asset_file.py b/waisntechtools/alerts/asset_files.py similarity index 87% rename from waisntechtools/alerts/asset_file.py rename to waisntechtools/alerts/asset_files.py index 73ad310..6f75537 100644 --- a/waisntechtools/alerts/asset_file.py +++ b/waisntechtools/alerts/asset_files.py @@ -1,4 +1,4 @@ -import os +from os import path from .languages import Language @@ -47,9 +47,9 @@ def follow_up_file(self): return self._asset_file(AssetFiles._FOLLOW_UP_FILE) def _asset_file(self, filename): - lang_file = "{}/{}/{}".format(AssetFiles._ASSET_DIR, self._lang, filename) - default_lang_file = "{}/{}/{}".format(AssetFiles._ASSET_DIR, Language.DEFAULT_LANGUAGE, filename) - if os.path.isfile(lang_file): + lang_file = path.join(AssetFiles._ASSET_DIR, self._lang, filename) + default_lang_file = path.join(AssetFiles._ASSET_DIR, Language.DEFAULT_LANGUAGE, filename) + if path.isfile(lang_file): return lang_file else: return default_lang_file diff --git a/waisntechtools/alerts/subscription_states.py b/waisntechtools/alerts/subscription_states.py new file mode 100644 index 0000000..919d3ed --- /dev/null +++ b/waisntechtools/alerts/subscription_states.py @@ -0,0 +1,120 @@ +from transitions import Machine + +from .asset_files import AssetFiles + + +class SubscriptionStates(object): + UNSUBSCRIBED_STATE = "unsubscribed" + SELECTING_LANG_STATE = "selecting_language" + COMPLETE_STATE = "complete" + INITIAL_STATE = UNSUBSCRIBED_STATE + + _SRC = "source" + _DST = "dest" + _TRIGGER = "trigger" + _AFTER = "after" + _BEFORE = "before" + + _STATES = [ + UNSUBSCRIBED_STATE, + SELECTING_LANG_STATE, + COMPLETE_STATE + ] + + _TRANSITIONS = [ + { + _TRIGGER: "subscribe_help", + _SRC: UNSUBSCRIBED_STATE, + _DST: UNSUBSCRIBED_STATE, + _AFTER: "_subscribe_help" + }, + { + _TRIGGER: "start_subscription", + _SRC: UNSUBSCRIBED_STATE, + _DST: SELECTING_LANG_STATE, + _AFTER: "_start_subscription" + }, + { + _TRIGGER: "unknown_lang_selected", + _SRC: SELECTING_LANG_STATE, + _DST: SELECTING_LANG_STATE, + _AFTER: "_unknown_lang_selected" + }, + { + _TRIGGER: "lang_selected", + _SRC: SELECTING_LANG_STATE, + _DST: COMPLETE_STATE, + _AFTER: "_lang_selected" + }, + { + _TRIGGER: "complete_state_help", + _SRC: COMPLETE_STATE, + _DST: COMPLETE_STATE, + _AFTER: "_complete_state_help" + }, + { + _TRIGGER: "reselect_language", + _SRC: COMPLETE_STATE, + _DST: SELECTING_LANG_STATE, + _AFTER: "_reselect_language" + }, + { + _TRIGGER: "end_subscription", + _SRC: COMPLETE_STATE, + _DST: UNSUBSCRIBED_STATE, + _AFTER: "_end_subscription" + }, + ] + + def __init__(self, subscriber, messenger): + if subscriber.state not in SubscriptionStates._STATES: + raise Exception("Unknown state: {}".format(subscriber.state)) + + self._subscriber = subscriber + self._machine = Machine( + model=self, + states=SubscriptionStates._STATES, + initial=subscriber.state, + transitions=SubscriptionStates._TRANSITIONS + ) + self._messenger = messenger + + def _subscribe_help(self): + self._send_msgs([AssetFiles(self._subscriber.language).subscribe_help_file()]) + + def _start_subscription(self): + self._subscriber.state = SubscriptionStates.SELECTING_LANG_STATE + self._subscriber.save() + self._send_msgs([ + AssetFiles(self._subscriber.language).welcome_file(), + AssetFiles(self._subscriber.language).lang_select_file() + ]) + + def _unknown_lang_selected(self): + self._send_msgs([ + AssetFiles(self._subscriber.language).unsupported_lang_file(), + AssetFiles(self._subscriber.language).lang_select_file() + ]) + + def _lang_selected(self, iso_code): + self._subscriber.state = SubscriptionStates.COMPLETE_STATE + self._subscriber.language = iso_code + self._subscriber.save() + self._send_msgs([AssetFiles(self._subscriber.language).confirmation_file()]) + + def _complete_state_help(self): + self._send_msgs([AssetFiles(self._subscriber.language).error_file()]) + + def _reselect_language(self): + self._subscriber.state = SubscriptionStates.SELECTING_LANG_STATE + self._subscriber.save() + self._send_msgs([AssetFiles(self._subscriber.language).lang_select_file()]) + + def _end_subscription(self): + self._send_msgs([AssetFiles(self._subscriber.language).unsubscribe_file()]) + self._subscriber.delete() + + def _send_msgs(self, filenames): + # TODO: add twilo hook here, we possibly want to refactor AssetFiles to provide the actual message rather + # than a file name hook + self._messenger.send(filenames) diff --git a/waisntechtools/alerts/tests/test_asset_file.py b/waisntechtools/alerts/tests/test_asset_file.py index 5c501de..857b9e4 100644 --- a/waisntechtools/alerts/tests/test_asset_file.py +++ b/waisntechtools/alerts/tests/test_asset_file.py @@ -2,7 +2,7 @@ from django.test import TestCase -from alerts.asset_file import * +from alerts.asset_files import * from alerts.languages import Language diff --git a/waisntechtools/alerts/tests/test_subscription_states.py b/waisntechtools/alerts/tests/test_subscription_states.py new file mode 100644 index 0000000..03f787f --- /dev/null +++ b/waisntechtools/alerts/tests/test_subscription_states.py @@ -0,0 +1,73 @@ +from unittest.mock import Mock + +from django.test import TestCase + +from alerts.languages import * +from alerts.models import Subscriber +from alerts.subscription_states import SubscriptionStates + + +class SubscriptionStateTestCase(TestCase): + def setUp(self) -> None: + self._msgr = Mock() + + def test_subscription_state_constructor(self): + SubscriptionStates(self.subscriber(SubscriptionStates.INITIAL_STATE), self._msgr) + + def test_subscribe_help(self): + state = SubscriptionStates(self.subscriber(SubscriptionStates.UNSUBSCRIBED_STATE), self._msgr) + state.subscribe_help() + self.assert_messenger_state(filenames=["alerts/assets/eng/subscribe_help_msg.txt"]) + + def test_start_subscription(self): + state = SubscriptionStates(self.subscriber(SubscriptionStates.UNSUBSCRIBED_STATE), self._msgr) + state.start_subscription() + self.assert_messenger_state(filenames=[ + "alerts/assets/eng/welcome_msg.txt", + "alerts/assets/eng/language_selection_msg.txt" + ]) + + def test_unknown_lang_selected(self): + state = SubscriptionStates(self.subscriber(SubscriptionStates.SELECTING_LANG_STATE), self._msgr) + state.unknown_lang_selected() + self.assert_messenger_state(filenames=[ + "alerts/assets/eng/unsupported_lang_msg.txt", + "alerts/assets/eng/language_selection_msg.txt" + ]) + + def test_lang_selected(self): + state = SubscriptionStates(self.subscriber(SubscriptionStates.SELECTING_LANG_STATE), self._msgr) + state.lang_selected("spa") + self.assert_messenger_state(filenames=["alerts/assets/spa/confirmation_msg.txt"]) + + def test_complete_state_help(self): + state = SubscriptionStates(self.subscriber(SubscriptionStates.COMPLETE_STATE), self._msgr) + state.complete_state_help() + self.assert_messenger_state(filenames=["alerts/assets/eng/error_msg.txt"]) + + def test_reselect_language(self): + state = SubscriptionStates(self.subscriber(SubscriptionStates.COMPLETE_STATE), self._msgr) + state.reselect_language() + self.assert_messenger_state(filenames=["alerts/assets/eng/language_selection_msg.txt"]) + + def test_end_subscription(self): + state = SubscriptionStates(self.subscriber(SubscriptionStates.COMPLETE_STATE), self._msgr) + state.end_subscription() + self.assert_messenger_state(filenames=["alerts/assets/eng/unsubscribed_msg.txt"]) + + @staticmethod + def subscriber(state) -> Subscriber: + sub = Mock() + sub.state = state + sub.language = Language.ENGLISH + sub.phone_number = "+11234567890" + return sub + + def assert_messenger_state(self, filenames) -> None: + assert self._msgr.send.call_count == 1 + + for index, filename in enumerate(filenames): + self.assertEqual( + self._msgr.send.call_args[0][0][index], + filename + ) diff --git a/waisntechtools/alerts/tests/test_views.py b/waisntechtools/alerts/tests/test_views.py index 6c0afb7..5eed2d4 100644 --- a/waisntechtools/alerts/tests/test_views.py +++ b/waisntechtools/alerts/tests/test_views.py @@ -2,12 +2,13 @@ from django.contrib.staticfiles import finders from django.core.management import call_command from django.core.management.commands import flush -from django.test import TestCase +from django.test import TestCase, override_settings from django.urls import reverse from alerts.tests.fakes import SubscriberFactory +@override_settings(WAISN_AUTH_DISABLED=True, DEBUG=True) class IndexViewTests(TestCase): _STATIC_PREFIX = '/static/' @@ -30,6 +31,7 @@ def _is_static_resource(resource): return resource.startswith(IndexViewTests._STATIC_PREFIX) +@override_settings(WAISN_AUTH_DISABLED=True, DEBUG=True) class DebugViewTests(TestCase): def setUp(self): call_command(flush.Command(), interactive=False) diff --git a/waisntechtools/alerts/waisn_auth.py b/waisntechtools/alerts/waisn_auth.py index 57262ef..cc9f524 100644 --- a/waisntechtools/alerts/waisn_auth.py +++ b/waisntechtools/alerts/waisn_auth.py @@ -1,13 +1,8 @@ from functools import wraps -from os import environ +from django.conf import settings from django.contrib.auth.decorators import login_required -from waisntechtools.settings import DEBUG - -_AUTH_DISABLED_KEY = 'WAISN_AUTH_DISABLED' -_AUTH_DISABLED_VALUE = 'TRUE' - def waisn_auth(view_func): """ @@ -15,7 +10,7 @@ def waisn_auth(view_func): """ @wraps(view_func) def _waisn_auth_decorator(request, *args, **kwargs): - if _AUTH_DISABLED_KEY in environ and environ[_AUTH_DISABLED_KEY] == _AUTH_DISABLED_VALUE and DEBUG: + if settings.WAISN_AUTH_DISABLED and settings.DEBUG: return view_func(request) else: return login_required()(view_func)(request, *args, **kwargs) diff --git a/waisntechtools/waisntechtools/settings.py b/waisntechtools/waisntechtools/settings.py index cf80fe6..97236dc 100644 --- a/waisntechtools/waisntechtools/settings.py +++ b/waisntechtools/waisntechtools/settings.py @@ -16,7 +16,6 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/ @@ -28,7 +27,6 @@ ALLOWED_HOSTS = [] - # Application definition INSTALLED_APPS = [ @@ -72,7 +70,6 @@ WSGI_APPLICATION = 'waisntechtools.wsgi.application' - # Database # https://docs.djangoproject.com/en/2.1/ref/settings/#databases @@ -83,7 +80,6 @@ } } - # Password validation # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators @@ -102,7 +98,6 @@ }, ] - # Internationalization # https://docs.djangoproject.com/en/2.1/topics/i18n/ @@ -116,17 +111,29 @@ USE_TZ = True - # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.1/howto/static-files/ STATIC_URL = '/static/' # BEGIN Auth0 +WAISN_AUTH_DISABLED = environ.get('WAISN_AUTH_DISABLED', False) + + +def environ_var(key): + """Used to allow tests to run without having to set the environment variables. If this gets unwieldy, we should + think about having a separate test settings file. If testing mode, returns the key name as the value.""" + import sys + if 'test' in sys.argv: + return key + else: + return environ[key] + + SOCIAL_AUTH_TRAILING_SLASH = False # Remove trailing slash from routes -SOCIAL_AUTH_AUTH0_DOMAIN = environ['AUTH0_DOMAIN'] -SOCIAL_AUTH_AUTH0_KEY = environ['AUTH0_KEY'] -SOCIAL_AUTH_AUTH0_SECRET = environ['AUTH0_SECRET'] +SOCIAL_AUTH_AUTH0_DOMAIN = environ_var('AUTH0_DOMAIN') +SOCIAL_AUTH_AUTH0_KEY = environ_var('AUTH0_KEY') +SOCIAL_AUTH_AUTH0_SECRET = environ_var('AUTH0_SECRET') SOCIAL_AUTH_AUTH0_SCOPE = [ 'openid', 'profile'