diff --git a/common/djangoapps/student/message_types.py b/common/djangoapps/student/message_types.py index da89a2a3f5..5829557067 100644 --- a/common/djangoapps/student/message_types.py +++ b/common/djangoapps/student/message_types.py @@ -10,3 +10,10 @@ class PasswordReset(BaseMessageType): super(PasswordReset, self).__init__(*args, **kwargs) self.options['transactional'] = True + + +class EmailChange(BaseMessageType): + def __init__(self, *args, **kwargs): + super(EmailChange, self).__init__(*args, **kwargs) + + self.options['transactional'] = True diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index c6ff803e4e..babf69167b 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -1,4 +1,4 @@ - +# coding=utf-8 import json import unittest @@ -14,8 +14,10 @@ from mock import Mock, patch from six import text_type from edxmako.shortcuts import render_to_string +from openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixin from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, CacheIsolationMixin from student.models import ( @@ -275,13 +277,14 @@ class ReactivationEmailTests(EmailTestMixin, CacheIsolationTestCase): self.assertTrue(response_data['success']) -class EmailChangeRequestTests(EventTestMixin, CacheIsolationTestCase): +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS") +class EmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, CacheIsolationTestCase): """ Test changing a user's email address """ - def setUp(self): - super(EmailChangeRequestTests, self).setUp('student.views.management.tracker') + def setUp(self, tracker='student.views.management.tracker'): + super(EmailChangeRequestTests, self).setUp(tracker) self.user = UserFactory.create() self.new_email = 'new.email@edx.org' self.req_factory = RequestFactory() @@ -305,10 +308,8 @@ class EmailChangeRequestTests(EventTestMixin, CacheIsolationTestCase): """ Executes do_email_change_request, returning any resulting error message. """ - try: + with patch('crum.get_current_request', return_value=self.fake_request): do_email_change_request(user, email, activation_key) - except ValueError as err: - return text_type(err) def assertFailedRequest(self, response_data, expected_error): """ @@ -332,8 +333,8 @@ class EmailChangeRequestTests(EventTestMixin, CacheIsolationTestCase): user2 = UserFactory.create(email=self.new_email, password="test2") # Send requests & ensure no error was thrown - self.assertIsNone(self.do_email_change(self.user, user1_new_email)) - self.assertIsNone(self.do_email_change(user2, user2_new_email)) + self.do_email_change(self.user, user1_new_email) + self.do_email_change(user2, user2_new_email) def test_invalid_emails(self): """ @@ -350,43 +351,64 @@ class EmailChangeRequestTests(EventTestMixin, CacheIsolationTestCase): self.assertEqual(self.do_email_validation(self.user.email), 'Old email is the same as the new email.') @patch('django.core.mail.send_mail') - @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_email_failure(self, send_mail): """ Test the return value if sending the email for the user to click fails. """ send_mail.side_effect = [Exception, None] - self.assertEqual( - self.do_email_change(self.user, "valid@email.com"), - 'Unable to send email activation link. Please try again later.' - ) + with self.assertRaisesRegexp(ValueError, 'Unable to send email activation link. Please try again later.'): + self.do_email_change(self.user, "valid@email.com") + self.assert_no_events_were_emitted() - @patch('django.core.mail.send_mail') - @patch('student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_email_success(self, send_mail): + def test_email_success(self): """ Test email was sent if no errors encountered. """ old_email = self.user.email new_email = "valid@example.com" - registration_key = "test registration key" - self.assertIsNone(self.do_email_change(self.user, new_email, registration_key)) - context = { - 'key': registration_key, - 'old_email': old_email, - 'new_email': new_email - } - send_mail.assert_called_with( - mock_render_to_string('emails/email_change_subject.txt', context), - mock_render_to_string('emails/email_change.txt', context), - configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), - [new_email] + registration_key = "test-registration-key" + + self.do_email_change(self.user, new_email, registration_key) + + self._assert_email( + subject=u'Request to change édX account e-mail', + body_fragments=[ + u'We received a request to change the e-mail associated with', + u'your édX account from {old_email} to {new_email}.'.format( + old_email=old_email, + new_email=new_email, + ), + u'If this is correct, please confirm your new e-mail address by visiting:', + u'http://edx.org/email_confirm/{key}'.format(key=registration_key), + u'If you didn\'t request this, you don\'t need to do anything;', + u'you won\'t receive any more email from us.', + u'Please do not reply to this e-mail; if you require assistance,', + u'check the help section of the édX web site.', + ], ) + self.assert_event_emitted( SETTING_CHANGE_INITIATED, user_id=self.user.id, setting=u'email', old=old_email, new=new_email ) + def _assert_email(self, subject, body_fragments): + """ + Verify that the email was sent. + """ + assert len(mail.outbox) == 1 + assert len(body_fragments) > 1, 'Should provide at least two body fragments' + + message = mail.outbox[0] + text = message.body + html = message.alternatives[0][0] + + assert message.subject == subject + + for body in text, html: + for fragment in body_fragments: + assert fragment in body + @patch('django.contrib.auth.models.User.email_user') @patch('student.views.management.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True)) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 07c4dda5b1..e01f0fb2a2 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -14,6 +14,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.models import AnonymousUser, User from django.contrib.auth.views import password_reset_confirm +from django.contrib.sites.models import Site from django.core import mail from django.urls import reverse from django.core.validators import ValidationError, validate_email @@ -29,6 +30,8 @@ from django.utils.http import base36_to_int, urlsafe_base64_encode from django.utils.translation import ugettext as _ from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.decorators.http import require_GET, require_POST, require_http_methods +from edx_ace import ace +from edx_ace.recipient import Recipient from edx_django_utils import monitoring as monitoring_utils from eventtracking import tracker from ipware.ip import get_ip @@ -38,7 +41,6 @@ from opaque_keys.edx.keys import CourseKey from pytz import UTC from six import text_type from xmodule.modulestore.django import modulestore - import track.views from course_modes.models import CourseMode from edx_ace import ace @@ -49,6 +51,7 @@ from entitlements.models import CourseEntitlement from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.catalog.utils import get_programs_with_type from openedx.core.djangoapps.embargo import api as embargo_api +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.oauth_dispatch.api import destroy_oauth_tokens from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -71,7 +74,7 @@ from student.helpers import ( generate_activation_email_context, get_next_url_for_login_page ) -from student.message_types import PasswordReset +from student.message_types import EmailChange, PasswordReset from student.models import ( CourseEnrollment, PasswordHistory, @@ -920,24 +923,32 @@ def do_email_change_request(user, new_email, activation_key=None): pec.activation_key = activation_key pec.save() - context = { - 'key': pec.activation_key, + use_https = theming_helpers.get_current_request().is_secure() + + site = Site.objects.get_current() + message_context = get_base_template_context(site) + message_context.update({ 'old_email': user.email, - 'new_email': pec.new_email - } + 'new_email': pec.new_email, + 'confirm_link': '{protocol}://{site}{link}'.format( + protocol='https' if use_https else 'http', + site=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME), + link=reverse('confirm_email_change', kwargs={ + 'key': pec.activation_key, + }), + ), + }) - subject = render_to_string('emails/email_change_subject.txt', context) - subject = ''.join(subject.splitlines()) - - message = render_to_string('emails/email_change.txt', context) - - from_address = configuration_helpers.get_value( - 'email_from_address', - settings.DEFAULT_FROM_EMAIL + msg = EmailChange().personalize( + recipient=Recipient(user.username, pec.new_email), + language=preferences_api.get_user_preference(user, LANGUAGE_KEY), + user_context=message_context, ) + try: - mail.send_mail(subject, message, from_address, [pec.new_email]) + ace.send(msg) except Exception: + from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) raise ValueError(_('Unable to send email activation link. Please try again later.')) @@ -948,8 +959,8 @@ def do_email_change_request(user, new_email, activation_key=None): SETTING_CHANGE_INITIATED, { "setting": "email", - "old": context['old_email'], - "new": context['new_email'], + "old": message_context['old_email'], + "new": message_context['new_email'], "user_id": user.id, } ) diff --git a/common/templates/student/edx_ace/emailchange/email/body.html b/common/templates/student/edx_ace/emailchange/email/body.html new file mode 100644 index 0000000000..93cc842e14 --- /dev/null +++ b/common/templates/student/edx_ace/emailchange/email/body.html @@ -0,0 +1,28 @@ +{% extends 'ace_common/edx_ace/common/base_body.html' %} + +{% load i18n %} +{% load static %} +{% block content %} +
+ + {% trans "Email Change" %} ++
+ {% blocktrans %}We received a request to change the e-mail associated with your {{ platform_name }} account from {{ old_email }} to {{ new_email }}. If this is correct, please confirm your new e-mail address by visiting:{% endblocktrans %}
+
+ {% blocktrans %}If you didn't request this, you don't need to do anything; you won't receive any more email from us. Please do not reply to this e-mail; if you require assistance, check the help section of the {{ platform_name }} web site.{% endblocktrans %}
+ |
+