Merge pull request #16545 from appsembler/omar/fmo/multipart-email

Use edx-ace for the password reset email
This commit is contained in:
Gabe Mulley
2018-05-10 11:31:43 -04:00
committed by GitHub
27 changed files with 270 additions and 132 deletions

View File

@@ -10,15 +10,22 @@ from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import RegexValidator, slug_re
from django.forms import widgets
from django.template import loader
from django.utils.http import int_to_base36
from django.utils.translation import ugettext_lazy as _
from django.core.validators import RegexValidator, slug_re
from edx_ace import ace
from edx_ace.recipient import Recipient
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import accounts as accounts_settings
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from student.message_types import PasswordReset
from student.models import CourseEnrollmentAllowed
from util.password_policy_validators import password_max_length, password_min_length, validate_password
@@ -47,41 +54,39 @@ class PasswordResetFormNoActive(PasswordResetForm):
raise forms.ValidationError(self.error_messages['unusable'])
return email
def save(
self,
subject_template_name='emails/password_reset_subject.txt',
email_template_name='registration/password_reset_email.html',
use_https=False,
token_generator=default_token_generator,
from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
request=None
):
def save(self, # pylint: disable=arguments-differ
use_https=False,
token_generator=default_token_generator,
request=None,
**_kwargs):
"""
Generates a one-use only link for resetting password and sends to the
user.
"""
# This import is here because we are copying and modifying the .save from Django 1.4.5's
# django.contrib.auth.forms.PasswordResetForm directly, which has this import in this place.
from django.core.mail import send_mail
for user in self.users_cache:
site_name = configuration_helpers.get_value(
'SITE_NAME',
settings.SITE_NAME
site = Site.objects.get_current()
message_context = get_base_template_context(site)
message_context.update({
'request': request, # Used by google_analytics_tracking_pixel
# TODO: This overrides `platform_name` from `get_base_template_context` to make the tests passes
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
'reset_link': '{protocol}://{site}{link}'.format(
protocol='https' if use_https else 'http',
site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME),
link=reverse('password_reset_confirm', kwargs={
'uidb36': int_to_base36(user.id),
'token': token_generator.make_token(user),
}),
)
})
msg = PasswordReset().personalize(
recipient=Recipient(user.username, user.email),
language=get_user_preference(user, LANGUAGE_KEY),
user_context=message_context,
)
context = {
'email': user.email,
'site_name': site_name,
'uid': int_to_base36(user.id),
'user': user,
'token': token_generator.make_token(user),
'protocol': 'https' if use_https else 'http',
'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME)
}
subject = loader.render_to_string(subject_template_name, context)
# Email subject *must not* contain newlines
subject = subject.replace('\n', '')
email = loader.render_to_string(email_template_name, context)
send_mail(subject, email, from_email, [user.email])
ace.send(msg)
class TrueCheckbox(widgets.CheckboxInput):

View File

@@ -0,0 +1,17 @@
"""
ACE message types for the student module.
"""
from django.conf import settings
from edx_ace.message import MessageType
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
class PasswordReset(MessageType):
def __init__(self, *args, **kwargs):
super(PasswordReset, self).__init__(*args, **kwargs)
self.options['transactional'] = True
self.options['from_address'] = configuration_helpers.get_value(
'email_from_address', settings.DEFAULT_FROM_EMAIL
)

View File

