diff --git a/.coveragerc b/.coveragerc index d5d4aa7..ec11bc5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -9,3 +9,4 @@ omit = */migrations/* */tests.py */tests/*.py + venv/* diff --git a/.travis.yml b/.travis.yml index c6c9040..2eea3fa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,7 @@ language: python python: - - '2.7' - - '3.3' - - '3.4' + - '3.6' + - '3.7' install: - pip install -r requirements.txt - pip install coveralls diff --git a/LICENSE b/LICENSE index cff04d4..225d6ce 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 Twilio Inc. +Copyright (c) 2019 Twilio Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f6abfc5..d7f548c 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ Use Twilio to send SMS alerts so that you never miss a critical issue. ## Quickstart -This project is built using the [Django](https://www.djangoproject.com/) web framework. It runs on Python 2.7+ and Python 3.4+. +This project is built using the [Django](https://www.djangoproject.com/) web framework. It runs on Python 3.6+. To run the app locally, first clone this repository and `cd` into its directory. Then: 1. Create a new virtual environment: - - If using vanilla [virtualenv](https://virtualenv.pypa.io/en/latest/): + - If using vanilla with Python 3 [virtualenv](https://docs.python.org/3/library/venv.html): ``` - virtualenv venv + python -m venv venv source venv/bin/activate ``` @@ -35,14 +35,14 @@ To run the app locally, first clone this repository and `cd` into its directory. 1. Copy the `.env_example` file to `.env`, and edit it to include your Twilio API credentials (found at https://www.twilio.com/user/account/voice) 1. For the TWILIO_NUMBER variable you'll need to provision a new number in the [Manage Numbers page](https://www.twilio.com/user/account/phone-numbers/incoming) under your account. The phone number should be in E.164 format -1. Run `source .env` to apply the environment variables (or even better, use [autoenv](https://github.com/kennethreitz/autoenv)) +1. (Optional) This project integrate [python-dotenv](https://github.com/theskumar/python-dotenv) to automatically load the `.env` file. Alternatively, you can run `source .env` to apply the environment variables (or even use [autoenv](https://github.com/kennethreitz/autoenv)) 1. Customize `config/administrators.json` with your phone number. 1. Start the development server ``` python manage.py runserver ``` -1. Go to [http://localhost:8000/error](http://localhost:8000/error). You'll receive a text shortly with details on the exception. +1. Go to [http://localhost:8000/error](http://localhost:8000/error/). You'll receive a text shortly with details on the exception. ## Run the tests diff --git a/requirements.txt b/requirements.txt index 8c4dd96..2d835eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ # Requirements for server-notifications-django -# Django (1.8 is a long-term support release) -Django==1.8 -twilio==6.9.0 -whitenoise +Django==2.2.5 +twilio==6.29.4 +python-dotenv==0.10.3 # Production dependencies gunicorn # Test dependencies +black coverage +flake8 mock -six diff --git a/twilio_notifications/middleware.py b/twilio_notifications/middleware.py index 488a232..aca3ecf 100644 --- a/twilio_notifications/middleware.py +++ b/twilio_notifications/middleware.py @@ -1,67 +1,89 @@ -from __future__ import unicode_literals +import json +import logging +import os +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponse +from dotenv import load_dotenv from twilio.rest import Client -from django.core.exceptions import MiddlewareNotUsed -import os -import logging -import json logger = logging.getLogger(__name__) +dotenv_path = settings.PROJECT_PATH / '.env' +logger.debug(f'Reading .env file at: {dotenv_path}') +load_dotenv(dotenv_path=dotenv_path) + + MESSAGE = """[This is a test] ALERT! It appears the server is having issues. -Exception: %s. Go to: http://newrelic.com for more details.""" +Exception: {0}""" -NOT_CONFIGURED_MESSAGE = """Cannot initialize Twilio notification -middleware. Required enviroment variables TWILIO_ACCOUNT_SID, or -TWILIO_AUTH_TOKEN or TWILIO_NUMBER missing""" +NOT_CONFIGURED_MESSAGE = ( + "Required enviroment variables " + "TWILIO_ACCOUNT_SID or TWILIO_AUTH_TOKEN or TWILIO_NUMBER missing." +) def load_admins_file(): - with open('config/administrators.json') as adminsFile: - admins = json.load(adminsFile) - return admins + admins_json_path = settings.PROJECT_PATH / 'config' / 'administrators.json' + logger.debug(f'Loading administrators info from: {admins_json_path}') + return json.loads(admins_json_path.read_text()) def load_twilio_config(): - twilio_account_sid = os.environ.get('TWILIO_ACCOUNT_SID') - twilio_auth_token = os.environ.get('TWILIO_AUTH_TOKEN') - twilio_number = os.environ.get('TWILIO_NUMBER') + logger.debug('Loading Twilio configuration') + + twilio_account_sid = os.getenv('TWILIO_ACCOUNT_SID') + twilio_auth_token = os.getenv('TWILIO_AUTH_TOKEN') + twilio_number = os.getenv('TWILIO_NUMBER') if not all([twilio_account_sid, twilio_auth_token, twilio_number]): - logger.error(NOT_CONFIGURED_MESSAGE) - raise MiddlewareNotUsed + raise ImproperlyConfigured(NOT_CONFIGURED_MESSAGE) return (twilio_number, twilio_account_sid, twilio_auth_token) -class MessageClient(object): +class MessageClient: def __init__(self): - (twilio_number, twilio_account_sid, - twilio_auth_token) = load_twilio_config() + logger.debug('Initializing messaging client') + + ( + twilio_number, + twilio_account_sid, + twilio_auth_token, + ) = load_twilio_config() self.twilio_number = twilio_number - self.twilio_client = Client(twilio_account_sid, - twilio_auth_token) + self.twilio_client = Client(twilio_account_sid, twilio_auth_token) + + logger.debug('Twilio client initialized') def send_message(self, body, to): - self.twilio_client.messages.create(body=body, to=to, - from_=self.twilio_number, - # media_url=['https://demo.twilio.com/owl.png']) - ) + self.twilio_client.messages.create( + body=body, to=to, from_=self.twilio_number + ) -class TwilioNotificationsMiddleware(object): - def __init__(self): +class TwilioNotificationsMiddleware: + def __init__(self, get_response): + logger.debug('Initializing Twilio notifications middleware') + self.administrators = load_admins_file() self.client = MessageClient() + self.get_response = get_response + + logger.debug('Twilio notifications middleware initialized') + + def __call__(self, request): + return self.get_response(request) def process_exception(self, request, exception): - exception_message = str(exception) - message_to_send = MESSAGE % exception_message + message_to_send = MESSAGE.format(exception) for admin in self.administrators: self.client.send_message(message_to_send, admin['phone_number']) - logger.info('Administrators notified') - - return None + logger.info('Administrators notified!') + return HttpResponse( + "An error occured. Don't panic! Administrators are notified." + ) diff --git a/twilio_notifications/tests/test_notification_middleware.py b/twilio_notifications/tests/test_notification_middleware.py index 1a20a9b..38b76ca 100644 --- a/twilio_notifications/tests/test_notification_middleware.py +++ b/twilio_notifications/tests/test_notification_middleware.py @@ -1,20 +1,23 @@ -from __future__ import unicode_literals -from mock import patch, Mock -from django.core.exceptions import MiddlewareNotUsed -import unittest import os +import unittest -from twilio_notifications.middleware import TwilioNotificationsMiddleware -from twilio_notifications.middleware import MessageClient, load_twilio_config -from twilio_notifications.middleware import MESSAGE +from django.core.exceptions import ImproperlyConfigured +from mock import Mock, patch +from twilio_notifications.middleware import ( + MESSAGE, + MessageClient, + TwilioNotificationsMiddleware, + load_admins_file, + load_twilio_config, +) class TestNotificationMiddleware(unittest.TestCase): - @patch('twilio_notifications.middleware.load_twilio_config') @patch('twilio_notifications.middleware.load_admins_file') - def test_notify_on_exception(self, mock_load_admins_file, - mock_load_twilio_config): + def test_notify_on_exception( + self, mock_load_admins_file, mock_load_twilio_config + ): # Given admin_number = '+15550005555' @@ -25,10 +28,13 @@ def test_notify_on_exception(self, mock_load_admins_file, ] mock_message_client = Mock(spec=MessageClient) - mock_load_twilio_config.return_value = (sending_number, - '4ccou1s1d', 'som3tok3n') + mock_load_twilio_config.return_value = ( + sending_number, + '4ccou1s1d', + 'som3tok3n', + ) - middleware = TwilioNotificationsMiddleware() + middleware = TwilioNotificationsMiddleware(None) middleware.client = mock_message_client exception_message = 'Some exception message' @@ -38,7 +44,7 @@ def test_notify_on_exception(self, mock_load_admins_file, # Then mock_message_client.send_message.assert_called_once_with( - MESSAGE % exception_message, admin_number + MESSAGE.format(exception_message), admin_number ) def test_correct_load_twilio_config(self): @@ -48,7 +54,7 @@ def test_correct_load_twilio_config(self): try: load_twilio_config() - except MiddlewareNotUsed: + except ImproperlyConfigured: self.fail('MiddlewareNotUsed when correctly configured') def test_fail_load_twilio_config(self): @@ -56,5 +62,10 @@ def test_fail_load_twilio_config(self): os.environ['TWILIO_AUTH_TOKEN'] = 'sometok3n' os.environ.pop('TWILIO_NUMBER') - with self.assertRaises(MiddlewareNotUsed): + with self.assertRaises(ImproperlyConfigured): load_twilio_config() + + def test_load_admins_file(self): + administrators = load_admins_file() + self.assertIsInstance(administrators, list) + self.assertGreater(len(administrators), 0) diff --git a/twilio_sample_project/settings/common.py b/twilio_sample_project/settings/common.py index 160c6df..cf86e7a 100644 --- a/twilio_sample_project/settings/common.py +++ b/twilio_sample_project/settings/common.py @@ -1,18 +1,22 @@ """ -Common Django settings for the project. +Django settings for twilio_sample_project project. -See the local, test, and production settings modules for the values used -in each environment. +Generated by 'django-admin startproject' using Django 2.2.5. For more information on this file, see -https://docs.djangoproject.com/en/1.8/topics/settings/ +https://docs.djangoproject.com/en/2.2/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/1.8/ref/settings/ +https://docs.djangoproject.com/en/2.2/ref/settings/ """ + import os +from pathlib import Path + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROJECT_PATH = Path(BASE_DIR).parent # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False @@ -31,33 +35,36 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'django.contrib.humanize' ) THIRD_PARTY_APPS = () -LOCAL_APPS = () +LOCAL_APPS = ( + 'twilio_notifications', +) INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( + 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'django.middleware.security.SecurityMiddleware', + 'twilio_notifications.middleware.TwilioNotificationsMiddleware', ) +APPEND_SLASH = True + ROOT_URLCONF = 'twilio_sample_project.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': ['templates/'], + 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -77,12 +84,19 @@ 'disable_existing_loggers': False, 'formatters': { 'simple': { - 'format': '%(levelname)s %(message)s' + 'format': '[{levelname}] {name} - {message}', + 'style': '{' }, }, + 'filters': { + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + } + }, 'handlers': { 'console': { 'level': 'DEBUG', + 'filters': ['require_debug_true'], 'class': 'logging.StreamHandler', 'formatter': 'simple' }, @@ -91,7 +105,7 @@ 'django.request': { 'handlers': ['console'], 'propagate': False, - 'level': 'DEBUG', + 'level': 'INFO', }, 'twilio_notifications.middleware': { 'handlers': ['console'], @@ -101,10 +115,32 @@ }, } -DATABASES = {} +# Database +# https://docs.djangoproject.com/en/2.2/ref/settings/#databases + +DATABASES = { +} + +# Password validation +# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] # Internationalization -# https://docs.djangoproject.com/en/1.8/topics/i18n/ +# https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = 'en-us' @@ -118,9 +154,9 @@ # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/1.8/howto/static-files/ +# https://docs.djangoproject.com/en/2.2/howto/static-files/ +STATIC_URL = '/static/' STATIC_ROOT = BASE_DIR + '/staticfiles' STATIC_URL = '/static/' - diff --git a/twilio_sample_project/tests.py b/twilio_sample_project/tests.py new file mode 100644 index 0000000..e778da3 --- /dev/null +++ b/twilio_sample_project/tests.py @@ -0,0 +1,7 @@ +import unittest +from .urls import error_handler + + +class TestTwilioSampleProject(unittest.TestCase): + def test_error_handler(self): + self.assertRaises(Exception, error_handler, None) diff --git a/twilio_sample_project/urls.py b/twilio_sample_project/urls.py index e6bef27..c7d9c54 100644 --- a/twilio_sample_project/urls.py +++ b/twilio_sample_project/urls.py @@ -1,10 +1,11 @@ -from django.conf.urls import url +from django.urls import path def error_handler(request): - raise Exception('Uh on an error happened') + raise Exception('Uh oh an error happened') + urlpatterns = [ # Your URLs go here - url(r'^error/$', error_handler), + path('error/', error_handler), ] diff --git a/twilio_sample_project/wsgi.py b/twilio_sample_project/wsgi.py index 34b8a62..56342f4 100644 --- a/twilio_sample_project/wsgi.py +++ b/twilio_sample_project/wsgi.py @@ -10,7 +10,6 @@ import os from django.core.wsgi import get_wsgi_application -from whitenoise.django import DjangoWhiteNoise # Use our production settings as our default settings, which is most secure os.environ.setdefault( @@ -19,7 +18,3 @@ # Get a WSGI application for our project application = get_wsgi_application() - -# Use Whitenoise to serve static files -# See: https://whitenoise.readthedocs.org/ -application = DjangoWhiteNoise(application)