Fixed #20832 -- Enabled HTML password reset email

Added optional html_email_template_name parameter to password_reset view
and PasswordResetForm.
This commit is contained in:
Justin Michalicek 2013-07-30 22:29:34 -04:00 committed by Tim Graham
parent 94d7fed775
commit 6d88d47be6
9 changed files with 100 additions and 4 deletions

View File

@ -417,6 +417,7 @@ answer newbie questions, and generally made Django that much better:
Zain Memon Zain Memon
Christian Metts Christian Metts
michal@plovarna.cz michal@plovarna.cz
Justin Michalicek <jmichalicek@gmail.com>
Slawek Mikula <slawek dot mikula at gmail dot com> Slawek Mikula <slawek dot mikula at gmail dot com>
Katie Miller <katie@sub50.com> Katie Miller <katie@sub50.com>
Shawn Milochik <shawn@milochik.com> Shawn Milochik <shawn@milochik.com>

View File

@ -230,7 +230,7 @@ class PasswordResetForm(forms.Form):
subject_template_name='registration/password_reset_subject.txt', subject_template_name='registration/password_reset_subject.txt',
email_template_name='registration/password_reset_email.html', email_template_name='registration/password_reset_email.html',
use_https=False, token_generator=default_token_generator, 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 Generates a one-use only link for resetting password and sends to the
user. user.
@ -263,7 +263,12 @@ class PasswordResetForm(forms.Form):
# Email subject *must not* contain newlines # Email subject *must not* contain newlines
subject = ''.join(subject.splitlines()) subject = ''.join(subject.splitlines())
email = loader.render_to_string(email_template_name, c) 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): class SetPasswordForm(forms.Form):

View File

@ -0,0 +1 @@
<html><a href="{{ protocol }}://{{ domain }}/reset/{{ uid }}/{{ token }}/">Link</a></html>

View File

@ -1,6 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import os import os
import re
from django import forms from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -452,6 +453,60 @@ class PasswordResetFormTest(TestCase):
form.save() form.save()
self.assertEqual(len(mail.outbox), 0) 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'^<html><a href="http://example.com/reset/[\w/-]+/">Link</a></html>$', message.get_payload(1).get_payload()))
class ReadOnlyPasswordHashTest(TestCase): class ReadOnlyPasswordHashTest(TestCase):

View File

@ -128,6 +128,25 @@ class PasswordResetTest(AuthViewsTestCase):
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertTrue("http://" in mail.outbox[0].body) self.assertTrue("http://" in mail.outbox[0].body)
self.assertEqual(settings.DEFAULT_FROM_EMAIL, mail.outbox[0].from_email) 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('<html>' not in message.get_payload(0).get_payload())
self.assertTrue('<html>' in message.get_payload(1).get_payload())
def test_email_found_custom_from(self): 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." "Email is sent if a valid email address is provided for password reset when a custom from_email is provided."

View File

@ -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_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/$', '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/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<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', (r'^reset/custom/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
'django.contrib.auth.views.password_reset_confirm', 'django.contrib.auth.views.password_reset_confirm',
dict(post_reset_redirect='/custom/')), dict(post_reset_redirect='/custom/')),

View File

@ -140,7 +140,8 @@ def password_reset(request, is_admin_site=False,
post_reset_redirect=None, post_reset_redirect=None,
from_email=None, from_email=None,
current_app=None, current_app=None,
extra_context=None): extra_context=None,
html_email_template_name=None):
if post_reset_redirect is None: if post_reset_redirect is None:
post_reset_redirect = reverse('password_reset_done') post_reset_redirect = reverse('password_reset_done')
else: else:
@ -155,6 +156,7 @@ def password_reset(request, is_admin_site=False,
'email_template_name': email_template_name, 'email_template_name': email_template_name,
'subject_template_name': subject_template_name, 'subject_template_name': subject_template_name,
'request': request, 'request': request,
'html_email_template_name': html_email_template_name,
} }
if is_admin_site: if is_admin_site:
opts = dict(opts, domain_override=request.get_host()) opts = dict(opts, domain_override=request.get_host())

View File

@ -118,6 +118,10 @@ Minor features
customize the value of :attr:`ModelAdmin.fields customize the value of :attr:`ModelAdmin.fields
<django.contrib.admin.ModelAdmin.fields>`. <django.contrib.admin.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 Backwards incompatible changes in 1.7
===================================== =====================================

View File

@ -793,7 +793,7 @@ patterns.
* ``extra_context``: A dictionary of context data that will be added to the * ``extra_context``: A dictionary of context data that will be added to the
default context data passed to the template. 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 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 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 * ``extra_context``: A dictionary of context data that will be added to the
default context data passed to the template. 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:** **Template context:**
* ``form``: The form (see ``password_reset_form`` above) for resetting * ``form``: The form (see ``password_reset_form`` above) for resetting