@@ -15,6 +15,7 @@ FAKE_SITE = {
"university": "fakeuniversity",
"course_org_filter": "fakeorg",
"platform_name": "Fake University",
"PLATFORM_NAME": "Fake University",
"email_from_address": "no-reply@fakeuniversity.com",
"REGISTRATION_EXTRA_FIELDS": {
"address1": "required",

View File

@@ -11,6 +11,7 @@ from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.core.cache import cache
from django.core import mail
from django.core.urlresolvers import reverse
from django.test.client import RequestFactory
from django.test.utils import override_settings
@@ -110,13 +111,12 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
cache.clear()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch('django.core.mail.send_mail')
@patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True))
def test_reset_password_email(self, send_email):
@ddt.data('plain_text', 'html')
def test_reset_password_email(self, body_type):
"""Tests contents of reset password email, and that user is not active"""
good_req = self.request_factory.post('/password_reset/', {'email': self.user.email})
good_req.user = self.user
good_req.site = Mock(domain='example.com')
dop_client = ClientFactory()
dop_access_token = AccessTokenFactory(user=self.user, client=dop_client)
RefreshTokenFactory(user=self.user, client=dop_client, access_token=dop_access_token)
@@ -130,26 +130,35 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
self.assertFalse(dot_models.AccessToken.objects.filter(user=self.user).exists())
self.assertFalse(dot_models.RefreshToken.objects.filter(user=self.user).exists())
obj = json.loads(good_resp.content)
self.assertEquals(obj, {
'success': True,
'value': "('registration/password_reset_done.html', [])",
})
self.assertTrue(obj['success'])
self.assertIn('e-mailed you instructions for setting your password', obj['value'])
(subject, msg, from_addr, to_addrs) = send_email.call_args[0]
self.assertIn("Password reset", subject)
self.assertIn("You're receiving this e-mail because you requested a password reset", msg)
self.assertEquals(from_addr, configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL))
self.assertEquals(len(to_addrs), 1)
self.assertIn(self.user.email, to_addrs)
from_email = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
sent_message = mail.outbox[0]
bodies = {
'plain_text': sent_message.body,
'html': sent_message.alternatives[0][0],
}
body = bodies[body_type]
self.assertIn("Password reset", sent_message.subject)
self.assertIn("You're receiving this e-mail because you requested a password reset", body)
self.assertEquals(sent_message.from_email, from_email)
self.assertEquals(len(sent_message.to), 1)
self.assertIn(self.user.email, sent_message.to)
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None,
)
#test that the user is not active
# Test that the user is not active
self.user = User.objects.get(pk=self.user.pk)
self.assertFalse(self.user.is_active)
re.search(r'password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', msg).groupdict()
self.assertIn('password_reset_confirm/', body)
re.search(r'password_reset_confirm/(?P<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', body).groupdict()
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch('django.core.mail.send_mail')
@@ -162,6 +171,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
req = self.request_factory.post(
'/password_reset/', {'email': self.user.email}
)
req.site = Mock(domain='example.com')
req.is_secure = Mock(return_value=is_secure)
req.user = self.user
password_reset(req)
@@ -189,6 +199,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
'/password_reset/', {'email': self.user.email}
)
req.user = self.user
req.site = Mock(domain='example.com')
password_reset(req)
_, msg, _, _ = send_email.call_args[0]
@@ -206,8 +217,8 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
@patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value)
@patch('django.core.mail.send_mail')
def test_reset_password_email_configuration_override(self, send_email):
@ddt.data('plain_text', 'html')
def test_reset_password_email_configuration_override(self, body_type):
"""
Tests that the right url domain and platform name is included in
the reset password email
@@ -216,18 +227,28 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
'/password_reset/', {'email': self.user.email}
)
req.get_host = Mock(return_value=None)
req.site = Mock(domain='example.com')
req.user = self.user
password_reset(req)
_, msg, from_addr, _ = send_email.call_args[0]
reset_msg = "you requested a password reset for your user account at {}".format(fake_get_value('platform_name'))
with patch('crum.get_current_request', return_value=req):
password_reset(req)
self.assertIn(reset_msg, msg)
sent_message = mail.outbox[0]
bodies = {
'plain_text': sent_message.body,
'html': sent_message.alternatives[0][0],
}
body = bodies[body_type]
reset_msg = "you requested a password reset for your user account at {}".format(fake_get_value('PLATFORM_NAME'))
self.assertIn(reset_msg, body)
self.assert_event_emitted(
SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'password', old=None, new=None
)
self.assertEqual(from_addr, "no-reply@fakeuniversity.com")
self.assertEqual(sent_message.from_email, "no-reply@fakeuniversity.com")
@ddt.data(
('invalidUid', 'invalid_token'),
@@ -351,6 +372,7 @@ class ResetPasswordTests(EventTestMixin, CacheIsolationTestCase):
'/password_reset/', {'email': self.user.email}
)
req.user = self.user
req.site = Mock(domain='example.com')
password_reset(req)
subj, _, _, _ = send_email.call_args[0]

View File

@@ -0,0 +1,40 @@
{% extends 'ace_common/edx_ace/common/base_body.html' %}
{% load i18n %}
{% load static %}
{% block content %}
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<h1>
{% trans "Password Reset" %}
</h1>
<p style="color: rgba(0,0,0,.75);">
{% blocktrans %}You're receiving this e-mail because you requested a password reset for your user account at {{ platform_name }}.{% endblocktrans %}
<br />
</p>
{% if failed %}
<p style="color: rgba(0,0,0,.75);">
{% blocktrans %}However, there is currently no user account associated with your email address: {{ email_address }}.{% endblocktrans %}
<br />
</p>
<p style="color: rgba(0,0,0,.75);">
{% trans "If you did not request this change, you can ignore this email." %}
<br />
</p>
{% else %}
<p style="color: rgba(0,0,0,.75);">
{% trans "If you didn't request this change, you can disregard this email - we have not yet reset your password." %}
<br />
</p>
{% trans "Change my Password" as course_cta_text %}
{% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=reset_link %}
{% endif %}
</td>
</tr>
</table>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}You're receiving this e-mail because you requested a password reset for your user account at {{ platform_name }}.{% endblocktrans %}
{% if failed %}
{% blocktrans %}However, there is currently no user account associated with your email address: {{ email_address }}.{% endblocktrans %}
{% trans "If you did not request this change, you can ignore this email." %}
{% else %}
{% trans "Please go to the following page and choose a new password:" %}
{{ reset_link }}
{% trans "If you didn't request this change, you can disregard this email - we have not yet reset your password." %}
{% trans "Thanks for using our site!" %}
{% endif %}
{% blocktrans %}The {{ platform_name }} Team{% endblocktrans %}
{% endautoescape %}

View File

@@ -0,0 +1 @@
{{ platform_name }}

View File

@@ -0,0 +1 @@
{% extends 'ace_common/edx_ace/common/base_head.html' %}

View File

@@ -0,0 +1,4 @@
{% load i18n %}
{% autoescape off %}
{% blocktrans trimmed %}Password reset on {{ platform_name }}{% endblocktrans %}
{% endautoescape %}