Skip to content
Snippets Groups Projects
Commit bd411de1 authored by Saleem Latif's avatar Saleem Latif
Browse files

Add recovery email to account settings page

parent a55ed883
No related branches found
No related tags found
No related merge requests found
Showing
with 248 additions and 16 deletions
......@@ -15,6 +15,7 @@ from openedx.core.djangoapps.waffle_utils import WaffleSwitch
from openedx.core.lib.courses import clean_course_id
from student import STUDENT_WAFFLE_NAMESPACE
from student.models import (
AccountRecovery,
CourseAccessRole,
CourseEnrollment,
CourseEnrollmentAllowed,
......@@ -241,6 +242,14 @@ class UserProfileInline(admin.StackedInline):
verbose_name_plural = _('User profile')
class AccountRecoveryInline(admin.StackedInline):
""" Inline admin interface for AccountRecovery model. """
model = AccountRecovery
can_delete = False
verbose_name = _('Account recovery')
verbose_name_plural = _('Account recovery')
class UserChangeForm(BaseUserChangeForm):
"""
Override the default UserChangeForm such that the password field
......@@ -257,7 +266,7 @@ class UserChangeForm(BaseUserChangeForm):
class UserAdmin(BaseUserAdmin):
""" Admin interface for the User model. """
inlines = (UserProfileInline,)
inlines = (UserProfileInline, AccountRecoveryInline)
form = UserChangeForm
def get_readonly_fields(self, request, obj=None):
......
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2018-12-10 12:15
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('student', '0016_coursenrollment_course_on_delete_do_nothing'),
]
operations = [
migrations.CreateModel(
name='AccountRecovery',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secondary_email', models.EmailField(help_text='Secondary email address to recover linked account.', max_length=254, unique=True, verbose_name='Secondary email address')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='account_recovery', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'auth_accountrecovery',
},
),
]
......@@ -2879,3 +2879,20 @@ class LogoutViewConfiguration(ConfigurationModel):
def __unicode__(self):
"""Unicode representation of the instance. """
return u'Logout view configuration: {enabled}'.format(enabled=self.enabled)
class AccountRecovery(models.Model):
"""
Model for storing information for user's account recovery in case of access loss.
"""
user = models.OneToOneField(User, related_name='account_recovery', on_delete=models.CASCADE)
secondary_email = models.EmailField(
verbose_name=_('Secondary email address'),
help_text=_('Secondary email address to recover linked account.'),
unique=True,
null=False,
blank=False,
)
class Meta(object):
db_table = "auth_accountrecovery"
......@@ -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(account_recovery, 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 new_email == account_recovery.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 == account_recovery.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
......
......@@ -3034,6 +3034,7 @@ ACCOUNT_VISIBILITY_CONFIGURATION = {
"account_privacy",
"accomplishments_shared",
"extended_profile",
"secondary_email",
]
}
......
......@@ -25,7 +25,8 @@
profile_image: null,
accomplishments_shared: false,
default_public_account_fields: [],
extended_profile: []
extended_profile: [],
secondary_email: ''
},
parse: function(response) {
......
......@@ -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
......
......@@ -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
);
</%static:require_module>
......
......@@ -14,7 +14,14 @@ from django.http import HttpResponseForbidden
from openedx.core.djangoapps.theming.helpers import get_current_request
from six import text_type
from student.models import User, UserProfile, Registration, email_exists_or_retired, username_exists_or_retired
from student.models import (
AccountRecovery,
User,
UserProfile,
Registration,
email_exists_or_retired,
username_exists_or_retired
)
from student import forms as student_forms
from student import views as student_views
from util.model_utils import emit_setting_changed_event
......@@ -131,6 +138,7 @@ def update_account_settings(requesting_user, update, username=None):
username = requesting_user.username
existing_user, existing_user_profile = _get_user_and_profile(username)
account_recovery = _get_account_recovery(existing_user)
if requesting_user.username != username:
raise errors.UserNotAuthorized()
......@@ -151,6 +159,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 +200,18 @@ 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(account_recovery, 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)
}
else:
account_recovery.secondary_email = update["secondary_email"]
account_recovery.save()
# If the user asked to change full name, validate it
if changing_full_name:
try:
......@@ -485,6 +509,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.
......@@ -560,6 +597,18 @@ def _get_user_and_profile(username):
return existing_user, existing_user_profile
def _get_account_recovery(user):
"""
helper method to return the account recovery object based on user.
"""
try:
account_recovery = user.account_recovery
except ObjectDoesNotExist:
account_recovery = AccountRecovery(user=user)
return account_recovery
def _validate(validation_func, err, *args):
"""Generic validation function that returns default on
no errors, but the message associated with the err class
......@@ -709,6 +758,25 @@ 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.
Arguments:
email (unicode): The proposed email.
Returns:
None
Raises:
errors.AccountEmailAlreadyExists: Raised if given email address is already associated as another
user's secondary email.
"""
if email is not None and AccountRecovery.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.
......
......@@ -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,
......@@ -81,7 +82,7 @@ class UserReadOnlySerializer(serializers.Serializer):
def to_representation(self, user):
"""
Overwrite to_native to handle custom logic since we are serializing two models as one here
Overwrite to_native to handle custom logic since we are serializing three models as one here
:param user: User object
:return: Dict serialized account
"""
......@@ -91,6 +92,11 @@ class UserReadOnlySerializer(serializers.Serializer):
user_profile = None
LOGGER.warning("user profile for the user [%s] does not exist", user.username)
try:
account_recovery = user.account_recovery
except ObjectDoesNotExist:
account_recovery = None
accomplishments_shared = badges_enabled()
data = {
......@@ -150,6 +156,14 @@ class UserReadOnlySerializer(serializers.Serializer):
}
)
if account_recovery:
if is_secondary_email_feature_enabled_for_user(user):
data.update(
{
"secondary_email": account_recovery.secondary_email,
}
)
if self.custom_fields:
fields = self.custom_fields
elif user_profile:
......
......@@ -357,6 +357,7 @@ class AccountSettingsOnCreationTest(TestCase):
'account_privacy': PRIVATE_VISIBILITY,
'accomplishments_shared': False,
'extended_profile': [],
'secondary_email': None
})
def test_normalize_password(self):
......
......@@ -243,7 +243,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
Verify that all account fields are returned (even those that are not shareable).
"""
data = response.data
self.assertEqual(19, len(data))
self.assertEqual(20, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual("US", data["country"])
......@@ -301,7 +301,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
with self.assertNumQueries(21):
with self.assertNumQueries(22):
response = self.send_get(self.different_client)
self._verify_full_shareable_account_response(response, account_privacy=ALL_USERS_VISIBILITY)
......@@ -316,7 +316,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
"""
self.different_client.login(username=self.different_user.username, password=TEST_PASSWORD)
self.create_mock_profile(self.user)
with self.assertNumQueries(21):
with self.assertNumQueries(22):
response = self.send_get(self.different_client)
self._verify_private_account_response(response, account_privacy=PRIVATE_VISIBILITY)
......@@ -372,7 +372,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
with self.assertNumQueries(queries):
response = self.send_get(self.client)
data = response.data
self.assertEqual(19, len(data))
self.assertEqual(20, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
for empty_field in ("year_of_birth", "level_of_education", "mailing_address", "bio"):
......@@ -391,12 +391,12 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
self.assertEqual(False, data["accomplishments_shared"])
self.client.login(username=self.user.username, password=TEST_PASSWORD)
verify_get_own_information(19)
verify_get_own_information(20)
# Now make sure that the user can get the same information, even if not active
self.user.is_active = False
self.user.save()
verify_get_own_information(13)
verify_get_own_information(14)
def test_get_account_empty_string(self):
"""
......@@ -410,7 +410,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
legacy_profile.save()
self.client.login(username=self.user.username, password=TEST_PASSWORD)
with self.assertNumQueries(19):
with self.assertNumQueries(20):
response = self.send_get(self.client)
for empty_field in ("level_of_education", "gender", "country", "bio"):
self.assertIsNone(response.data[empty_field])
......@@ -782,7 +782,7 @@ class TestAccountsAPI(CacheIsolationTestCase, UserAPITestCase):
response = self.send_get(client)
if has_full_access:
data = response.data
self.assertEqual(19, len(data))
self.assertEqual(20, len(data))
self.assertEqual(self.user.username, data["username"])
self.assertEqual(self.user.first_name + " " + self.user.last_name, data["name"])
self.assertEqual(self.user.email, data["email"])
......
......@@ -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)
......@@ -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()
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment