Merge pull request #16545 from appsembler/omar/fmo/multipart-email
Use edx-ace for the password reset email
This commit is contained in:
@@ -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):
|
||||
|
||||
17
common/djangoapps/student/message_types.py
Normal file
17
common/djangoapps/student/message_types.py
Normal 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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1 @@
|
||||
{{ platform_name }}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends 'ace_common/edx_ace/common/base_head.html' %}
|
||||
@@ -0,0 +1,4 @@
|
||||
{% load i18n %}
|
||||
{% autoescape off %}
|
||||
{% blocktrans trimmed %}Password reset on {{ platform_name }}{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
Reference in New Issue
Block a user