From 6d88d47be6d37234aab86d0e863e371f28347d12 Mon Sep 17 00:00:00 2001 From: Justin Michalicek Date: Tue, 30 Jul 2013 22:29:34 -0400 Subject: [PATCH] Fixed #20832 -- Enabled HTML password reset email Added optional html_email_template_name parameter to password_reset view and PasswordResetForm. --- AUTHORS | 1 + django/contrib/auth/forms.py | 9 ++- .../html_password_reset_email.html | 1 + django/contrib/auth/tests/test_forms.py | 55 +++++++++++++++++++ django/contrib/auth/tests/test_views.py | 19 +++++++ django/contrib/auth/tests/urls.py | 1 + django/contrib/auth/views.py | 4 +- docs/releases/1.7.txt | 4 ++ docs/topics/auth/default.txt | 10 +++- 9 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 django/contrib/auth/tests/templates/registration/html_password_reset_email.html diff --git a/AUTHORS b/AUTHORS index 130122c5ea6..15f3e8dbf42 100644 --- a/AUTHORS +++ b/AUTHORS @@ -417,6 +417,7 @@ answer newbie questions, and generally made Django that much better: Zain Memon Christian Metts michal@plovarna.cz + Justin Michalicek Slawek Mikula Katie Miller Shawn Milochik diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index c5c2db456e9..3eba8abd518 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -230,7 +230,7 @@ class PasswordResetForm(forms.Form): subject_template_name='registration/password_reset_subject.txt', email_template_name='registration/password_reset_email.html', use_https=False, token_generator=default_token_generator, - from_email=None, request=None): + from_email=None, request=None, html_email_template_name=None): """ Generates a one-use only link for resetting password and sends to the user. @@ -263,7 +263,12 @@ class PasswordResetForm(forms.Form): # Email subject *must not* contain newlines subject = ''.join(subject.splitlines()) email = loader.render_to_string(email_template_name, c) - send_mail(subject, email, from_email, [user.email]) + + if html_email_template_name: + html_email = loader.render_to_string(html_email_template_name, c) + else: + html_email = None + send_mail(subject, email, from_email, [user.email], html_message=html_email) class SetPasswordForm(forms.Form): diff --git a/django/contrib/auth/tests/templates/registration/html_password_reset_email.html b/django/contrib/auth/tests/templates/registration/html_password_reset_email.html new file mode 100644 index 00000000000..1ebb550048d --- /dev/null +++ b/django/contrib/auth/tests/templates/registration/html_password_reset_email.html @@ -0,0 +1 @@ +Link diff --git a/django/contrib/auth/tests/test_forms.py b/django/contrib/auth/tests/test_forms.py index ae2ec934284..eef366f1847 100644 --- a/django/contrib/auth/tests/test_forms.py +++ b/django/contrib/auth/tests/test_forms.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import os +import re from django import forms from django.contrib.auth import get_user_model @@ -452,6 +453,60 @@ class PasswordResetFormTest(TestCase): form.save() self.assertEqual(len(mail.outbox), 0) + @override_settings( + TEMPLATE_LOADERS=('django.template.loaders.filesystem.Loader',), + TEMPLATE_DIRS=( + os.path.join(os.path.dirname(upath(__file__)), 'templates'), + ), + ) + def test_save_plaintext_email(self): + """ + Test the PasswordResetForm.save() method with no html_email_template_name + parameter passed in. + Test to ensure original behavior is unchanged after the parameter was added. + """ + (user, username, email) = self.create_dummy_user() + form = PasswordResetForm({"email": email}) + self.assertTrue(form.is_valid()) + form.save() + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0].message() + self.assertFalse(message.is_multipart()) + self.assertEqual(message.get_content_type(), 'text/plain') + self.assertEqual(message.get('subject'), 'Custom password reset on example.com') + self.assertEqual(len(mail.outbox[0].alternatives), 0) + self.assertEqual(message.get_all('to'), [email]) + self.assertTrue(re.match(r'^http://example.com/reset/[\w+/-]', message.get_payload())) + + @override_settings( + TEMPLATE_LOADERS=('django.template.loaders.filesystem.Loader',), + TEMPLATE_DIRS=( + os.path.join(os.path.dirname(upath(__file__)), 'templates'), + ), + ) + def test_save_html_email_template_name(self): + """ + Test the PasswordResetFOrm.save() method with html_email_template_name + parameter specified. + Test to ensure that a multipart email is sent with both text/plain + and text/html parts. + """ + (user, username, email) = self.create_dummy_user() + form = PasswordResetForm({"email": email}) + self.assertTrue(form.is_valid()) + form.save(html_email_template_name='registration/html_password_reset_email.html') + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox[0].alternatives), 1) + message = mail.outbox[0].message() + self.assertEqual(message.get('subject'), 'Custom password reset on example.com') + self.assertEqual(len(message.get_payload()), 2) + self.assertTrue(message.is_multipart()) + self.assertEqual(message.get_payload(0).get_content_type(), 'text/plain') + self.assertEqual(message.get_payload(1).get_content_type(), 'text/html') + self.assertEqual(message.get_all('to'), [email]) + self.assertTrue(re.match(r'^http://example.com/reset/[\w/-]+', message.get_payload(0).get_payload())) + self.assertTrue(re.match(r'^Link$', message.get_payload(1).get_payload())) + class ReadOnlyPasswordHashTest(TestCase): diff --git a/django/contrib/auth/tests/test_views.py b/django/contrib/auth/tests/test_views.py index 4820116c68e..22ccbfd2257 100644 --- a/django/contrib/auth/tests/test_views.py +++ b/django/contrib/auth/tests/test_views.py @@ -128,6 +128,25 @@ class PasswordResetTest(AuthViewsTestCase): self.assertEqual(len(mail.outbox), 1) self.assertTrue("http://" in mail.outbox[0].body) self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email) + # optional multipart text/html email has been added. Make sure original, + # default functionality is 100% the same + self.assertFalse(mail.outbox[0].message().is_multipart()) + + def test_html_mail_template(self): + """ + A multipart email with text/plain and text/html is sent + if the html_email_template parameter is passed to the view + """ + response = self.client.post('/password_reset/html_email_template/', {'email': 'staffmember@example.com'}) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0].message() + self.assertEqual(len(message.get_payload()), 2) + self.assertTrue(message.is_multipart()) + self.assertEqual(message.get_payload(0).get_content_type(), 'text/plain') + self.assertEqual(message.get_payload(1).get_content_type(), 'text/html') + self.assertTrue('' not in message.get_payload(0).get_payload()) + self.assertTrue('' in message.get_payload(1).get_payload()) def test_email_found_custom_from(self): "Email is sent if a valid email address is provided for password reset when a custom from_email is provided." diff --git a/django/contrib/auth/tests/urls.py b/django/contrib/auth/tests/urls.py index 502fc659d45..2af83d21ea4 100644 --- a/django/contrib/auth/tests/urls.py +++ b/django/contrib/auth/tests/urls.py @@ -67,6 +67,7 @@ urlpatterns = urlpatterns + patterns('', (r'^password_reset_from_email/$', 'django.contrib.auth.views.password_reset', dict(from_email='staffmember@example.com')), (r'^password_reset/custom_redirect/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='/custom/')), (r'^password_reset/custom_redirect/named/$', 'django.contrib.auth.views.password_reset', dict(post_reset_redirect='password_reset')), + (r'^password_reset/html_email_template/$', 'django.contrib.auth.views.password_reset', dict(html_email_template_name='registration/html_password_reset_email.html')), (r'^reset/custom/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', 'django.contrib.auth.views.password_reset_confirm', dict(post_reset_redirect='/custom/')), diff --git a/django/contrib/auth/views.py b/django/contrib/auth/views.py index 1d2838e9b07..d852106a4e6 100644 --- a/django/contrib/auth/views.py +++ b/django/contrib/auth/views.py @@ -140,7 +140,8 @@ def password_reset(request, is_admin_site=False, post_reset_redirect=None, from_email=None, current_app=None, - extra_context=None): + extra_context=None, + html_email_template_name=None): if post_reset_redirect is None: post_reset_redirect = reverse('password_reset_done') else: @@ -155,6 +156,7 @@ def password_reset(request, is_admin_site=False, 'email_template_name': email_template_name, 'subject_template_name': subject_template_name, 'request': request, + 'html_email_template_name': html_email_template_name, } if is_admin_site: opts = dict(opts, domain_override=request.get_host()) diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index 970f3629490..28d9c9e1f11 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -118,6 +118,10 @@ Minor features customize the value of :attr:`ModelAdmin.fields `. +* :func:`django.contrib.auth.views.password_reset` takes an optional + ``html_email_template_name`` parameter used to send a multipart HTML email + for password resets. + Backwards incompatible changes in 1.7 ===================================== diff --git a/docs/topics/auth/default.txt b/docs/topics/auth/default.txt index 4ffafc17203..7dff9cdca72 100644 --- a/docs/topics/auth/default.txt +++ b/docs/topics/auth/default.txt @@ -793,7 +793,7 @@ patterns. * ``extra_context``: A dictionary of context data that will be added to the default context data passed to the template. -.. function:: password_reset(request[, is_admin_site, template_name, email_template_name, password_reset_form, token_generator, post_reset_redirect, from_email, current_app, extra_context]) +.. function:: password_reset(request[, is_admin_site, template_name, email_template_name, password_reset_form, token_generator, post_reset_redirect, from_email, current_app, extra_context, html_email_template_name]) Allows a user to reset their password by generating a one-time use link that can be used to reset the password, and sending that link to the @@ -856,6 +856,14 @@ patterns. * ``extra_context``: A dictionary of context data that will be added to the default context data passed to the template. + * ``html_email_template_name``: The full name of a template to use + for generating a ``text/html`` multipart email with the password reset + link. By default, HTML email is not sent. + + .. versionadded:: 1.7 + + ``html_email_template_name`` was added. + **Template context:** * ``form``: The form (see ``password_reset_form`` above) for resetting