From 2055f94253b432900e6cf02d27a2b9c5d747cb46 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Tue, 27 Nov 2018 17:40:55 +0500 Subject: [PATCH] Add recovery email to account settings page --- .../0017_userprofile_secondary_email.py | 20 ++++++++++ common/djangoapps/student/models.py | 1 + common/djangoapps/student/views/management.py | 31 ++++++++++++++- lms/envs/common.py | 1 + .../models/user_account_model.js | 3 +- .../views/account_settings_factory.js | 29 ++++++++++++-- .../student_account/account_settings.html | 5 ++- .../core/djangoapps/user_api/accounts/api.py | 38 +++++++++++++++++++ .../user_api/accounts/serializers.py | 9 ++++- .../djangoapps/user_api/accounts/utils.py | 25 ++++++++++++ .../content_type_gating/tests/test_access.py | 2 +- openedx/features/enterprise_support/utils.py | 14 +++++++ 12 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 common/djangoapps/student/migrations/0017_userprofile_secondary_email.py diff --git a/common/djangoapps/student/migrations/0017_userprofile_secondary_email.py b/common/djangoapps/student/migrations/0017_userprofile_secondary_email.py new file mode 100644 index 0000000000..1ef37903e4 --- /dev/null +++ b/common/djangoapps/student/migrations/0017_userprofile_secondary_email.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-12-04 10:03 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('student', '0016_coursenrollment_course_on_delete_do_nothing'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='secondary_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='Secondary email address'), + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 95c90111d3..d639de7bdb 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -410,6 +410,7 @@ class UserProfile(models.Model): # This is not visible to other users, but could introduce holes later user = models.OneToOneField(User, unique=True, db_index=True, related_name='profile', on_delete=models.CASCADE) name = models.CharField(blank=True, max_length=255, db_index=True) + secondary_email = models.EmailField(verbose_name=_('Secondary email address'), blank=True) meta = models.TextField(blank=True) # JSON dictionary for future expansion courseware = models.CharField(blank=True, max_length=255, default='course.xml') diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 9044018684..54255999f0 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -125,8 +125,8 @@ def csrf_token(context): token = context.get('csrf_token', '') if token == 'NOTPROVIDED': return '' - return (u'
'.format(token)) + return (HTML(u'
').format(token)) # NOTE: This view is not linked to directly--it is called from @@ -912,6 +912,33 @@ def validate_new_email(user, new_email): raise ValueError(_('Old email is the same as the new email.')) +def validate_secondary_email(user_profile, new_email): + """ + Enforce valid email addresses. + """ + from openedx.core.djangoapps.user_api.accounts.api import get_email_validation_error, \ + get_email_existence_validation_error, get_secondary_email_validation_error + + if get_email_validation_error(new_email): + raise ValueError(_('Valid e-mail address required.')) + + if user_profile.secondary_email and new_email == user_profile.secondary_email: + raise ValueError(_('Old email is the same as the new email.')) + + # Make sure that secondary email address is not same as user's primary email. + if new_email == user_profile.user.email: + raise ValueError(_('Cannot be same as your sign in email address.')) + + # Make sure that secondary email address is not same as any of the primary emails. + message = get_email_existence_validation_error(new_email) + if message: + raise ValueError(message) + + message = get_secondary_email_validation_error(new_email) + if message: + raise ValueError(message) + + def do_email_change_request(user, new_email, activation_key=None): """ Given a new email for a user, does some basic verification of the new address and sends an activation message diff --git a/lms/envs/common.py b/lms/envs/common.py index dab7b9a675..a68d48b993 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3034,6 +3034,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { "account_privacy", "accomplishments_shared", "extended_profile", + "secondary_email", ] } diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js index 44008ed622..0bf577db3a 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -25,7 +25,8 @@ profile_image: null, accomplishments_shared: false, default_public_account_fields: [], - extended_profile: [] + extended_profile: [], + secondary_email: '' }, parse: function(response) { diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js index 7d9951758d..ebef64b1ec 100644 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -27,14 +27,16 @@ enterpriseReadonlyAccountFields, edxSupportUrl, extendedProfileFields, - displayAccountDeletion + displayAccountDeletion, + isSecondaryEmailFeatureEnabled ) { var $accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData, accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage, showLoadingError, orderNumber, getUserField, userFields, timeZoneDropdownField, countryDropdownField, - emailFieldView, socialFields, accountDeletionFields, platformData, + emailFieldView, secondaryEmailFieldView, socialFields, accountDeletionFields, platformData, aboutSectionMessageType, aboutSectionMessage, fullnameFieldView, countryFieldView, - fullNameFieldData, emailFieldData, countryFieldData, additionalFields, fieldItem; + fullNameFieldData, emailFieldData, secondaryEmailFieldData, countryFieldData, additionalFields, + fieldItem, emailFieldViewIndex; $accountSettingsElement = $('.wrapper-account-settings'); @@ -82,6 +84,14 @@ }; } + secondaryEmailFieldData = { + model: userAccountModel, + title: gettext('Secondary Email Address'), + valueAttribute: 'secondary_email', + helpMessage: gettext('You may access your account when single-sign on is not available.'), + persistChanges: true + }; + fullNameFieldData = { model: userAccountModel, title: gettext('Full Name'), @@ -233,6 +243,19 @@ } ]; + // Secondary email address + if (isSecondaryEmailFeatureEnabled) { + secondaryEmailFieldView = { + view: new AccountSettingsFieldViews.EmailFieldView(secondaryEmailFieldData) + }; + emailFieldViewIndex = aboutSectionsData[0].fields.indexOf(emailFieldView); + + // Insert secondary email address after email address field. + aboutSectionsData[0].fields.splice( + emailFieldViewIndex + 1, 0, secondaryEmailFieldView + ) + } + // Add the extended profile fields additionalFields = aboutSectionsData[1]; for (var field in extendedProfileFields) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len diff --git a/lms/templates/student_account/account_settings.html b/lms/templates/student_account/account_settings.html index ffc83b1f94..507f9f8da1 100644 --- a/lms/templates/student_account/account_settings.html +++ b/lms/templates/student_account/account_settings.html @@ -10,6 +10,7 @@ from django.utils.translation import ugettext as _ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML from webpack_loader.templatetags.webpack_loader import render_bundle +from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled_for_user %> <%inherit file="/main.html" /> @@ -46,6 +47,7 @@ from webpack_loader.templatetags.webpack_loader import render_bundle edxSupportUrl = '${ edx_support_url | n, js_escaped_string }', extendedProfileFields = ${ extended_profile_fields | n, dump_js_escaped_json }, displayAccountDeletion = ${ enable_account_deletion | n, dump_js_escaped_json}; + isSecondaryEmailFeatureEnabled = ${ bool(is_secondary_email_feature_enabled_for_user(user)) | n, dump_js_escaped_json }, AccountSettingsFactory( fieldsData, @@ -65,7 +67,8 @@ from webpack_loader.templatetags.webpack_loader import render_bundle enterpriseReadonlyAccountFields, edxSupportUrl, extendedProfileFields, - displayAccountDeletion + displayAccountDeletion, + isSecondaryEmailFeatureEnabled ); diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 42c02ba5cd..a8dca02578 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -151,6 +151,10 @@ def update_account_settings(requesting_user, update, username=None): changing_full_name = True old_name = existing_user_profile.name + changing_secondary_email = False + if "secondary_email" in update: + changing_secondary_email = True + # Check for fields that are not editable. Marking them read-only causes them to be ignored, but we wish to 400. read_only_fields = set(update.keys()).intersection( AccountUserSerializer.get_read_only_fields() + AccountLegacyProfileSerializer.get_read_only_fields() @@ -188,6 +192,15 @@ def update_account_settings(requesting_user, update, username=None): # This is so that this endpoint cannot be used to determine if an email is valid or not. changing_email = new_email and not email_exists_or_retired(new_email) + if changing_secondary_email: + try: + student_views.validate_secondary_email(existing_user_profile, update["secondary_email"]) + except ValueError as err: + field_errors["secondary_email"] = { + "developer_message": u"Error thrown from validate_secondary_email: '{}'".format(text_type(err)), + "user_message": text_type(err) + } + # If the user asked to change full name, validate it if changing_full_name: try: @@ -485,6 +498,19 @@ def get_email_validation_error(email): return _validate(_validate_email, errors.AccountEmailInvalid, email) +def get_secondary_email_validation_error(email): + """ + Get the built-in validation error message for when the email is invalid in some way. + + Arguments: + email (str): The proposed email (unicode). + Returns: + (str): Validation error message. + + """ + return _validate(_validate_secondary_email_doesnt_exist, errors.AccountEmailAlreadyExists, email) + + def get_confirm_email_validation_error(confirm_email, email): """Get the built-in validation error message for when the confirmation email is invalid in some way. @@ -709,6 +735,18 @@ def _validate_email_doesnt_exist(email): raise errors.AccountEmailAlreadyExists(_(accounts.EMAIL_CONFLICT_MSG).format(email_address=email)) +def _validate_secondary_email_doesnt_exist(email): + """Validate that the email is not associated as a secondary email of an existing user. + + :param email: The proposed email (unicode). + :return: None + :raises: errors.AccountEmailAlreadyExists + """ + if email is not None and UserProfile.objects.filter(secondary_email=email).exists(): + # pylint: disable=no-member + raise errors.AccountEmailAlreadyExists(accounts.EMAIL_CONFLICT_MSG.format(email_address=email)) + + def _validate_password_works_with_username(password, username=None): """Run validation checks on whether the password and username go well together. diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index fa884b00cb..ff7e1b7936 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -14,6 +14,7 @@ from six import text_type from lms.djangoapps.badges.utils import badges_enabled from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import errors +from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled_for_user from openedx.core.djangoapps.user_api.models import ( RetirementState, UserPreference, @@ -147,6 +148,7 @@ class UserReadOnlySerializer(serializers.Serializer): user_profile.social_links.all(), many=True ).data, "extended_profile": get_extended_profile(user_profile), + "secondary_email": user_profile.secondary_email, } ) @@ -157,6 +159,10 @@ class UserReadOnlySerializer(serializers.Serializer): else: fields = self.configuration.get('public_fields') + # Do not display secondary email input field, if secondary email feature is not enabled for the current user. + if 'secondary_email' in fields and not is_secondary_email_feature_enabled_for_user(user): + fields.remove('secondary_email') + return self._filter_fields( fields, data @@ -198,7 +204,8 @@ class AccountLegacyProfileSerializer(serializers.HyperlinkedModelSerializer, Rea model = UserProfile fields = ( "name", "gender", "goals", "year_of_birth", "level_of_education", "country", "social_links", - "mailing_address", "bio", "profile_image", "requires_parental_consent", "language_proficiencies" + "mailing_address", "bio", "profile_image", "requires_parental_consent", "language_proficiencies", + "secondary_email" ) # Currently no read-only field, but keep this so view code doesn't need to know. read_only_fields = () diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 1d25a23caa..a2e18f0aef 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -8,6 +8,7 @@ import re import string from urlparse import urlparse +import waffle from django.conf import settings from django.utils.translation import ugettext as _ from six import text_type @@ -19,6 +20,8 @@ from openedx.core.djangoapps.theming.helpers import get_config_value_from_site_o from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH = 'enable_secondary_email_feature' + def validate_social_link(platform_name, new_social_link): """ @@ -189,3 +192,25 @@ def generate_password(length=12, chars=string.letters + string.digits): password += choice(string.letters) password += ''.join([choice(chars) for _i in xrange(length - 2)]) return password + + +def is_secondary_email_feature_enabled(): + """ + Checks to see if the django-waffle switch for enabling the secondary email feature is active + + Returns: + Boolean value representing switch status + """ + return waffle.switch_is_active(ENABLE_SECONDARY_EMAIL_FEATURE_SWITCH) + + +def is_secondary_email_feature_enabled_for_user(user): + """ + Checks to see if secondary email feature is enabled for the given user. + + Returns: + Boolean value representing the status of secondary email feature. + """ + # import is placed here to avoid cyclic import. + from openedx.features.enterprise_support.utils import is_enterprise_learner + return is_secondary_email_feature_enabled() and is_enterprise_learner(user) diff --git a/openedx/features/content_type_gating/tests/test_access.py b/openedx/features/content_type_gating/tests/test_access.py index 65681c9c30..e923e36e41 100644 --- a/openedx/features/content_type_gating/tests/test_access.py +++ b/openedx/features/content_type_gating/tests/test_access.py @@ -666,7 +666,7 @@ class TestMessageDeduplication(ModuleStoreTestCase): self.user = UserFactory.create() self.request_factory = RequestFactory() - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) def _create_course(self): course = CourseFactory.create(run='test', display_name='test') diff --git a/openedx/features/enterprise_support/utils.py b/openedx/features/enterprise_support/utils.py index 0846de4220..5202ea53e5 100644 --- a/openedx/features/enterprise_support/utils.py +++ b/openedx/features/enterprise_support/utils.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext as _ import third_party_auth from third_party_auth import pipeline +from enterprise.models import EnterpriseCustomerUser from openedx.core.djangoapps.user_authn.cookies import standard_cookie_settings from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -257,3 +258,16 @@ def get_enterprise_learner_generic_name(request): if enterprise_customer and enterprise_customer['replace_sensitive_sso_username'] else '' ) + + +def is_enterprise_learner(user): + """ + Check if the given user belongs to an enterprise. + + Arguments: + user (User): Django User object. + + Returns: + (bool): True if given user is an enterprise learner. + """ + return EnterpriseCustomerUser.objects.filter(user_id=user.id).exists()