From 791fb6879baf0df870d069eb31a33266ac1825fc Mon Sep 17 00:00:00 2001 From: Andrew Pinkham Date: Tue, 22 Aug 2017 17:33:30 -0700 Subject: [PATCH] Provide example project integration (#28) Demonstrate basic integration of django-improved-user with Django and django-registration. --- .gitignore | 3 + .isort.cfg | 4 +- .pylintrc | 2 +- .travis.yml | 5 +- MANIFEST.in | 1 + README.rst | 7 + example_project/config/__init__.py | 0 example_project/config/settings.py | 151 ++++++++++++ example_project/config/urls.py | 26 ++ example_project/config/wsgi.py | 16 ++ example_project/manage.py | 22 ++ example_project/user_integration/__init__.py | 0 example_project/user_integration/apps.py | 7 + .../user_integration/templates/base.html | 15 ++ .../user_integration/templates/home.html | 17 ++ .../registration/activation_complete.html | 6 + .../registration/activation_email.txt | 6 + .../registration/activation_email_subject.txt | 1 + .../templates/registration/login.html | 19 ++ .../templates/registration/logout.html | 8 + .../registration/password_reset_done.html | 6 + .../registration/password_reset_email.txt | 5 + .../registration/registration_complete.html | 6 + .../registration/registration_form.html | 11 + example_project/user_integration/tests.py | 226 ++++++++++++++++++ example_project/user_integration/urls.py | 22 ++ requirements.txt | 1 + tox.ini | 38 +-- 28 files changed, 612 insertions(+), 19 deletions(-) create mode 100644 example_project/config/__init__.py create mode 100644 example_project/config/settings.py create mode 100644 example_project/config/urls.py create mode 100644 example_project/config/wsgi.py create mode 100755 example_project/manage.py create mode 100644 example_project/user_integration/__init__.py create mode 100644 example_project/user_integration/apps.py create mode 100644 example_project/user_integration/templates/base.html create mode 100644 example_project/user_integration/templates/home.html create mode 100644 example_project/user_integration/templates/registration/activation_complete.html create mode 100644 example_project/user_integration/templates/registration/activation_email.txt create mode 100644 example_project/user_integration/templates/registration/activation_email_subject.txt create mode 100644 example_project/user_integration/templates/registration/login.html create mode 100644 example_project/user_integration/templates/registration/logout.html create mode 100644 example_project/user_integration/templates/registration/password_reset_done.html create mode 100644 example_project/user_integration/templates/registration/password_reset_email.txt create mode 100644 example_project/user_integration/templates/registration/registration_complete.html create mode 100644 example_project/user_integration/templates/registration/registration_form.html create mode 100644 example_project/user_integration/tests.py create mode 100644 example_project/user_integration/urls.py diff --git a/.gitignore b/.gitignore index 72364f9..35148cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Project Specific +example_project/db.sqlite3 + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.isort.cfg b/.isort.cfg index b91b4c5..83f085a 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -3,7 +3,9 @@ balanced_wrapping=true combine_as_imports=true force_add=true include_trailing_comma=true -known_third_party=django +known_third_party= + django + registration known_first_party=improved_user indent=' ' line_length=79 diff --git a/.pylintrc b/.pylintrc index 95c69d6..dded504 100644 --- a/.pylintrc +++ b/.pylintrc @@ -123,7 +123,7 @@ class-rgx=[A-Z_][a-zA-Z0-9]+$ const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ # Regular expression matching correct constant names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|application)$ # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. diff --git a/.travis.yml b/.travis.yml index 9ace749..deb5409 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,10 +44,13 @@ before_script: - if [[ `python -V | grep -c -e 3.6` -eq 1 && "$DJANGO" == 'django>=1.11,<1.12' ]]; then check-manifest . ; fi - if [[ `python -V | grep -c -e 3.6` -eq 1 && "$DJANGO" == 'django>=1.11,<1.12' ]]; then flake8 src tests setup.py runtests.py ; fi - if [[ `python -V | grep -c -e 3.6` -eq 1 && "$DJANGO" == 'django>=1.11,<1.12' ]]; then isort --verbose --check-only --diff --recursive src tests setup.py runtests.py ; fi -- if [[ `python -V | grep -c -e 3.6` -eq 1 && "$DJANGO" == 'django>=1.11,<1.12' ]]; then pylint --rcfile=.pylintrc -d fixme src tests setup.py runtests.py ; fi +- if [[ `python -V | grep -c -e 3.6` -eq 1 && "$DJANGO" == 'django>=1.11,<1.12' ]]; then pylint --rcfile=.pylintrc -d duplicate-code -d fixme src tests setup.py runtests.py ; fi script: # do not use setup.py test with coverage; missing files omitted entirely - coverage run runtests.py +- cd example_project +- ./manage.py test +- cd .. # for coverage reports after_success: # coverage is run in parallel, so it is necessary to combine the reports # Note that this is only for our benefit: codecov handles this itself diff --git a/MANIFEST.in b/MANIFEST.in index bf26aa5..3a0beb2 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -15,5 +15,6 @@ include setup.cfg include tox.ini prune .github prune docs +prune example_project recursive-include tests *.json recursive-include tests *.py diff --git a/README.rst b/README.rst index a1524a0..1dd1232 100644 --- a/README.rst +++ b/README.rst @@ -137,3 +137,10 @@ To run all linters and test multiple Python and Django versions, use You will need to install Python 3.4, 3.5, and 3.6 on your system for this to work. + +You may also limit tests to specific environments or test suites with tox. For instance: + +.. code:: console + + $ tox -e py36-django111-unit tests.test_basic + $ tox -e py36-django111-integration user_integration.tests.TestViews.test_home diff --git a/example_project/config/__init__.py b/example_project/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_project/config/settings.py b/example_project/config/settings.py new file mode 100644 index 0000000..11d2c15 --- /dev/null +++ b/example_project/config/settings.py @@ -0,0 +1,151 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 1.11.3. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.11/ref/settings/ +""" + +import os + +from django import VERSION as DjangoVersion + +# 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/1.11/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = ')#-l63lltqso!-&dz3r)xb&p#mz*s=dti_l@=1&ynd_3$+sw85' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'improved_user.apps.ImprovedUserConfig', + 'user_integration.apps.UserIntegrationConfig', +] + +if DjangoVersion >= (1, 10): + 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.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + ] +else: + MIDDLEWARE_CLASSES = ( + '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', + ) + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Database +# https://docs.djangoproject.com/en/1.11/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + }, +} + + +# Password validation +# https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators + +AUTH_USER_MODEL = 'improved_user.User' + +LOGIN_REDIRECT_URL = 'home' +LOGIN_URL = LOGOUT_REDIRECT_URL = 'auth_login' + +AUTH_PREFIX = 'django.contrib.auth.password_validation.' +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': AUTH_PREFIX + 'UserAttributeSimilarityValidator', + 'OPTIONS': { + 'user_attributes': ('email', 'full_name', 'short_name'), + }, + }, + { + 'NAME': AUTH_PREFIX + 'MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 12, + }, + }, + { + 'NAME': AUTH_PREFIX + 'CommonPasswordValidator', + }, + { + 'NAME': AUTH_PREFIX + 'NumericPasswordValidator', + }, +] + +ACCOUNT_ACTIVATION_DAYS = 3 + + +# Internationalization +# https://docs.djangoproject.com/en/1.11/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.11/howto/static-files/ + +STATIC_URL = '/static/' diff --git a/example_project/config/urls.py b/example_project/config/urls.py new file mode 100644 index 0000000..8addec2 --- /dev/null +++ b/example_project/config/urls.py @@ -0,0 +1,26 @@ +"""config URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.11/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import include, url +from django.contrib import admin +from django.views.generic import TemplateView + +from user_integration import urls as account_urls + +urlpatterns = [ + url(r'^admin/', admin.site.urls), + url(r'^accounts/', include(account_urls)), + url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), +] diff --git a/example_project/config/wsgi.py b/example_project/config/wsgi.py new file mode 100644 index 0000000..d382c3b --- /dev/null +++ b/example_project/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/example_project/manage.py b/example_project/manage.py new file mode 100755 index 0000000..deebfef --- /dev/null +++ b/example_project/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Script that allows developers to run Django commands""" +import os +import sys + +if __name__ == '__main__': + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django # noqa: F401 pylint: disable=unused-import + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?') + raise + execute_from_command_line(sys.argv) diff --git a/example_project/user_integration/__init__.py b/example_project/user_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example_project/user_integration/apps.py b/example_project/user_integration/apps.py new file mode 100644 index 0000000..545ac55 --- /dev/null +++ b/example_project/user_integration/apps.py @@ -0,0 +1,7 @@ +"""Application Definition File""" +from django.apps import AppConfig + + +class UserIntegrationConfig(AppConfig): + """AppConfig definition for user integration code""" + name = 'user_integration' diff --git a/example_project/user_integration/templates/base.html b/example_project/user_integration/templates/base.html new file mode 100644 index 0000000..bdf99e5 --- /dev/null +++ b/example_project/user_integration/templates/base.html @@ -0,0 +1,15 @@ + + + + + Django Improved User Test Project + + + + + + {% block content %}{% endblock %} + + diff --git a/example_project/user_integration/templates/home.html b/example_project/user_integration/templates/home.html new file mode 100644 index 0000000..968c48c --- /dev/null +++ b/example_project/user_integration/templates/home.html @@ -0,0 +1,17 @@ +{% extends parent_template|default:'base.html' %} +{% load i18n %} + +{% block content %} + {% if user.is_authenticated %} +

{% blocktrans with name=user.get_short_name %} + Hello {{name}}! + {% endblocktrans %}

+

+ {% trans 'Log Out' %}

+ {% else %} +

+ {% trans 'Register' %}

+

+ {% trans 'Log In' %}

+ {% endif %} +{% endblock %} diff --git a/example_project/user_integration/templates/registration/activation_complete.html b/example_project/user_integration/templates/registration/activation_complete.html new file mode 100644 index 0000000..530df9e --- /dev/null +++ b/example_project/user_integration/templates/registration/activation_complete.html @@ -0,0 +1,6 @@ +{% extends parent_template|default:'base.html' %} +{% load i18n %} + +{% block content %} +

{% trans 'Your account is now activated.' %}

+{% endblock %} diff --git a/example_project/user_integration/templates/registration/activation_email.txt b/example_project/user_integration/templates/registration/activation_email.txt new file mode 100644 index 0000000..911df01 --- /dev/null +++ b/example_project/user_integration/templates/registration/activation_email.txt @@ -0,0 +1,6 @@ +{% load i18n %} +{% trans "Activate account at" %} {{ site.name }}: + +http://{{ site.domain }}{% url 'registration_activate' activation_key %} + +{% blocktrans %}Link is valid for {{ expiration_days }} days.{% endblocktrans %} diff --git a/example_project/user_integration/templates/registration/activation_email_subject.txt b/example_project/user_integration/templates/registration/activation_email_subject.txt new file mode 100644 index 0000000..3d31cef --- /dev/null +++ b/example_project/user_integration/templates/registration/activation_email_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% trans 'Account activation on' %} {{ site.name }} diff --git a/example_project/user_integration/templates/registration/login.html b/example_project/user_integration/templates/registration/login.html new file mode 100644 index 0000000..403c814 --- /dev/null +++ b/example_project/user_integration/templates/registration/login.html @@ -0,0 +1,19 @@ +{% extends parent_template|default:'base.html' %} +{% load i18n %} + +{% block content %} +

+ {% trans 'Not a member?' %} + {% trans 'Register!' %} +

+
+ + {% csrf_token %} + {{ form.as_p }} +

+ {% trans 'Forgot password?' %} + {% trans 'Reset it!' %} +

+ +
+{% endblock %} diff --git a/example_project/user_integration/templates/registration/logout.html b/example_project/user_integration/templates/registration/logout.html new file mode 100644 index 0000000..b56d210 --- /dev/null +++ b/example_project/user_integration/templates/registration/logout.html @@ -0,0 +1,8 @@ +{% extends parent_template|default:'base.html' %} +{% load i18n %} +{# Used only in Django 1.8, as LOGOUT_REDIRECT_URL setting added in 1.10 #} +{# TODO: delete this when Django 1.8 support dropped #} + +{% block content %} +

{% trans 'Logged out' %}

+{% endblock %} diff --git a/example_project/user_integration/templates/registration/password_reset_done.html b/example_project/user_integration/templates/registration/password_reset_done.html new file mode 100644 index 0000000..58f5b64 --- /dev/null +++ b/example_project/user_integration/templates/registration/password_reset_done.html @@ -0,0 +1,6 @@ +{% extends parent_template|default:'base.html' %} +{% load i18n %} + +{% block content %} +

{% trans 'Email with password reset instructions has been sent.' %}

+{% endblock %} diff --git a/example_project/user_integration/templates/registration/password_reset_email.txt b/example_project/user_integration/templates/registration/password_reset_email.txt new file mode 100644 index 0000000..c78893e --- /dev/null +++ b/example_project/user_integration/templates/registration/password_reset_email.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% blocktrans %}Reset password at {{ site_name }}{% endblocktrans %}: +{% block reset_link %} +{{ protocol }}://{{ domain }}{% url 'auth_password_reset_confirm' uid token %} +{% endblock %} diff --git a/example_project/user_integration/templates/registration/registration_complete.html b/example_project/user_integration/templates/registration/registration_complete.html new file mode 100644 index 0000000..530df9e --- /dev/null +++ b/example_project/user_integration/templates/registration/registration_complete.html @@ -0,0 +1,6 @@ +{% extends parent_template|default:'base.html' %} +{% load i18n %} + +{% block content %} +

{% trans 'Your account is now activated.' %}

+{% endblock %} diff --git a/example_project/user_integration/templates/registration/registration_form.html b/example_project/user_integration/templates/registration/registration_form.html new file mode 100644 index 0000000..1c51efc --- /dev/null +++ b/example_project/user_integration/templates/registration/registration_form.html @@ -0,0 +1,11 @@ +{% extends parent_template|default:'base.html' %} +{% load i18n %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} + + +
+{% endblock %} diff --git a/example_project/user_integration/tests.py b/example_project/user_integration/tests.py new file mode 100644 index 0000000..1002bc5 --- /dev/null +++ b/example_project/user_integration/tests.py @@ -0,0 +1,226 @@ +"""Test integration of Improved User with Django & Django Registration + +The goal is to ensure that all views provided by registration work as +desired with improved user. These end-to-end tests may be used on full +sites. +""" +from re import search as re_search + +from django import VERSION as DjangoVersion +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import TestCase + +from improved_user.forms import UserCreationForm + +# TODO: remove the conditional import when Dj 1.10 dropped +try: + from django.urls import reverse # pylint: disable=ungrouped-imports +except ImportError: # pragma: no cover + from django.core.urlresolvers import reverse + + +class TestViews(TestCase): + """Test Registration views to ensure User integration""" + + def test_home(self): + """Test that homeview returns basic template""" + get_response = self.client.get(reverse('home')) + self.assertEqual(200, get_response.status_code) + self.assertTemplateUsed(get_response, 'home.html') + self.assertTemplateUsed(get_response, 'base.html') + + def test_tester(self): + """Ensure that tests behave as expected""" + email = 'hello@jambonsw.com' + password = 's4f3passw0rd!' + User = get_user_model() # pylint: disable=invalid-name + User.objects.create_user(email, password) + self.assertTrue(self.client.login(username=email, password=password)) + + def test_account_registration(self): + """Test that users can register Improved User accounts""" + User = get_user_model() # pylint: disable=invalid-name + email = 'hello@jambonsw.com' + password = 's4f3passw0rd!' + + get_response = self.client.get(reverse('registration_register')) + self.assertEqual(200, get_response.status_code) + self.assertIsInstance(get_response.context['form'], UserCreationForm) + self.assertTemplateUsed('registration/registration_form.html') + self.assertTemplateUsed('base.html') + + post_response = self.client.post( + reverse('registration_register'), + data={ + 'email': email, + 'password1': password, + 'password2': password, + }, + ) + self.assertRedirects(post_response, reverse('registration_complete')) + self.assertTrue( + User.objects.filter(email=email).exists()) + user = User.objects.get(email=email) + self.assertTrue(user.check_password(password)) + self.assertFalse(user.is_active) + self.assertFalse(self.client.login(username=email, password=password)) + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [email]) + self.assertEqual( + mail.outbox[0].subject, 'Account activation on testserver') + urlmatch = re_search( + r'https?://[^/]*(/.*activate/\S*)', + mail.outbox[0].body) + self.assertIsNotNone(urlmatch, 'No URL found in sent email') + url_path = urlmatch.groups()[0] + self.assertEqual( + reverse('registration_activate', + kwargs={'activation_key': url_path.split('/')[3]}), + url_path) + activation_get_response = self.client.get(url_path) + self.assertRedirects( + activation_get_response, + reverse('registration_activation_complete')) + # reload user from DB + user = User.objects.get(email=email) + self.assertTrue(user.is_active) + self.assertTrue(self.client.login(username=email, password=password)) + + def test_user_login_logout(self): + """Simulate a user logging in and then out""" + email = 'hello@jambonsw.com' + password = 's4f3passw0rd!' + User = get_user_model() # pylint: disable=invalid-name + User.objects.create_user(email, password) + self.assertTrue(User.objects.filter(email=email).exists()) + + # get login + get_response = self.client.get(reverse('auth_login')) + self.assertEqual(200, get_response.status_code) + self.assertTemplateUsed(get_response, 'registration/login.html') + self.assertTemplateUsed(get_response, 'base.html') + + # post login + form_data = { + 'username': email, + 'password': password, + } + post_response = self.client.post(reverse('auth_login'), data=form_data) + self.assertRedirects(post_response, reverse('home')) + + # logout + get_logout_response = self.client.get(reverse('auth_logout')) + # TODO: remove condition when Dj 1.8 dropped + if DjangoVersion >= (1, 10): + self.assertRedirects(get_logout_response, reverse('auth_login')) + + def test_password_change(self): + """Simulate a user changing their password""" + email = 'hello@jambonsw.com' + password = 's4f3passw0rd!' + newpassword = 'neo.h1m1tsu!' + User = get_user_model() # pylint: disable=invalid-name + User.objects.create_user(email, password) + + response = self.client.get(reverse('auth_password_change')) + self.assertRedirects( + response, + '{}?next={}'.format( + reverse(settings.LOGIN_URL), reverse('auth_password_change'))) + self.client.login(username=email, password=password) + response = self.client.get(reverse('auth_password_change')) + # WARNING: + # this uses Django's admin template + # to change this behavior, place user_integration app before + # the admin app in the INSTALLED_APPS settings + self.assertTemplateUsed( + response, 'registration/password_change_form.html') + + data = { + 'old_password': password, + 'new_password1': newpassword, + 'new_password2': newpassword, + } + response = self.client.post( + reverse('auth_password_change'), data=data, follow=True) + self.assertRedirects(response, reverse('auth_password_change_done')) + self.assertEqual(response.status_code, 200) + # WARNING: + # this uses Django's admin template + # to change this behavior, place user_integration app before + # the admin app in the INSTALLED_APPS settings + self.assertTemplateUsed( + response, 'registration/password_change_done.html') + + self.client.logout() + self.assertTrue( + self.client.login(username=email, password=newpassword)) + + def test_password_reset(self): + """Simulate a user resetting their password""" + email = 'hello@jambonsw.com' + password = 's4f3passw0rd!' + newpassword = 'neo.h1m1tsu!' + User = get_user_model() # pylint: disable=invalid-name + User.objects.create_user(email, password) + + response = self.client.get(reverse('auth_password_reset')) + self.assertEqual(response.status_code, 200) + # WARNING: + # this uses Django's admin template + # to change this behavior, place user_integration app before + # the admin app in the INSTALLED_APPS settings + self.assertTemplateUsed( + response, 'registration/password_reset_form.html') + + data = {'email': email} + post_response = self.client.post( + reverse('auth_password_reset'), + data=data, + follow=True) + self.assertRedirects( + post_response, reverse('auth_password_reset_done')) + # WARNING: + # this uses Django's admin template + # to change this behavior, place user_integration app before + # the admin app in the INSTALLED_APPS settings + self.assertTemplateUsed( + post_response, 'registration/password_reset_done.html') + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, [email]) + self.assertEqual( + mail.outbox[0].subject, 'Password reset on testserver') + urlmatch = re_search(r'https?://[^/]*(/.*reset/\S*)', + mail.outbox[0].body) + self.assertIsNotNone(urlmatch, 'No URL found in sent email') + + url_path = urlmatch.groups()[0] + *_, uidb64, token = filter(None, url_path.split('/')) + self.assertEqual( + reverse('auth_password_reset_confirm', + kwargs={'uidb64': uidb64, 'token': token}), + url_path) + reset_get_response = self.client.get(url_path) + self.assertEqual(reset_get_response.status_code, 200) + # WARNING: + # this uses Django's admin template + # to change this behavior, place user_integration app before + # the admin app in the INSTALLED_APPS settings + self.assertTemplateUsed( + reset_get_response, 'registration/password_reset_confirm.html') + + data = { + 'new_password1': newpassword, + 'new_password2': newpassword, + } + reset_post_response = self.client.post( + url_path, data=data, follow=True) + self.assertRedirects( + reset_post_response, reverse('auth_password_reset_complete')) + + self.assertTrue( + self.client.login(username=email, password=newpassword)) diff --git a/example_project/user_integration/urls.py b/example_project/user_integration/urls.py new file mode 100644 index 0000000..4c064f1 --- /dev/null +++ b/example_project/user_integration/urls.py @@ -0,0 +1,22 @@ +"""URL Routing for user account interaction + +Uses the HMAC flow from django-registration with the Improved User. + +https://django-registration.readthedocs.io/en/latest/hmac.html +https://django-registration.readthedocs.io/en/latest/custom-user.html + +Your flake8 config will likely cause imports to be sorted differently. + +""" +from django.conf.urls import include, url +from registration.backends.hmac import urls as registration_urls +from registration.backends.hmac.views import RegistrationView + +from improved_user.forms import UserCreationForm + +urlpatterns = [ + url(r'^register/$', + RegistrationView.as_view(form_class=UserCreationForm), + name='registration_register'), + url(r'^', include(registration_urls)), +] diff --git a/requirements.txt b/requirements.txt index 78e8c7a..11c2583 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ bumpversion==0.5.3 check-manifest==0.35 coverage==4.4.1 +django-registration==2.2 docutils==0.14 factory_boy==2.9.2 Faker==0.7.18 diff --git a/tox.ini b/tox.ini index 1e86784..b20ab6a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,22 +1,24 @@ [tox] envlist = lint,pylint,pkgcheck, - {py34,py35}-django{18,110} - {py34,py35,py36}-django111, - {py34,py35,py36}-djangomaster + {py34,py35}-django{18,110,111,master}-unit + {py36}-django{111,master}-unit, + {py34,py35}-django{18,110,111,master}-integration + {py36}-django{111,master}-integration, -[testenv:pkgcheck] +[testenv:lint] basepython=python3.6 deps = + Django -r{toxinidir}/requirements.txt setenv = PYTHONDONTWRITEBYTECODE=1 skip_install = true commands = - ./setup.py check --strict --metadata --restructuredtext - check-manifest {toxinidir} + flake8 src tests setup.py runtests.py example_project + isort --verbose --check-only --diff --recursive src tests setup.py runtests.py example_project -[testenv:lint] +[testenv:pylint] basepython=python3.6 deps = Django @@ -25,31 +27,35 @@ setenv = PYTHONDONTWRITEBYTECODE=1 skip_install = true commands = - flake8 src tests setup.py runtests.py - isort --verbose --check-only --diff --recursive src tests setup.py runtests.py + pylint src tests setup.py runtests.py example_project/manage.py example_project/config example_project/user_integration -[testenv:pylint] +[testenv:pkgcheck] basepython=python3.6 deps = - Django -r{toxinidir}/requirements.txt setenv = PYTHONDONTWRITEBYTECODE=1 skip_install = true commands = - pylint src tests setup.py runtests.py + ./setup.py check --strict --metadata --restructuredtext + check-manifest {toxinidir} [testenv] +changedir= + unit: {toxinidir} + integration: example_project commands = - coverage erase - coverage run runtests.py - coverage combine --append - coverage report + unit: coverage erase + unit: coverage run runtests.py {posargs} + unit: coverage combine --append + unit: coverage report + integration: ./manage.py test {posargs} setenv = PYTHONDONTWRITEBYTECODE=1 PYTHONWARNINGS=once deps = coverage==4.4.1 + django-registration==2.2 factory_boy==2.9.2 Faker==0.7.18 python-dateutil==2.6.1