Commit cb034d4f by Uman Shahzad

Implement client-side registration form validation.

Input forms that need validation will have AJAX requests
performed to get validation decisions live.

All but a few important and common form fields perform
generic validation; these will need a back-end handler
in the future in order to have them validated through AJAX requests.

Information is conveyed on focus and blur for both
errors and successes.
parent 39ac333b
...@@ -22,7 +22,9 @@ from notification_prefs import NOTIFICATION_PREF_KEY ...@@ -22,7 +22,9 @@ from notification_prefs import NOTIFICATION_PREF_KEY
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.user_api.accounts import USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE from openedx.core.djangoapps.user_api.accounts import (
USERNAME_BAD_LENGTH_MSG, USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE
)
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from student.models import UserAttribute from student.models import UserAttribute
from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS
...@@ -476,16 +478,16 @@ class TestCreateAccountValidation(TestCase): ...@@ -476,16 +478,16 @@ class TestCreateAccountValidation(TestCase):
# Missing # Missing
del params["username"] del params["username"]
assert_username_error("Username must be minimum of two characters long") assert_username_error(USERNAME_BAD_LENGTH_MSG)
# Empty, too short # Empty, too short
for username in ["", "a"]: for username in ["", "a"]:
params["username"] = username params["username"] = username
assert_username_error("Username must be minimum of two characters long") assert_username_error(USERNAME_BAD_LENGTH_MSG)
# Too long # Too long
params["username"] = "this_username_has_31_characters" params["username"] = "this_username_has_31_characters"
assert_username_error("Username cannot be more than 30 characters long") assert_username_error(USERNAME_BAD_LENGTH_MSG)
# Invalid # Invalid
params["username"] = "invalid username" params["username"] = "invalid username"
......
...@@ -5,6 +5,8 @@ import json ...@@ -5,6 +5,8 @@ import json
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from openedx.core.djangoapps.user_api.accounts import USERNAME_BAD_LENGTH_MSG
class TestLongUsernameEmail(TestCase): class TestLongUsernameEmail(TestCase):
...@@ -34,7 +36,7 @@ class TestLongUsernameEmail(TestCase): ...@@ -34,7 +36,7 @@ class TestLongUsernameEmail(TestCase):
obj = json.loads(response.content) obj = json.loads(response.content)
self.assertEqual( self.assertEqual(
obj['value'], obj['value'],
"Username cannot be more than 30 characters long", USERNAME_BAD_LENGTH_MSG,
) )
def test_long_email(self): def test_long_email(self):
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
var _fn = { var _fn = {
validate: { validate: {
template: _.template('<li><%= content %></li>'), template: _.template('<li id="<%- id %>-validation-error-container"><%- content %></li>'),
msg: { msg: {
email: gettext("The email address you've provided isn't formatted correctly."), email: gettext("The email address you've provided isn't formatted correctly."),
...@@ -32,6 +32,7 @@ ...@@ -32,6 +32,7 @@
field: function(el) { field: function(el) {
var $el = $(el), var $el = $(el),
id = $el.attr('id'),
required = true, required = true,
min = true, min = true,
max = true, max = true,
...@@ -66,6 +67,8 @@ ...@@ -66,6 +67,8 @@
}); });
} }
response.id = id;
return response; return response;
}, },
...@@ -107,7 +110,7 @@ ...@@ -107,7 +110,7 @@
regex: new RegExp( regex: new RegExp(
[ [
'(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*', '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*',
'|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"', '|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"', // eslint-disable-line max-len
')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+[A-Z]{2,6}\\.?$)', ')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+[A-Z]{2,6}\\.?$)',
'|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$' '|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$'
].join(''), 'i' ].join(''), 'i'
...@@ -124,7 +127,7 @@ ...@@ -124,7 +127,7 @@
getLabel: function(id) { getLabel: function(id) {
// Extract the field label, remove the asterisk (if it appears) and any extra whitespace // Extract the field label, remove the asterisk (if it appears) and any extra whitespace
return $('label[for=' + id + ']').text().split('*')[0].trim(); return $('label[for=' + id + '] > span.label-text').text().split('*')[0].trim();
}, },
getMessage: function($el, tests) { getMessage: function($el, tests) {
...@@ -154,7 +157,10 @@ ...@@ -154,7 +157,10 @@
content = _.sprintf(_fn.validate.msg[key], context); content = _.sprintf(_fn.validate.msg[key], context);
} }
txt.push(_fn.validate.template({content: content})); txt.push(_fn.validate.template({
content: content,
id: $el.attr('id')
}));
} }
}); });
...@@ -173,7 +179,7 @@ ...@@ -173,7 +179,7 @@
return { return {
validate: _fn.validate.field validate: _fn.validate.field
}; };
})(); }());
return utils; return utils;
}); });
......
...@@ -344,10 +344,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest): ...@@ -344,10 +344,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
# Verify that the expected errors are displayed. # Verify that the expected errors are displayed.
errors = self.register_page.wait_for_errors() errors = self.register_page.wait_for_errors()
self.assertIn(u'Please enter your Public Username.', errors) self.assertIn(u'Please enter your Public Username.', errors)
self.assertIn( self.assertIn(u'You must agree to the édX Terms of Service and Honor Code', errors)
u'You must agree to the édX Terms of Service and Honor Code',
errors
)
self.assertIn(u'Please select your Country.', errors) self.assertIn(u'Please select your Country.', errors)
self.assertIn(u'Please tell us your favorite movie.', errors) self.assertIn(u'Please tell us your favorite movie.', errors)
......
...@@ -331,31 +331,6 @@ msgid "User profile" ...@@ -331,31 +331,6 @@ msgid "User profile"
msgstr "" msgstr ""
#: common/djangoapps/student/forms.py #: common/djangoapps/student/forms.py
msgid "Username must be minimum of two characters long"
msgstr ""
#: common/djangoapps/student/forms.py
#, python-format
msgid "Username cannot be more than %(limit_value)s characters long"
msgstr ""
#. Translators: This message is shown when the Unicode usernames are NOT
#. allowed
#: common/djangoapps/student/forms.py
msgid ""
"Usernames can only contain Roman letters, western numerals (0-9), "
"underscores (_), and hyphens (-)."
msgstr ""
#. Translators: This message is shown only when the Unicode usernames are
#. allowed
#: common/djangoapps/student/forms.py
msgid ""
"Usernames can only contain letters, numerals, underscore (_), numbers and "
"@/./+/-/_ characters."
msgstr ""
#: common/djangoapps/student/forms.py
msgid "" msgid ""
"That e-mail address doesn't have an associated user account. Are you sure " "That e-mail address doesn't have an associated user account. Are you sure "
"you've registered?" "you've registered?"
...@@ -9288,23 +9263,83 @@ msgstr "" ...@@ -9288,23 +9263,83 @@ msgstr ""
msgid "Enable course home page improvements." msgid "Enable course home page improvements."
msgstr "" msgstr ""
#: openedx/core/djangoapps/theming/views.py #: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid ""
"Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores "
"(_), and hyphens (-)."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid ""
"Usernames can only contain letters, numerals, and @/./+/-/_ characters."
msgstr ""
#. Translators: This message is shown to users who attempt to create a new
#. account using
#. an invalid email format.
#: openedx/core/djangoapps/user_api/accounts/__init__.py
#, python-brace-format #, python-brace-format
msgid "Site theme changed to {site_theme}" msgid "\"{email}\" is not a valid email address."
msgstr "" msgstr ""
#: openedx/core/djangoapps/theming/views.py #: openedx/core/djangoapps/user_api/accounts/__init__.py
#, python-brace-format #, python-brace-format
msgid "Theme {site_theme} does not exist" msgid ""
"It looks like {email_address} belongs to an existing account. Try again with"
" a different email address."
msgstr "" msgstr ""
#: openedx/core/djangoapps/theming/views.py #: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Site theme reverted to the default" #, python-brace-format
msgid ""
"It looks like {username} belongs to an existing account. Try again with a "
"different username."
msgstr "" msgstr ""
#: openedx/core/djangoapps/theming/views.py #. Translators: This message is shown to users who enter a
#: openedx/core/djangoapps/theming/templates/theming/theming-admin-fragment.html #. username/email/password
msgid "Theming Administration" #. with an inappropriate length (too short or too long).
#: openedx/core/djangoapps/user_api/accounts/__init__.py
#, python-brace-format
msgid "Username must be between {min} and {max} characters long."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
#, python-brace-format
msgid "Enter a valid email address that contains at least {min} characters."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Please enter a password."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Password is not long enough."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
#, python-brace-format
msgid "Password cannot be longer than {max} character."
msgstr ""
#. Translators: This message is shown to users who enter a password matching
#. the username they enter(ed).
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Password cannot be the same as the username."
msgstr ""
#. Translators: These messages are shown to users who do not enter information
#. into the required field or enter it incorrectly.
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Please enter your Full Name."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "The email addresses do not match."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Please select your Country."
msgstr "" msgstr ""
#: openedx/core/djangoapps/user_api/accounts/api.py #: openedx/core/djangoapps/user_api/accounts/api.py
...@@ -9392,24 +9427,6 @@ msgstr "" ...@@ -9392,24 +9427,6 @@ msgstr ""
msgid "Remember me" msgid "Remember me"
msgstr "" msgstr ""
#. Translators: This message is shown to users who attempt to create a new
#. account using an email address associated with an existing account.
#: openedx/core/djangoapps/user_api/views.py
#, python-brace-format
msgid ""
"It looks like {email_address} belongs to an existing account. Try again with"
" a different email address."
msgstr ""
#. Translators: This message is shown to users who attempt to create a new
#. account using a username associated with an existing account.
#: openedx/core/djangoapps/user_api/views.py
#, python-brace-format
msgid ""
"It looks like {username} belongs to an existing account. Try again with a "
"different username."
msgstr ""
#. Translators: These instructions appear on the registration form, #. Translators: These instructions appear on the registration form,
#. immediately #. immediately
#. below a field meant to hold the user's email address. #. below a field meant to hold the user's email address.
...@@ -9423,10 +9440,6 @@ msgstr "" ...@@ -9423,10 +9440,6 @@ msgstr ""
msgid "Confirm Email" msgid "Confirm Email"
msgstr "" msgstr ""
#: openedx/core/djangoapps/user_api/views.py
msgid "The email addresses do not match."
msgstr ""
#. Translators: This example name is used as a placeholder in #. Translators: This example name is used as a placeholder in
#. a field on the registration form meant to hold the user's name. #. a field on the registration form meant to hold the user's name.
#: openedx/core/djangoapps/user_api/views.py #: openedx/core/djangoapps/user_api/views.py
...@@ -9506,10 +9519,6 @@ msgid "Company" ...@@ -9506,10 +9519,6 @@ msgid "Company"
msgstr "" msgstr ""
#: openedx/core/djangoapps/user_api/views.py #: openedx/core/djangoapps/user_api/views.py
msgid "Please select your Country."
msgstr ""
#: openedx/core/djangoapps/user_api/views.py
msgid "Review the Honor Code" msgid "Review the Honor Code"
msgstr "" msgstr ""
......
...@@ -4219,6 +4219,10 @@ msgid "We couldn't create your account." ...@@ -4219,6 +4219,10 @@ msgid "We couldn't create your account."
msgstr "" msgstr ""
#: lms/static/js/student_account/views/RegisterView.js #: lms/static/js/student_account/views/RegisterView.js
msgid "(required)"
msgstr ""
#: lms/static/js/student_account/views/RegisterView.js
msgid "You've successfully signed into %(currentProvider)s." msgid "You've successfully signed into %(currentProvider)s."
msgstr "" msgstr ""
......
...@@ -30,6 +30,17 @@ ...@@ -30,6 +30,17 @@
confirm_email: 'xsy@edx.org', confirm_email: 'xsy@edx.org',
honor_code: true honor_code: true
}, },
$email = null,
$name = null,
$username = null,
$password = null,
$levelOfEducation = null,
$gender = null,
$yearOfBirth = null,
$mailingAddress = null,
$goals = null,
$confirmEmail = null,
$honorCode = null,
THIRD_PARTY_AUTH = { THIRD_PARTY_AUTH = {
currentProvider: null, currentProvider: null,
providers: [ providers: [
...@@ -49,9 +60,26 @@ ...@@ -49,9 +60,26 @@
} }
] ]
}, },
VALIDATION_DECISIONS_POSITIVE = {
validation_decisions: {
email: '',
username: '',
password: '',
confirm_email: ''
}
},
VALIDATION_DECISIONS_NEGATIVE = {
validation_decisions: {
email: 'Error.',
username: 'Error.',
password: 'Error.',
confirm_email: 'Error'
}
},
FORM_DESCRIPTION = { FORM_DESCRIPTION = {
method: 'post', method: 'post',
submit_url: '/user_api/v1/account/registration/', submit_url: '/user_api/v1/account/registration/',
validation_url: '/api/user/v1/validation/registration',
fields: [ fields: [
{ {
placeholder: 'username@domain.com', placeholder: 'username@domain.com',
...@@ -185,7 +213,6 @@ ...@@ -185,7 +213,6 @@
} }
] ]
}; };
var createRegisterView = function(that) { var createRegisterView = function(that) {
// Initialize the register model // Initialize the register model
model = new RegisterModel({}, { model = new RegisterModel({}, {
...@@ -209,6 +236,43 @@ ...@@ -209,6 +236,43 @@
view.on('auth-complete', function() { view.on('auth-complete', function() {
authComplete = true; authComplete = true;
}); });
// Target each form field.
$email = $('#register-email');
$confirmEmail = $('#register-confirm_email');
$name = $('#register-name');
$username = $('#register-username');
$password = $('#register-password');
$levelOfEducation = $('#register-level_of_education');
$gender = $('#register-gender');
$yearOfBirth = $('#register-year_of_birth');
$mailingAddress = $('#register-mailing_address');
$goals = $('#register-goals');
$honorCode = $('#register-honor_code');
};
var fillData = function() {
$email.val(USER_DATA.email);
$confirmEmail.val(USER_DATA.email);
$name.val(USER_DATA.name);
$username.val(USER_DATA.username);
$password.val(USER_DATA.password);
$levelOfEducation.val(USER_DATA.level_of_education);
$gender.val(USER_DATA.gender);
$yearOfBirth.val(USER_DATA.year_of_birth);
$mailingAddress.val(USER_DATA.mailing_address);
$goals.val(USER_DATA.goals);
// Check the honor code checkbox
$honorCode.prop('checked', USER_DATA.honor_code);
};
var liveValidate = function($el, validationSuccess) {
$el.focus();
if (!_.isUndefined(validationSuccess) && !validationSuccess) {
model.trigger('validation', $el, VALIDATION_DECISIONS_NEGATIVE);
} else {
model.trigger('validation', $el, VALIDATION_DECISIONS_POSITIVE);
}
}; };
var submitForm = function(validationSuccess) { var submitForm = function(validationSuccess) {
...@@ -216,19 +280,7 @@ ...@@ -216,19 +280,7 @@
var clickEvent = $.Event('click'); var clickEvent = $.Event('click');
// Simulate manual entry of registration form data // Simulate manual entry of registration form data
$('#register-email').val(USER_DATA.email); fillData();
$('#register-confirm_email').val(USER_DATA.email);
$('#register-name').val(USER_DATA.name);
$('#register-username').val(USER_DATA.username);
$('#register-password').val(USER_DATA.password);
$('#register-level_of_education').val(USER_DATA.level_of_education);
$('#register-gender').val(USER_DATA.gender);
$('#register-year_of_birth').val(USER_DATA.year_of_birth);
$('#register-mailing_address').val(USER_DATA.mailing_address);
$('#register-goals').val(USER_DATA.goals);
// Check the honor code checkbox
$('#register-honor_code').prop('checked', USER_DATA.honor_code);
// If validationSuccess isn't passed, we avoid // If validationSuccess isn't passed, we avoid
// spying on `view.validate` twice // spying on `view.validate` twice
...@@ -238,6 +290,10 @@ ...@@ -238,6 +290,10 @@
isValid: validationSuccess, isValid: validationSuccess,
message: 'Submission was validated.' message: 'Submission was validated.'
}); });
// Successful validation means there's no need to use AJAX calls from liveValidate,
if (validationSuccess) {
spyOn(view, 'liveValidate').and.callFake(function() {});
}
} }
// Submit the email address // Submit the email address
...@@ -284,6 +340,7 @@ ...@@ -284,6 +340,7 @@
if (param === '?course_id') { if (param === '?course_id') {
return encodeURIComponent(COURSE_ID); return encodeURIComponent(COURSE_ID);
} }
return null;
}); });
// Attempt to register // Attempt to register
...@@ -308,17 +365,17 @@ ...@@ -308,17 +365,17 @@
expect($('.button-oa2-facebook')).toBeVisible(); expect($('.button-oa2-facebook')).toBeVisible();
}); });
it('validates registration form fields', function() { it('validates registration form fields on form submission', function() {
createRegisterView(this); createRegisterView(this);
// Submit the form, with successful validation // Submit the form, with successful validation
submitForm(true); submitForm(true);
// Verify that validation of form fields occurred // Verify that validation of form fields occurred
expect(view.validate).toHaveBeenCalledWith($('#register-email')[0]); expect(view.validate).toHaveBeenCalledWith($email[0]);
expect(view.validate).toHaveBeenCalledWith($('#register-name')[0]); expect(view.validate).toHaveBeenCalledWith($name[0]);
expect(view.validate).toHaveBeenCalledWith($('#register-username')[0]); expect(view.validate).toHaveBeenCalledWith($username[0]);
expect(view.validate).toHaveBeenCalledWith($('#register-password')[0]); expect(view.validate).toHaveBeenCalledWith($password[0]);
// Verify that no submission errors are visible // Verify that no submission errors are visible
expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(0); expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(0);
...@@ -327,7 +384,34 @@ ...@@ -327,7 +384,34 @@
expect(view.$submitButton).toHaveAttr('disabled'); expect(view.$submitButton).toHaveAttr('disabled');
}); });
it('displays registration form validation errors', function() { it('live validates registration form fields', function() {
var requiredValidationFields = [$email, $confirmEmail, $username, $password],
i,
$el;
createRegisterView(this);
for (i = 0; i < requiredValidationFields.length; ++i) {
$el = requiredValidationFields[i];
// Perform successful live validations.
liveValidate($el);
// Confirm success.
expect($el).toHaveClass('success');
// Confirm that since we've blurred from each input, required text doesn't show.
expect(view.getRequiredTextLabel($el)).toHaveClass('hidden');
// Confirm fa-check shows.
expect(view.getIcon($el)).toHaveClass('fa-check');
expect(view.getIcon($el)).toBeVisible();
// Confirm the error tip is empty.
expect(view.getErrorTip($el).val().length).toBe(0);
}
});
it('displays registration form validation errors on form submission', function() {
createRegisterView(this); createRegisterView(this);
// Submit the form, with failed validation // Submit the form, with failed validation
...@@ -343,7 +427,34 @@ ...@@ -343,7 +427,34 @@
expect(view.$submitButton).not.toHaveAttr('disabled'); expect(view.$submitButton).not.toHaveAttr('disabled');
}); });
it('displays an error if the server returns an error while registering', function() { it('displays live registration form validation errors', function() {
var requiredValidationFields = [$email, $confirmEmail, $username, $password],
i,
$el;
createRegisterView(this);
for (i = 0; i < requiredValidationFields.length; ++i) {
$el = requiredValidationFields[i];
// Perform invalid live validations.
liveValidate($el, false);
// Confirm error.
expect($el).toHaveClass('error');
// Confirm that since we've blurred from each input, required text still shows for errors.
expect(view.getRequiredTextLabel($el)).not.toHaveClass('hidden');
// Confirm fa-times shows.
expect(view.getIcon($el)).toHaveClass('fa-exclamation');
expect(view.getIcon($el)).toBeVisible();
// Confirm the error tip shows an error message.
expect(view.getErrorTip($el).val()).not.toBeEmpty();
}
});
it('displays an error on form submission if the server returns an error', function() {
createRegisterView(this); createRegisterView(this);
// Submit the form, with successful validation // Submit the form, with successful validation
......
...@@ -79,6 +79,7 @@ ...@@ -79,6 +79,7 @@
var buildIframe = function(link, modalSelector, contentSelector, tosLinkSelector) { var buildIframe = function(link, modalSelector, contentSelector, tosLinkSelector) {
// Create an iframe with contents from the link and set its height to match the content area // Create an iframe with contents from the link and set its height to match the content area
return $('<iframe>', { return $('<iframe>', {
title: 'Terms of Service and Honor Code',
src: link.href, src: link.href,
load: function() { load: function() {
var $iframeHead = $(this).contents().find('head'), var $iframeHead = $(this).contents().find('head'),
......
...@@ -6,43 +6,30 @@ ...@@ -6,43 +6,30 @@
'backbone', 'backbone',
'common/js/utils/edx.utils.validate', 'common/js/utils/edx.utils.validate',
'edx-ui-toolkit/js/utils/html-utils', 'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils',
'text!templates/student_account/form_errors.underscore' 'text!templates/student_account/form_errors.underscore'
], ], function($, _, Backbone, EdxUtilsValidate, HtmlUtils, StringUtils, formErrorsTpl) {
function($, _, Backbone, EdxUtilsValidate, HtmlUtils, formErrorsTpl) {
return Backbone.View.extend({ return Backbone.View.extend({
tagName: 'form', tagName: 'form',
el: '', el: '',
tpl: '', tpl: '',
fieldTpl: '#form_field-tpl', fieldTpl: '#form_field-tpl',
formErrorsTpl: formErrorsTpl, formErrorsTpl: formErrorsTpl,
formErrorsJsHook: 'js-form-errors', formErrorsJsHook: 'js-form-errors',
defaultFormErrorsTitle: gettext('An error occurred.'), defaultFormErrorsTitle: gettext('An error occurred.'),
events: {}, events: {},
errors: [], errors: [],
formType: '', formType: '',
$form: {}, $form: {},
fields: [], fields: [],
liveValidationFields: [],
// String to append to required label fields // String to append to required label fields
requiredStr: '', requiredStr: '',
/* /*
Translators: This string is appended to optional field labels on the student login, registration, and Translators: This string is appended to optional field labels on the student login, registration, and
profile forms. profile forms.
*/ */
optionalStr: gettext('(optional)'), optionalStr: gettext('(optional)'),
submitButton: '', submitButton: '',
initialize: function(data) { initialize: function(data) {
...@@ -157,7 +144,7 @@ ...@@ -157,7 +144,7 @@
$label, $label,
key = '', key = '',
errors = [], errors = [],
test = {}; validation = {};
for (i = 0; i < len; i++) { for (i = 0; i < len; i++) {
$el = $(elements[i]); $el = $(elements[i]);
...@@ -171,13 +158,13 @@ ...@@ -171,13 +158,13 @@
} }
if (key) { if (key) {
test = this.validate(elements[i]); validation = this.validate(elements[i]);
if (test.isValid) { if (validation.isValid) {
obj[key] = $el.attr('type') === 'checkbox' ? $el.is(':checked') : $el.val(); obj[key] = $el.attr('type') === 'checkbox' ? $el.is(':checked') : $el.val();
$el.removeClass('error'); $el.removeClass('error');
$label.removeClass('error'); $label.removeClass('error');
} else { } else {
errors.push(test.message); errors.push(validation.message);
$el.addClass('error'); $el.addClass('error');
$label.addClass('error'); $label.addClass('error');
} }
...@@ -190,7 +177,13 @@ ...@@ -190,7 +177,13 @@
}, },
saveError: function(error) { saveError: function(error) {
this.errors = ['<li>' + error.responseText + '</li>']; this.errors = [
StringUtils.interpolate(
'<li>{error}</li>', {
error: error.responseText
}
)
];
this.renderErrors(this.defaultFormErrorsTitle, this.errors); this.renderErrors(this.defaultFormErrorsTitle, this.errors);
this.toggleDisableButton(false); this.toggleDisableButton(false);
}, },
...@@ -201,24 +194,48 @@ ...@@ -201,24 +194,48 @@
renderErrors: function(title, errorMessages) { renderErrors: function(title, errorMessages) {
this.clearFormErrors(); this.clearFormErrors();
if (title || errorMessages.length) {
this.renderFormFeedback(this.formErrorsTpl, { this.renderFormFeedback(this.formErrorsTpl, {
jsHook: this.formErrorsJsHook, jsHook: this.formErrorsJsHook,
title: title, title: title,
messagesHtml: HtmlUtils.HTML(errorMessages.join('')) messagesHtml: HtmlUtils.HTML(errorMessages.join(''))
}); });
}
}, },
renderFormFeedback: function(template, context) { renderFormFeedback: function(template, context) {
var tpl = HtmlUtils.template(template); var tpl = HtmlUtils.template(template);
HtmlUtils.prepend(this.$formFeedback, tpl(context)); HtmlUtils.prepend(this.$formFeedback, tpl(context));
},
// Scroll to feedback container doOnErrorList: function(id, action) {
$('html,body').animate({ var i;
scrollTop: this.$formFeedback.offset().top for (i = 0; i < this.errors.length; ++i) {
}, 'slow'); if (this.errors[i].includes(id)) {
action(i);
}
}
},
// Focus on the feedback container to ensure screen readers see the messages. updateError: function(error, id) {
this.$formFeedback.focus(); this.deleteError(id);
this.addError(error, id);
},
deleteError: function(id) {
var self = this;
this.doOnErrorList(id, function(index) {
self.errors.splice(index, 1);
});
},
addError: function(error, id) {
this.errors.push(StringUtils.interpolate(
'<li id="{errorId}">{error}</li>', {
errorId: id,
error: error
}
));
}, },
/* Allows extended views to add non-form attributes /* Allows extended views to add non-form attributes
...@@ -244,6 +261,14 @@ ...@@ -244,6 +261,14 @@
this.clearFormErrors(); this.clearFormErrors();
} else { } else {
this.renderErrors(this.defaultFormErrorsTitle, this.errors); this.renderErrors(this.defaultFormErrorsTitle, this.errors);
// Scroll to feedback container
$('html,body').animate({
scrollTop: this.$formFeedback.offset().top
}, 'slow');
// Focus on the feedback container to ensure screen readers see the messages.
this.$formFeedback.focus();
this.toggleDisableButton(false); this.toggleDisableButton(false);
} }
...@@ -285,6 +310,29 @@ ...@@ -285,6 +310,29 @@
validate: function($el) { validate: function($el) {
return EdxUtilsValidate.validate($el); return EdxUtilsValidate.validate($el);
},
liveValidate: function($el, url, dataType, data, method, model) {
$.ajax({
url: url,
dataType: dataType,
data: data,
method: method,
success: function(response) {
model.trigger('validation', $el, response);
}
});
},
inLiveValidationFields: function($el) {
var i,
name = $el.attr('name') || false;
for (i = 0; i < this.liveValidationFields.length; ++i) {
if (this.liveValidationFields[i] === name) {
return true;
}
}
return false;
} }
}); });
}); });
......
...@@ -4,29 +4,45 @@ ...@@ -4,29 +4,45 @@
'jquery', 'jquery',
'underscore', 'underscore',
'gettext', 'gettext',
'edx-ui-toolkit/js/utils/string-utils',
'js/student_account/views/FormView', 'js/student_account/views/FormView',
'text!templates/student_account/form_status.underscore' 'text!templates/student_account/form_status.underscore'
], ],
function($, _, gettext, FormView, formStatusTpl) { function(
$, _, gettext,
StringUtils,
FormView,
formStatusTpl
) {
return FormView.extend({ return FormView.extend({
el: '#register-form', el: '#register-form',
tpl: '#register-tpl', tpl: '#register-tpl',
validationUrl: '/api/user/v1/validation/registration',
events: { events: {
'click .js-register': 'submitForm', 'click .js-register': 'submitForm',
'click .login-provider': 'thirdPartyAuth' 'click .login-provider': 'thirdPartyAuth',
'click input[required][type="checkbox"]': 'liveValidateHandler',
'blur input[required], textarea[required], select[required]': 'liveValidateHandler',
'focus input[required], textarea[required], select[required]': 'handleRequiredInputFocus'
}, },
liveValidationFields: [
'name',
'username',
'password',
'email',
'confirm_email',
'country',
'honor_code',
'terms_of_service'
],
formType: 'register', formType: 'register',
formStatusTpl: formStatusTpl, formStatusTpl: formStatusTpl,
authWarningJsHook: 'js-auth-warning', authWarningJsHook: 'js-auth-warning',
defaultFormErrorsTitle: gettext('We couldn\'t create your account.'), defaultFormErrorsTitle: gettext('We couldn\'t create your account.'),
submitButton: '.js-register', submitButton: '.js-register',
positiveValidationIcon: 'fa-check',
negativeValidationIcon: 'fa-exclamation',
successfulValidationDisplaySeconds: 3,
preRender: function(data) { preRender: function(data) {
this.providers = data.thirdPartyAuth.providers || []; this.providers = data.thirdPartyAuth.providers || [];
...@@ -41,6 +57,7 @@ ...@@ -41,6 +57,7 @@
this.autoRegisterWelcomeMessage = data.thirdPartyAuth.autoRegisterWelcomeMessage || ''; this.autoRegisterWelcomeMessage = data.thirdPartyAuth.autoRegisterWelcomeMessage || '';
this.listenTo(this.model, 'sync', this.saveSuccess); this.listenTo(this.model, 'sync', this.saveSuccess);
this.listenTo(this.model, 'validation', this.renderLiveValidations);
}, },
render: function(html) { render: function(html) {
...@@ -79,6 +96,144 @@ ...@@ -79,6 +96,144 @@
return this; return this;
}, },
hideRequiredMessageExceptOnError: function($el) {
// We only handle blur if not in an error state.
if (!$el.hasClass('error')) {
this.hideRequiredMessage($el);
}
},
hideRequiredMessage: function($el) {
this.doOnInputLabel($el, function($label) {
$label.addClass('hidden');
});
},
doOnInputLabel: function($el, action) {
var $label = this.getRequiredTextLabel($el);
action($label);
},
handleRequiredInputFocus: function(event) {
var $el = $(event.currentTarget);
// Avoid rendering for required checkboxes.
if ($el.attr('type') !== 'checkbox') {
this.renderRequiredMessage($el);
}
if ($el.hasClass('error')) {
this.doOnInputLabel($el, function($label) {
$label.addClass('error');
});
}
},
renderRequiredMessage: function($el) {
this.doOnInputLabel($el, function($label) {
$label.removeClass('hidden').text(gettext('(required)'));
});
},
getRequiredTextLabel: function($el) {
return $('#' + $el.attr('id') + '-required-label');
},
renderLiveValidations: function($el, decisions) {
var $label = this.getLabel($el),
$requiredTextLabel = this.getRequiredTextLabel($el),
$icon = this.getIcon($el),
$errorTip = this.getErrorTip($el),
name = $el.attr('name'),
type = $el.attr('type'),
isCheckbox = type === 'checkbox',
hasError = decisions.validation_decisions[name] !== '',
error = isCheckbox ? '' : decisions.validation_decisions[name];
if (hasError) {
this.renderLiveValidationError($el, $label, $requiredTextLabel, $icon, $errorTip, error);
} else {
this.renderLiveValidationSuccess($el, $label, $requiredTextLabel, $icon, $errorTip);
}
},
getLabel: function($el) {
return this.$form.find('label[for=' + $el.attr('id') + ']');
},
getIcon: function($el) {
return $('#' + $el.attr('id') + '-validation-icon');
},
getErrorTip: function($el) {
return $('#' + $el.attr('id') + '-validation-error-msg');
},
getFieldTimeout: function($el) {
return $('#' + $el.attr('id')).attr('timeout-id') || null;
},
setFieldTimeout: function($el, time, action) {
$el.attr('timeout-id', setTimeout(action, time));
},
clearFieldTimeout: function($el) {
var timeout = this.getFieldTimeout($el);
if (timeout) {
clearTimeout(this.getFieldTimeout($el));
$el.removeAttr('timeout-id');
}
},
renderLiveValidationError: function($el, $label, $req, $icon, $tip, error) {
this.removeLiveValidationIndicators(
$el, $label, $req, $icon,
'success', this.positiveValidationIcon
);
this.addLiveValidationIndicators(
$el, $label, $req, $icon, $tip,
'error', this.negativeValidationIcon, error
);
this.renderRequiredMessage($el);
},
renderLiveValidationSuccess: function($el, $label, $req, $icon, $tip) {
var self = this,
validationFadeTime = this.successfulValidationDisplaySeconds * 1000;
this.removeLiveValidationIndicators(
$el, $label, $req, $icon,
'error', this.negativeValidationIcon
);
this.addLiveValidationIndicators(
$el, $label, $req, $icon, $tip,
'success', this.positiveValidationIcon, ''
);
this.hideRequiredMessage($el);
// Hide success indicators after some time.
this.clearFieldTimeout($el);
this.setFieldTimeout($el, validationFadeTime, function() {
self.removeLiveValidationIndicators(
$el, $label, $req, $icon,
'success', self.positiveValidationIcon
);
self.clearFieldTimeout($el);
});
},
addLiveValidationIndicators: function($el, $label, $req, $icon, $tip, indicator, icon, msg) {
$el.addClass(indicator);
$label.addClass(indicator);
$req.addClass(indicator);
$icon.addClass(indicator + ' ' + icon);
$tip.text(msg);
},
removeLiveValidationIndicators: function($el, $label, $req, $icon, indicator, icon) {
$el.removeClass(indicator);
$label.removeClass(indicator);
$req.removeClass(indicator);
$icon.removeClass(indicator + ' ' + icon);
},
thirdPartyAuth: function(event) { thirdPartyAuth: function(event) {
var providerUrl = $(event.currentTarget).data('provider-url') || ''; var providerUrl = $(event.currentTarget).data('provider-url') || '';
...@@ -100,7 +255,11 @@ ...@@ -100,7 +255,11 @@
function(errorList) { function(errorList) {
return _.map( return _.map(
errorList, errorList,
function(errorItem) { return '<li>' + errorItem.user_message + '</li>'; } function(errorItem) {
return StringUtils.interpolate('<li>{error}</li>', {
error: errorItem.user_message
});
}
); );
} }
) )
...@@ -135,32 +294,103 @@ ...@@ -135,32 +294,103 @@
getFormData: function() { getFormData: function() {
var obj = FormView.prototype.getFormData.apply(this, arguments), var obj = FormView.prototype.getFormData.apply(this, arguments),
$form = this.$form, $form = this.$form,
$label, $emailElement = $form.find('input[name=email]'),
$emailElement, $confirmEmail = $form.find('input[name=confirm_email]'),
$confirmEmailElement, elements = $form[0].elements,
email = '', $el,
confirmEmail = ''; key = '',
i;
$emailElement = $form.find('input[name=email]');
$confirmEmailElement = $form.find('input[name=confirm_email]'); for (i = 0; i < elements.length; i++) {
$el = $(elements[i]);
if ($confirmEmailElement.length) { key = $el.attr('name') || false;
email = $emailElement.val();
confirmEmail = $confirmEmailElement.val(); // Due to a bug in firefox, whitespaces in email type field are not removed.
$label = $form.find('label[for=' + $confirmEmailElement.attr('id') + ']'); // TODO: Remove this code once firefox bug is resolved.
if (key === 'email') {
if (confirmEmail !== '' && email !== confirmEmail) { $el.val($el.val().trim());
this.errors.push('<li>' + $confirmEmailElement.data('errormsg-required') + '</li>');
$confirmEmailElement.addClass('error');
$label.addClass('error');
} else if (confirmEmail !== '') {
obj.confirm_email = confirmEmail;
$confirmEmailElement.removeClass('error');
$label.removeClass('error');
} }
// Simulate live validation.
if ($el.attr('required')) {
$el.blur();
// Special case: show required string for errors even if we're not focused.
if ($el.hasClass('error')) {
this.renderRequiredMessage($el);
}
}
}
if ($confirmEmail.length) {
if (!$confirmEmail.val() || ($emailElement.val() !== $confirmEmail.val())) {
this.errors.push(StringUtils.interpolate('<li>{error}</li>', {
error: $confirmEmail.data('errormsg-required')
}));
}
obj.confirm_email = $confirmEmail.val();
} }
return obj; return obj;
},
liveValidateHandler: function(event) {
var $el = $(event.currentTarget);
// Until we get a back-end that can handle all available
// registration fields, we do some generic validation here.
if (this.inLiveValidationFields($el)) {
if ($el.attr('type') === 'checkbox') {
this.liveValidateCheckbox($el);
} else {
this.liveValidate($el);
}
} else {
this.genericLiveValidateHandler($el);
}
// On blur, we do exactly as the function name says, no matter which input.
this.hideRequiredMessageExceptOnError($el);
},
liveValidate: function($el) {
var data = {},
field,
i;
for (i = 0; i < this.liveValidationFields.length; ++i) {
field = this.liveValidationFields[i];
data[field] = $('#register-' + field).val();
}
FormView.prototype.liveValidate(
$el, this.validationUrl, 'json', data, 'POST', this.model
);
},
liveValidateCheckbox: function($checkbox) {
var validationDecisions = {validation_decisions: {}},
decisions = validationDecisions.validation_decisions,
name = $checkbox.attr('name'),
checked = $checkbox.is(':checked'),
error = $checkbox.data('errormsg-required');
decisions[name] = checked ? '' : error;
this.renderLiveValidations($checkbox, validationDecisions);
},
genericLiveValidateHandler: function($el) {
var elementType = $el.attr('type');
if (elementType === 'checkbox') {
// We are already validating checkboxes in a generic way.
this.liveValidateCheckbox($el);
} else {
this.genericLiveValidate($el);
}
},
genericLiveValidate: function($el) {
var validationDecisions = {validation_decisions: {}},
decisions = validationDecisions.validation_decisions,
name = $el.attr('name'),
error = $el.data('errormsg-required');
decisions[name] = $el.val() ? '' : error;
this.renderLiveValidations($el, validationDecisions);
} }
}); });
}); });
......
...@@ -296,10 +296,6 @@ ...@@ -296,10 +296,6 @@
display: inline; display: inline;
} }
&.error {
color: $red;
}
&[for="register-data_sharing_consent"], &[for="register-data_sharing_consent"],
&[for="register-honor_code"], &[for="register-honor_code"],
&[for="register-terms_of_service"] { &[for="register-terms_of_service"] {
...@@ -365,7 +361,22 @@ ...@@ -365,7 +361,22 @@
} }
&.error { &.error {
border-color: $error-color; border-color: $red;
}
&.success {
border-color: $success-color-hover;
}
}
textarea,
select {
&.error {
outline-color: $red;
}
&.success {
outline-color: $success-color-hover;
} }
} }
...@@ -384,9 +395,16 @@ ...@@ -384,9 +395,16 @@
&:active, &:focus { &:active, &:focus {
outline: auto; outline: auto;
} }
}
span,
label {
&.error { &.error {
outline-color: $error-color; color: $red;
}
&.success {
color: $success-color-hover;
} }
} }
...@@ -394,6 +412,7 @@ ...@@ -394,6 +412,7 @@
@extend %t-copy-sub1; @extend %t-copy-sub1;
color: $uxpl-gray-base; color: $uxpl-gray-base;
} }
.tip { .tip {
display: block; display: block;
} }
......
<div class="form-field <%=type%>-<%= name %>"> <div class="form-field <%- type %>-<%- name %>">
<% if ( type !== 'checkbox' ) { %> <% if ( type !== 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>"> <label for="<%- form %>-<%- name %>">
<span class="label-text"><%= label %></span> <span class="label-text"><%- label %></span>
<% if ( required && requiredStr && (type !== 'hidden') ) { %><span class="label-required"><%= requiredStr %></span><% } %> <% if ( required && type !== 'hidden' ) { %>
<% if ( !required && optionalStr && (type !== 'hidden') ) { %><span class="label-optional"><%= optionalStr %></span><% } %> <span id="<%- form %>-<%- name %>-required-label"
class="label-required <% if ( !requiredStr ) { %>hidden<% } %>">
<% if ( requiredStr ) { %><%- requiredStr %><% }%>
</span>
<span class="icon fa" id="<%- form %>-<%- name %>-validation-icon" aria-hidden="true"></span>
<% } %>
<% if ( !required && optionalStr && (type !== 'hidden') ) { %>
<span class="label-optional" id="<%- form %>-<%- name %>-optional-label"><%- optionalStr %></span>
<% } %>
</label> </label>
<% if (supplementalLink && supplementalText) { %> <% if (supplementalLink && supplementalText) { %>
<div class="supplemental-link"> <div class="supplemental-link">
...@@ -13,45 +21,54 @@ ...@@ -13,45 +21,54 @@
<% } %> <% } %>
<% if ( type === 'select' ) { %> <% if ( type === 'select' ) { %>
<select id="<%= form %>-<%= name %>" <select id="<%- form %>-<%- name %>"
name="<%= name %>" name="<%- name %>"
class="input-inline" class="input-inline"
<% if ( instructions ) { %> <% if ( instructions ) { %>
aria-describedby="<%= form %>-<%= name %>-desc" aria-describedby="<%- form %>-<%- name %>-desc <%- form %>-<%- name %>-validation-error"
<% } %> <% } %>
<% if ( typeof errorMessages !== 'undefined' ) { <% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%> _.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>" data-errormsg-<%- type %>="<%- msg %>"
<% }); <% });
} %> } %>
<% if ( required ) { %> aria-required="true" required<% } %>> <% if ( required ) { %> aria-required="true" required<% } %>
>
<% _.each(options, function(el) { %> <% _.each(options, function(el) { %>
<option value="<%= el.value%>"<% if ( el.default ) { %> data-isdefault="true" selected<% } %>><%= el.name %></option> <option value="<%- el.value%>"<% if ( el.default ) { %> data-isdefault="true" selected<% } %>><%- el.name %></option>
<% }); %> <% }); %>
</select> </select>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%= form %>-<%= name %>-desc"><%= instructions %></span><% } %> <span id="<%- form %>-<%- name %>-validation-error" class="tip error" aria-live="assertive">
<span class="sr-only">ERROR: </span>
<span id="<%- form %>-<%- name %>-validation-error-msg"></span>
</span>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%- form %>-<%- name %>-desc"><%- instructions %></span><% } %>
<% if (supplementalLink && supplementalText) { %> <% if (supplementalLink && supplementalText) { %>
<div class="supplemental-link"> <div class="supplemental-link">
<a href="<%- supplementalLink %>" target="_blank"><%- supplementalText %></a> <a href="<%- supplementalLink %>" target="_blank"><%- supplementalText %></a>
</div> </div>
<% } %> <% } %>
<% } else if ( type === 'textarea' ) { %> <% } else if ( type === 'textarea' ) { %>
<textarea id="<%= form %>-<%= name %>" <textarea id="<%- form %>-<%- name %>"
type="<%= type %>" type="<%- type %>"
name="<%= name %>" name="<%- name %>"
class="input-block" class="input-block"
<% if ( instructions ) { %> <% if ( instructions ) { %>
aria-describedby="<%= form %>-<%= name %>-desc" aria-describedby="<%- form %>-<%- name %>-desc <%- form %>-<%- name %>-validation-error"
<% } %> <% } %>
<% if ( restrictions.min_length ) { %> minlength="<%= restrictions.min_length %>"<% } %> <% if ( restrictions.min_length ) { %> minlength="<%- restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%= restrictions.max_length %>"<% } %> <% if ( restrictions.max_length ) { %> maxlength="<%- restrictions.max_length %>"<% } %>
<% if ( typeof errorMessages !== 'undefined' ) { <% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%> _.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>" data-errormsg-<%- type %>="<%- msg %>"
<% }); <% });
} %> } %>
<% if ( required ) { %> aria-required="true" required<% } %> ></textarea> <% if ( required ) { %> aria-required="true" required<% } %>></textarea>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%= form %>-<%= name %>-desc"><%= instructions %></span><% } %> <span id="<%- form %>-<%- name %>-validation-error" class="tip error" aria-live="assertive">
<span class="sr-only">ERROR: </span>
<span id="<%- form %>-<%- name %>-validation-error-msg"></span>
</span>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%- form %>-<%- name %>-desc"><%- instructions %></span><% } %>
<% if (supplementalLink && supplementalText) { %> <% if (supplementalLink && supplementalText) { %>
<div class="supplemental-link"> <div class="supplemental-link">
<a href="<%- supplementalLink %>" target="_blank"><%- supplementalText %></a> <a href="<%- supplementalLink %>" target="_blank"><%- supplementalText %></a>
...@@ -65,30 +82,44 @@ ...@@ -65,30 +82,44 @@
</div> </div>
<% } %> <% } %>
<% } %> <% } %>
<input id="<%= form %>-<%= name %>" <input id="<%- form %>-<%- name %>"
type="<%= type %>" type="<%- type %>"
name="<%= name %>" name="<%- name %>"
class="input-block <% if ( type === 'checkbox' ) { %>checkbox<% } %>" class="input-block <% if ( type === 'checkbox' ) { %>checkbox<% } %>"
<% if ( instructions ) { %> aria-describedby="<%= form %>-<%= name %>-desc" <% } %> <% if ( instructions ) { %>
<% if ( restrictions.min_length ) { %> minlength="<%= restrictions.min_length %>"<% } %> aria-describedby="<%- form %>-<%- name %>-desc <%- form %>-<%- name %>-validation-error"
<% if ( restrictions.max_length ) { %> maxlength="<%= restrictions.max_length %>"<% } %> <% } %>
<% if ( restrictions.min_length ) { %> minlength="<%- restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%- restrictions.max_length %>"<% } %>
<% if ( required ) { %> required<% } %> <% if ( required ) { %> required<% } %>
<% if ( typeof errorMessages !== 'undefined' ) { <% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%> _.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>" data-errormsg-<%- type %>="<%- msg %>"
<% }); <% });
} %> } %>
<% if ( placeholder ) { %> placeholder="<%= placeholder %>"<% } %> <% if ( placeholder ) { %> placeholder="<%- placeholder %>"<% } %>
value="<%- defaultValue %>" value="<%- defaultValue %>"
/> />
<% if ( type === 'checkbox' ) { %> <% if ( type === 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>"> <label for="<%- form %>-<%- name %>">
<span class="label-text"><%= label %></span> <span class="label-text"><%- label %></span>
<% if ( required && requiredStr ) { %><span class="label-required"><%= requiredStr %></span><% } %> <% if ( required && type !== 'hidden' ) { %>
<% if ( !required && optionalStr ) { %><span class="label-optional"><%= optionalStr %></span><% } %> <span id="<%- form %>-<%- name %>-required-label"
class="label-required <% if ( !requiredStr ) { %>hidden<% } %>">
<% if ( requiredStr ) { %><%- requiredStr %><% }%>
</span>
<span class="icon fa" id="<%- form %>-<%- name %>-validation-icon" aria-hidden="true"></span>
<% } %>
<% if ( !required && optionalStr ) { %>
<span class="label-optional" id="<%- form %>-<%- name %>-optional-label"><%- optionalStr %></span>
<% } %>
</label> </label>
<% } %> <% } %>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%= form %>-<%= name %>-desc"><%= instructions %></span><% } %> <span id="<%- form %>-<%- name %>-validation-error" class="tip error" aria-live="assertive">
<span class="sr-only">ERROR: </span>
<span id="<%- form %>-<%- name %>-validation-error-msg"></span>
</span>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%- form %>-<%- name %>-desc"><%- instructions %></span><% } %>
<% } %> <% } %>
<% if( form === 'login' && name === 'password' ) { %> <% if( form === 'login' && name === 'password' ) { %>
......
...@@ -45,7 +45,7 @@ USERNAME_INVALID_CHARS_UNICODE = _( ...@@ -45,7 +45,7 @@ USERNAME_INVALID_CHARS_UNICODE = _(
# Translators: This message is shown to users who attempt to create a new account using # Translators: This message is shown to users who attempt to create a new account using
# an invalid email format. # an invalid email format.
EMAIL_INVALID_MSG = _(u"Email '{email}' format is not valid") EMAIL_INVALID_MSG = _(u'"{email}" is not a valid email address.')
# Translators: This message is shown to users who attempt to create a new # Translators: This message is shown to users who attempt to create a new
# account using an username/email associated with an existing account. # account using an username/email associated with an existing account.
...@@ -60,15 +60,31 @@ USERNAME_CONFLICT_MSG = _( ...@@ -60,15 +60,31 @@ USERNAME_CONFLICT_MSG = _(
# Translators: This message is shown to users who enter a username/email/password # Translators: This message is shown to users who enter a username/email/password
# with an inappropriate length (too short or too long). # with an inappropriate length (too short or too long).
USERNAME_BAD_LENGTH_MSG = _(u"Username '{username}' must be between {min} and {max} characters long") USERNAME_BAD_LENGTH_MSG = _(u"Username must be between {min} and {max} characters long.").format(
EMAIL_BAD_LENGTH_MSG = _(u"Email '{email}' must be between {min} and {max} characters long") min=USERNAME_MIN_LENGTH, max=USERNAME_MAX_LENGTH
PASSWORD_BAD_LENGTH_MSG = _(u"Password must be between {min} and {max} characters long") )
EMAIL_BAD_LENGTH_MSG = _(u"Enter a valid email address that contains at least {min} characters.").format(
min=EMAIL_MIN_LENGTH
)
PASSWORD_EMPTY_MSG = _(u"Please enter a password.")
PASSWORD_BAD_MIN_LENGTH_MSG = _(u"Password is not long enough.")
PASSWORD_BAD_MAX_LENGTH_MSG = _(u"Password cannot be longer than {max} character.").format(max=PASSWORD_MAX_LENGTH)
# These strings are normally not user-facing. # These strings are normally not user-facing.
USERNAME_BAD_TYPE_MSG = u"Username must be a string" USERNAME_BAD_TYPE_MSG = u"Username must be a string."
EMAIL_BAD_TYPE_MSG = u"Email must be a string" EMAIL_BAD_TYPE_MSG = u"Email must be a string."
PASSWORD_BAD_TYPE_MSG = u"Password must be a string" PASSWORD_BAD_TYPE_MSG = u"Password must be a string."
# Translators: This message is shown to users who enter a password matching # Translators: This message is shown to users who enter a password matching
# the username they enter(ed). # the username they enter(ed).
PASSWORD_CANT_EQUAL_USERNAME_MSG = _(u"Password cannot be the same as the username") PASSWORD_CANT_EQUAL_USERNAME_MSG = _(u"Password cannot be the same as the username.")
# Translators: These messages are shown to users who do not enter information
# into the required field or enter it incorrectly.
REQUIRED_FIELD_NAME_MSG = _(u"Please enter your Full Name.")
REQUIRED_FIELD_CONFIRM_EMAIL_MSG = _(u"The email addresses do not match.")
REQUIRED_FIELD_COUNTRY_MSG = _(u"Please select your Country.")
REQUIRED_FIELD_CITY_MSG = _(u"Please enter your City.")
REQUIRED_FIELD_GOALS_MSG = _(u"Please tell us your goals.")
REQUIRED_FIELD_LEVEL_OF_EDUCATION_MSG = _(u"Please select your highest level of education completed.")
REQUIRED_FIELD_MAILING_ADDRESS_MSG = _(u"Please enter your mailing address.")
...@@ -20,37 +20,19 @@ from util.model_utils import emit_setting_changed_event ...@@ -20,37 +20,19 @@ from util.model_utils import emit_setting_changed_event
from openedx.core.lib.api.view_utils import add_serializer_errors from openedx.core.lib.api.view_utils import add_serializer_errors
from ..errors import (
AccountUpdateError, AccountValidationError,
AccountDataBadLength, AccountDataBadType,
AccountUsernameInvalid, AccountPasswordInvalid, AccountEmailInvalid,
AccountUserAlreadyExists, AccountUsernameAlreadyExists, AccountEmailAlreadyExists,
UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized
)
from ..forms import PasswordResetFormNoActive
from ..helpers import intercept_errors
from . import (
EMAIL_BAD_LENGTH_MSG, PASSWORD_BAD_LENGTH_MSG, USERNAME_BAD_LENGTH_MSG,
EMAIL_BAD_TYPE_MSG, PASSWORD_BAD_TYPE_MSG, USERNAME_BAD_TYPE_MSG,
EMAIL_CONFLICT_MSG, USERNAME_CONFLICT_MSG,
EMAIL_INVALID_MSG, USERNAME_INVALID_MSG,
EMAIL_MIN_LENGTH, PASSWORD_MIN_LENGTH, USERNAME_MIN_LENGTH,
EMAIL_MAX_LENGTH, PASSWORD_MAX_LENGTH, USERNAME_MAX_LENGTH,
PASSWORD_CANT_EQUAL_USERNAME_MSG
)
from .serializers import ( from .serializers import (
AccountLegacyProfileSerializer, AccountUserSerializer, AccountLegacyProfileSerializer, AccountUserSerializer,
UserReadOnlySerializer, _visible_fields # pylint: disable=invalid-name UserReadOnlySerializer, _visible_fields # pylint: disable=invalid-name
) )
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import errors, accounts, forms, helpers
# Public access point for this function. # Public access point for this function.
visible_fields = _visible_fields visible_fields = _visible_fields
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) @helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
def get_account_settings(request, usernames=None, configuration=None, view=None): def get_account_settings(request, usernames=None, configuration=None, view=None):
"""Returns account information for a user serialized as JSON. """Returns account information for a user serialized as JSON.
...@@ -75,9 +57,9 @@ def get_account_settings(request, usernames=None, configuration=None, view=None) ...@@ -75,9 +57,9 @@ def get_account_settings(request, usernames=None, configuration=None, view=None)
A list of users account details. A list of users account details.
Raises: Raises:
UserNotFound: no user with username `username` exists (or `request.user.username` if errors.UserNotFound: no user with username `username` exists (or `request.user.username` if
`username` is not specified) `username` is not specified)
UserAPIInternalError: the operation failed due to an unexpected error. errors.UserAPIInternalError: the operation failed due to an unexpected error.
""" """
requesting_user = request.user requesting_user = request.user
...@@ -85,7 +67,7 @@ def get_account_settings(request, usernames=None, configuration=None, view=None) ...@@ -85,7 +67,7 @@ def get_account_settings(request, usernames=None, configuration=None, view=None)
requested_users = User.objects.select_related('profile').filter(username__in=usernames) requested_users = User.objects.select_related('profile').filter(username__in=usernames)
if not requested_users: if not requested_users:
raise UserNotFound() raise errors.UserNotFound()
serialized_users = [] serialized_users = []
for user in requested_users: for user in requested_users:
...@@ -104,7 +86,7 @@ def get_account_settings(request, usernames=None, configuration=None, view=None) ...@@ -104,7 +86,7 @@ def get_account_settings(request, usernames=None, configuration=None, view=None)
return serialized_users return serialized_users
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) @helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
def update_account_settings(requesting_user, update, username=None): def update_account_settings(requesting_user, update, username=None):
"""Update user account information. """Update user account information.
...@@ -120,17 +102,17 @@ def update_account_settings(requesting_user, update, username=None): ...@@ -120,17 +102,17 @@ def update_account_settings(requesting_user, update, username=None):
`requesting_user.username` is assumed. `requesting_user.username` is assumed.
Raises: Raises:
UserNotFound: no user with username `username` exists (or `requesting_user.username` if errors.UserNotFound: no user with username `username` exists (or `requesting_user.username` if
`username` is not specified) `username` is not specified)
UserNotAuthorized: the requesting_user does not have access to change the account errors.UserNotAuthorized: the requesting_user does not have access to change the account
associated with `username` associated with `username`
AccountValidationError: the update was not attempted because validation errors were found with errors.AccountValidationError: the update was not attempted because validation errors were found with
the supplied update the supplied update
AccountUpdateError: the update could not be completed. Note that if multiple fields are updated at the same errors.AccountUpdateError: the update could not be completed. Note that if multiple fields are updated at the
time, some parts of the update may have been successful, even if an AccountUpdateError is returned; same time, some parts of the update may have been successful, even if an errors.AccountUpdateError is
in particular, the user account (not including e-mail address) may have successfully been updated, returned; in particular, the user account (not including e-mail address) may have successfully been updated,
but then the e-mail change request, which is processed last, may throw an error. but then the e-mail change request, which is processed last, may throw an error.
UserAPIInternalError: the operation failed due to an unexpected error. errors.UserAPIInternalError: the operation failed due to an unexpected error.
""" """
if username is None: if username is None:
...@@ -139,7 +121,7 @@ def update_account_settings(requesting_user, update, username=None): ...@@ -139,7 +121,7 @@ def update_account_settings(requesting_user, update, username=None):
existing_user, existing_user_profile = _get_user_and_profile(username) existing_user, existing_user_profile = _get_user_and_profile(username)
if requesting_user.username != username: if requesting_user.username != username:
raise UserNotAuthorized() raise errors.UserNotAuthorized()
# If user has requested to change email, we must call the multi-step process to handle this. # If user has requested to change email, we must call the multi-step process to handle this.
# It is not handled by the serializer (which considers email to be read-only). # It is not handled by the serializer (which considers email to be read-only).
...@@ -189,7 +171,7 @@ def update_account_settings(requesting_user, update, username=None): ...@@ -189,7 +171,7 @@ def update_account_settings(requesting_user, update, username=None):
# If we have encountered any validation errors, return them to the user. # If we have encountered any validation errors, return them to the user.
if field_errors: if field_errors:
raise AccountValidationError(field_errors) raise errors.AccountValidationError(field_errors)
try: try:
# If everything validated, go ahead and save the serializers. # If everything validated, go ahead and save the serializers.
...@@ -234,26 +216,26 @@ def update_account_settings(requesting_user, update, username=None): ...@@ -234,26 +216,26 @@ def update_account_settings(requesting_user, update, username=None):
existing_user_profile.save() existing_user_profile.save()
except PreferenceValidationError as err: except PreferenceValidationError as err:
raise AccountValidationError(err.preference_errors) raise errors.AccountValidationError(err.preference_errors)
except Exception as err: except Exception as err:
raise AccountUpdateError( raise errors.AccountUpdateError(
u"Error thrown when saving account updates: '{}'".format(err.message) u"Error thrown when saving account updates: '{}'".format(err.message)
) )
# And try to send the email change request if necessary. # And try to send the email change request if necessary.
if changing_email: if changing_email:
if not settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']: if not settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']:
raise AccountUpdateError(u"Email address changes have been disabled by the site operators.") raise errors.AccountUpdateError(u"Email address changes have been disabled by the site operators.")
try: try:
student_views.do_email_change_request(existing_user, new_email) student_views.do_email_change_request(existing_user, new_email)
except ValueError as err: except ValueError as err:
raise AccountUpdateError( raise errors.AccountUpdateError(
u"Error thrown from do_email_change_request: '{}'".format(err.message), u"Error thrown from do_email_change_request: '{}'".format(err.message),
user_message=err.message user_message=err.message
) )
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) @helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
@transaction.atomic @transaction.atomic
def create_account(username, password, email): def create_account(username, password, email):
"""Create a new user account. """Create a new user account.
...@@ -287,11 +269,11 @@ def create_account(username, password, email): ...@@ -287,11 +269,11 @@ def create_account(username, password, email):
unicode: an activation key for the account. unicode: an activation key for the account.
Raises: Raises:
AccountUserAlreadyExists errors.AccountUserAlreadyExists
AccountUsernameInvalid errors.AccountUsernameInvalid
AccountEmailInvalid errors.AccountEmailInvalid
AccountPasswordInvalid errors.AccountPasswordInvalid
UserAPIInternalError: the operation failed due to an unexpected error. errors.UserAPIInternalError: the operation failed due to an unexpected error.
""" """
# Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation
...@@ -314,7 +296,7 @@ def create_account(username, password, email): ...@@ -314,7 +296,7 @@ def create_account(username, password, email):
try: try:
user.save() user.save()
except IntegrityError: except IntegrityError:
raise AccountUserAlreadyExists raise errors.AccountUserAlreadyExists
# Create a registration to track the activation process # Create a registration to track the activation process
# This implicitly saves the registration. # This implicitly saves the registration.
...@@ -349,17 +331,17 @@ def check_account_exists(username=None, email=None): ...@@ -349,17 +331,17 @@ def check_account_exists(username=None, email=None):
try: try:
_validate_email_doesnt_exist(email) _validate_email_doesnt_exist(email)
except AccountEmailAlreadyExists: except errors.AccountEmailAlreadyExists:
conflicts.append("email") conflicts.append("email")
try: try:
_validate_username_doesnt_exist(username) _validate_username_doesnt_exist(username)
except AccountUsernameAlreadyExists: except errors.AccountUsernameAlreadyExists:
conflicts.append("username") conflicts.append("username")
return conflicts return conflicts
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) @helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
def activate_account(activation_key): def activate_account(activation_key):
"""Activate a user's account. """Activate a user's account.
...@@ -370,20 +352,20 @@ def activate_account(activation_key): ...@@ -370,20 +352,20 @@ def activate_account(activation_key):
None None
Raises: Raises:
UserNotAuthorized errors.UserNotAuthorized
UserAPIInternalError: the operation failed due to an unexpected error. errors.UserAPIInternalError: the operation failed due to an unexpected error.
""" """
try: try:
registration = Registration.objects.get(activation_key=activation_key) registration = Registration.objects.get(activation_key=activation_key)
except Registration.DoesNotExist: except Registration.DoesNotExist:
raise UserNotAuthorized raise errors.UserNotAuthorized
else: else:
# This implicitly saves the registration # This implicitly saves the registration
registration.activate() registration.activate()
@intercept_errors(UserAPIInternalError, ignore_errors=[UserAPIRequestError]) @helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
def request_password_change(email, orig_host, is_secure): def request_password_change(email, orig_host, is_secure):
"""Email a single-use link for performing a password reset. """Email a single-use link for performing a password reset.
...@@ -398,14 +380,14 @@ def request_password_change(email, orig_host, is_secure): ...@@ -398,14 +380,14 @@ def request_password_change(email, orig_host, is_secure):
None None
Raises: Raises:
UserNotFound errors.UserNotFound
AccountRequestError AccountRequestError
UserAPIInternalError: the operation failed due to an unexpected error. errors.UserAPIInternalError: the operation failed due to an unexpected error.
""" """
# Binding data to a form requires that the data be passed as a dictionary # Binding data to a form requires that the data be passed as a dictionary
# to the Form class constructor. # to the Form class constructor.
form = PasswordResetFormNoActive({'email': email}) form = forms.PasswordResetFormNoActive({'email': email})
# Validate that a user exists with the given email address. # Validate that a user exists with the given email address.
if form.is_valid(): if form.is_valid():
...@@ -418,10 +400,21 @@ def request_password_change(email, orig_host, is_secure): ...@@ -418,10 +400,21 @@ def request_password_change(email, orig_host, is_secure):
) )
else: else:
# No user with the provided email address exists. # No user with the provided email address exists.
raise UserNotFound raise errors.UserNotFound
def get_name_validation_error(name):
"""Get the built-in validation error message for when
the user's real name is invalid in some way (we wonder how).
:param name: The proposed user's real name.
:return: Validation error message.
"""
return '' if name else accounts.REQUIRED_FIELD_NAME_MSG
def get_username_validation_error(username, default=''): def get_username_validation_error(username):
"""Get the built-in validation error message for when """Get the built-in validation error message for when
the username is invalid in some way. the username is invalid in some way.
...@@ -430,14 +423,10 @@ def get_username_validation_error(username, default=''): ...@@ -430,14 +423,10 @@ def get_username_validation_error(username, default=''):
:return: Validation error message. :return: Validation error message.
""" """
try: return _validate(_validate_username, errors.AccountUsernameInvalid, username)
_validate_username(username)
except AccountUsernameInvalid as invalid_username_err:
return invalid_username_err.message
return default
def get_email_validation_error(email, default=''): def get_email_validation_error(email):
"""Get the built-in validation error message for when """Get the built-in validation error message for when
the email is invalid in some way. the email is invalid in some way.
...@@ -446,14 +435,23 @@ def get_email_validation_error(email, default=''): ...@@ -446,14 +435,23 @@ def get_email_validation_error(email, default=''):
:return: Validation error message. :return: Validation error message.
""" """
try: return _validate(_validate_email, errors.AccountEmailInvalid, email)
_validate_email(email)
except AccountEmailInvalid as invalid_email_err:
return invalid_email_err.message
return default
def get_password_validation_error(password, username=None, default=''): 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.
:param confirm_email: The proposed confirmation email (unicode).
:param email: The email to match (unicode).
:param default: THe message to default to in case of no error.
:return: Validation error message.
"""
return _validate(_validate_confirm_email, errors.AccountEmailInvalid, confirm_email, email)
def get_password_validation_error(password, username=None):
"""Get the built-in validation error message for when """Get the built-in validation error message for when
the password is invalid in some way. the password is invalid in some way.
...@@ -463,14 +461,21 @@ def get_password_validation_error(password, username=None, default=''): ...@@ -463,14 +461,21 @@ def get_password_validation_error(password, username=None, default=''):
:return: Validation error message. :return: Validation error message.
""" """
try: return _validate(_validate_password, errors.AccountPasswordInvalid, password, username)
_validate_password(password, username)
except AccountPasswordInvalid as invalid_password_err:
return invalid_password_err.message
return default
def get_username_existence_validation_error(username, default=''): def get_country_validation_error(country):
"""Get the built-in validation error message for when
the country is invalid in some way.
:param country: The proposed country.
:return: Validation error message.
"""
return _validate(_validate_country, errors.AccountCountryInvalid, country)
def get_username_existence_validation_error(username):
"""Get the built-in validation error message for when """Get the built-in validation error message for when
the username has an existence conflict. the username has an existence conflict.
...@@ -479,14 +484,10 @@ def get_username_existence_validation_error(username, default=''): ...@@ -479,14 +484,10 @@ def get_username_existence_validation_error(username, default=''):
:return: Validation error message. :return: Validation error message.
""" """
try: return _validate(_validate_username_doesnt_exist, errors.AccountUsernameAlreadyExists, username)
_validate_username_doesnt_exist(username)
except AccountUsernameAlreadyExists as username_exists_err:
return username_exists_err.message
return default
def get_email_existence_validation_error(email, default=''): def get_email_existence_validation_error(email):
"""Get the built-in validation error message for when """Get the built-in validation error message for when
the email has an existence conflict. the email has an existence conflict.
...@@ -495,11 +496,7 @@ def get_email_existence_validation_error(email, default=''): ...@@ -495,11 +496,7 @@ def get_email_existence_validation_error(email, default=''):
:return: Validation error message. :return: Validation error message.
""" """
try: return _validate(_validate_email_doesnt_exist, errors.AccountEmailAlreadyExists, email)
_validate_email_doesnt_exist(email)
except AccountEmailAlreadyExists as email_exists_err:
return email_exists_err.message
return default
def _get_user_and_profile(username): def _get_user_and_profile(username):
...@@ -509,13 +506,31 @@ def _get_user_and_profile(username): ...@@ -509,13 +506,31 @@ def _get_user_and_profile(username):
try: try:
existing_user = User.objects.get(username=username) existing_user = User.objects.get(username=username)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise UserNotFound() raise errors.UserNotFound()
existing_user_profile, _ = UserProfile.objects.get_or_create(user=existing_user) existing_user_profile, _ = UserProfile.objects.get_or_create(user=existing_user)
return existing_user, existing_user_profile return existing_user, existing_user_profile
def _validate(validation_func, err, *args):
"""Generic validation function that returns default on
no errors, but the message associated with the err class
otherwise. Passes all other arguments into the validation function.
:param validation_func: The function used to perform validation.
:param err: The error class to catch.
:param args: The arguments to pass into the validation function.
:return: Validation error message, or empty string if no error.
"""
try:
validation_func(*args)
except err as validation_err:
return validation_err.message
return ''
def _validate_username(username): def _validate_username(username):
"""Validate the username. """Validate the username.
...@@ -526,25 +541,24 @@ def _validate_username(username): ...@@ -526,25 +541,24 @@ def _validate_username(username):
None None
Raises: Raises:
AccountUsernameInvalid errors.AccountUsernameInvalid
""" """
try: try:
_validate_unicode(username) _validate_unicode(username)
_validate_type(username, basestring, USERNAME_BAD_TYPE_MSG) _validate_type(username, basestring, accounts.USERNAME_BAD_TYPE_MSG)
_validate_length( _validate_length(
username, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_BAD_LENGTH_MSG.format( username,
username=username, accounts.USERNAME_MIN_LENGTH,
min=USERNAME_MIN_LENGTH, accounts.USERNAME_MAX_LENGTH,
max=USERNAME_MAX_LENGTH accounts.USERNAME_BAD_LENGTH_MSG
)
) )
with override_language('en'): with override_language('en'):
# `validate_username` provides a proper localized message, however the API needs only the English # `validate_username` provides a proper localized message, however the API needs only the English
# message by convention. # message by convention.
student_forms.validate_username(username) student_forms.validate_username(username)
except (UnicodeError, AccountDataBadType, AccountDataBadLength, ValidationError) as invalid_username_err: except (UnicodeError, errors.AccountDataBadType, errors.AccountDataBadLength, ValidationError) as username_err:
raise AccountUsernameInvalid(invalid_username_err.message) raise errors.AccountUsernameInvalid(username_err.message)
def _validate_email(email): def _validate_email(email):
...@@ -557,23 +571,29 @@ def _validate_email(email): ...@@ -557,23 +571,29 @@ def _validate_email(email):
None None
Raises: Raises:
AccountEmailInvalid errors.AccountEmailInvalid
""" """
try: try:
_validate_unicode(email) _validate_unicode(email)
_validate_type(email, basestring, EMAIL_BAD_TYPE_MSG) _validate_type(email, basestring, accounts.EMAIL_BAD_TYPE_MSG)
_validate_length( _validate_length(email, accounts.EMAIL_MIN_LENGTH, accounts.EMAIL_MAX_LENGTH, accounts.EMAIL_BAD_LENGTH_MSG)
email, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, EMAIL_BAD_LENGTH_MSG.format( validate_email.message = accounts.EMAIL_INVALID_MSG.format(email=email)
email=email,
min=EMAIL_MIN_LENGTH,
max=EMAIL_MAX_LENGTH
)
)
validate_email.message = EMAIL_INVALID_MSG.format(email=email)
validate_email(email) validate_email(email)
except (UnicodeError, AccountDataBadType, AccountDataBadLength, ValidationError) as invalid_email_err: except (UnicodeError, errors.AccountDataBadType, errors.AccountDataBadLength, ValidationError) as invalid_email_err:
raise AccountEmailInvalid(invalid_email_err.message) raise errors.AccountEmailInvalid(invalid_email_err.message)
def _validate_confirm_email(confirm_email, email):
"""Validate the confirmation email field.
:param confirm_email: The proposed confirmation email. (unicode)
:param email: The email to match. (unicode)
:return: None
"""
if not confirm_email or confirm_email != email:
raise errors.AccountEmailInvalid(accounts.REQUIRED_FIELD_CONFIRM_EMAIL_MSG)
def _validate_password(password, username=None): def _validate_password(password, username=None):
...@@ -590,20 +610,33 @@ def _validate_password(password, username=None): ...@@ -590,20 +610,33 @@ def _validate_password(password, username=None):
None None
Raises: Raises:
AccountPasswordInvalid errors.AccountPasswordInvalid
""" """
try: try:
_validate_type(password, basestring, PASSWORD_BAD_TYPE_MSG) _validate_type(password, basestring, accounts.PASSWORD_BAD_TYPE_MSG)
_validate_length(
password, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, PASSWORD_BAD_LENGTH_MSG.format( if len(password) == 0:
min=PASSWORD_MIN_LENGTH, raise errors.AccountPasswordInvalid(accounts.PASSWORD_EMPTY_MSG)
max=PASSWORD_MAX_LENGTH elif len(password) < accounts.PASSWORD_MIN_LENGTH:
) raise errors.AccountPasswordInvalid(accounts.PASSWORD_BAD_MIN_LENGTH_MSG)
) elif len(password) > accounts.PASSWORD_MAX_LENGTH:
raise errors.AccountPasswordInvalid(accounts.PASSWORD_BAD_MAX_LENGTH_MSG)
_validate_password_works_with_username(password, username) _validate_password_works_with_username(password, username)
except (AccountDataBadType, AccountDataBadLength) as invalid_password_err: except (errors.AccountDataBadType, errors.AccountDataBadLength) as invalid_password_err:
raise AccountPasswordInvalid(invalid_password_err.message) raise errors.AccountPasswordInvalid(invalid_password_err.message)
def _validate_country(country):
"""Validate the country selection.
:param country: The proposed country.
:return: None
"""
if country == '' or country == '--':
raise errors.AccountCountryInvalid(accounts.REQUIRED_FIELD_COUNTRY_MSG)
def _validate_username_doesnt_exist(username): def _validate_username_doesnt_exist(username):
...@@ -611,10 +644,10 @@ def _validate_username_doesnt_exist(username): ...@@ -611,10 +644,10 @@ def _validate_username_doesnt_exist(username):
:param username: The proposed username (unicode). :param username: The proposed username (unicode).
:return: None :return: None
:raises: AccountUsernameAlreadyExists :raises: errors.AccountUsernameAlreadyExists
""" """
if username is not None and User.objects.filter(username=username).exists(): if username is not None and User.objects.filter(username=username).exists():
raise AccountUsernameAlreadyExists(_(USERNAME_CONFLICT_MSG).format(username=username)) raise errors.AccountUsernameAlreadyExists(_(accounts.USERNAME_CONFLICT_MSG).format(username=username))
def _validate_email_doesnt_exist(email): def _validate_email_doesnt_exist(email):
...@@ -622,10 +655,10 @@ def _validate_email_doesnt_exist(email): ...@@ -622,10 +655,10 @@ def _validate_email_doesnt_exist(email):
:param email: The proposed email (unicode). :param email: The proposed email (unicode).
:return: None :return: None
:raises: AccountEmailAlreadyExists :raises: errors.AccountEmailAlreadyExists
""" """
if email is not None and User.objects.filter(email=email).exists(): if email is not None and User.objects.filter(email=email).exists():
raise AccountEmailAlreadyExists(_(EMAIL_CONFLICT_MSG).format(email_address=email)) raise errors.AccountEmailAlreadyExists(_(accounts.EMAIL_CONFLICT_MSG).format(email_address=email))
def _validate_password_works_with_username(password, username=None): def _validate_password_works_with_username(password, username=None):
...@@ -637,10 +670,10 @@ def _validate_password_works_with_username(password, username=None): ...@@ -637,10 +670,10 @@ def _validate_password_works_with_username(password, username=None):
:param password: The proposed password (unicode). :param password: The proposed password (unicode).
:param username: The username associated with the user's account (unicode). :param username: The username associated with the user's account (unicode).
:return: None :return: None
:raises: AccountPasswordInvalid :raises: errors.AccountPasswordInvalid
""" """
if password == username: if password == username:
raise AccountPasswordInvalid(PASSWORD_CANT_EQUAL_USERNAME_MSG) raise errors.AccountPasswordInvalid(accounts.PASSWORD_CANT_EQUAL_USERNAME_MSG)
def _validate_type(data, type, err): def _validate_type(data, type, err):
...@@ -651,11 +684,11 @@ def _validate_type(data, type, err): ...@@ -651,11 +684,11 @@ def _validate_type(data, type, err):
:param type: The type to check against. :param type: The type to check against.
:param err: The error message to throw back if data is not of type. :param err: The error message to throw back if data is not of type.
:return: None :return: None
:raises: AccountDataBadType :raises: errors.AccountDataBadType
""" """
if not isinstance(data, type): if not isinstance(data, type):
raise AccountDataBadType(err) raise errors.AccountDataBadType(err)
def _validate_length(data, min, max, err): def _validate_length(data, min, max, err):
...@@ -665,12 +698,13 @@ def _validate_length(data, min, max, err): ...@@ -665,12 +698,13 @@ def _validate_length(data, min, max, err):
:param data: The data to do the test on. :param data: The data to do the test on.
:param min: The minimum allowed length. :param min: The minimum allowed length.
:param max: The maximum allowed length. :param max: The maximum allowed length.
:param err: The error message to throw back if data's length is below min or above max.
:return: None :return: None
:raises: AccountDataBadLength :raises: errors.AccountDataBadLength
""" """
if len(data) < min or len(data) > max: if len(data) < min or len(data) > max:
raise AccountDataBadLength(err) raise errors.AccountDataBadLength(err)
def _validate_unicode(data, err=u"Input not valid unicode"): def _validate_unicode(data, err=u"Input not valid unicode"):
...@@ -684,8 +718,8 @@ def _validate_unicode(data, err=u"Input not valid unicode"): ...@@ -684,8 +718,8 @@ def _validate_unicode(data, err=u"Input not valid unicode"):
""" """
try: try:
if not isinstance(data, str) and not isinstance(data, unicode): if not isinstance(data, str) and not isinstance(data, unicode):
raise UnicodeError raise UnicodeError(err)
# In some cases we pass the above, but it's still inappropriate utf-8. # In some cases we pass the above, but it's still inappropriate utf-8.
str(data) unicode(data)
except UnicodeError: except UnicodeError:
raise UnicodeError(err) raise UnicodeError(err)
...@@ -36,7 +36,7 @@ from openedx.core.djangoapps.user_api.errors import ( ...@@ -36,7 +36,7 @@ from openedx.core.djangoapps.user_api.errors import (
AccountRequestError AccountRequestError
) )
from openedx.core.djangoapps.user_api.accounts.tests.testutils import ( from openedx.core.djangoapps.user_api.accounts.tests.testutils import (
INVALID_EMAILS, INVALID_PASSWORDS, INVALID_USERNAMES INVALID_EMAILS, INVALID_PASSWORDS, INVALID_USERNAMES, VALID_USERNAMES_UNICODE
) )
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.models import PendingEmailChange from student.models import PendingEmailChange
...@@ -450,14 +450,7 @@ class AccountCreationUnicodeUsernameTest(TestCase): ...@@ -450,14 +450,7 @@ class AccountCreationUnicodeUsernameTest(TestCase):
PASSWORD = u'unicode-user-password' PASSWORD = u'unicode-user-password'
EMAIL = u'unicode-user-username@example.com' EMAIL = u'unicode-user-username@example.com'
UNICODE_USERNAMES = [ @ddt.data(*VALID_USERNAMES_UNICODE)
u'Enchanté',
u'username_with_@',
u'username with spaces',
u'eastern_arabic_numbers_١٢٣',
]
@ddt.data(*UNICODE_USERNAMES)
def test_unicode_usernames(self, unicode_username): def test_unicode_usernames(self, unicode_username):
with patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': False}): with patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': False}):
with self.assertRaises(AccountUsernameInvalid): with self.assertRaises(AccountUsernameInvalid):
......
...@@ -10,6 +10,12 @@ from openedx.core.djangoapps.user_api.accounts import ( ...@@ -10,6 +10,12 @@ from openedx.core.djangoapps.user_api.accounts import (
) )
INVALID_NAMES = [
None,
'',
u''
]
INVALID_USERNAMES_ASCII = [ INVALID_USERNAMES_ASCII = [
'$invalid-ascii$', '$invalid-ascii$',
'invalid-fŕáńḱ', 'invalid-fŕáńḱ',
...@@ -52,6 +58,24 @@ INVALID_PASSWORDS = [ ...@@ -52,6 +58,24 @@ INVALID_PASSWORDS = [
u'a' * (PASSWORD_MAX_LENGTH + 1) u'a' * (PASSWORD_MAX_LENGTH + 1)
] ]
INVALID_COUNTRIES = [
None,
"",
"--"
]
VALID_NAMES = [
'Validation Bot',
u'Validation Bot'
]
VALID_USERNAMES_UNICODE = [
u'Enchanté',
u'username_with_@',
u'username with spaces',
u'eastern_arabic_numbers_١٢٣',
]
VALID_USERNAMES = [ VALID_USERNAMES = [
u'username', u'username',
u'a' * USERNAME_MIN_LENGTH, u'a' * USERNAME_MIN_LENGTH,
...@@ -72,3 +96,9 @@ VALID_PASSWORDS = [ ...@@ -72,3 +96,9 @@ VALID_PASSWORDS = [
u'a' * PASSWORD_MIN_LENGTH, u'a' * PASSWORD_MIN_LENGTH,
u'a' * PASSWORD_MAX_LENGTH u'a' * PASSWORD_MAX_LENGTH
] ]
VALID_COUNTRIES = [
u'PK',
u'Pakistan',
u'US'
]
...@@ -58,6 +58,11 @@ class AccountPasswordInvalid(AccountRequestError): ...@@ -58,6 +58,11 @@ class AccountPasswordInvalid(AccountRequestError):
pass pass
class AccountCountryInvalid(AccountRequestError):
"""The requested country does not exist. """
pass
class AccountDataBadLength(AccountRequestError): class AccountDataBadLength(AccountRequestError):
"""The requested account data is either too short or too long. """ """The requested account data is either too short or too long. """
pass pass
......
...@@ -33,7 +33,7 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase ...@@ -33,7 +33,7 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from ..accounts import ( from ..accounts import (
NAME_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH, NAME_MAX_LENGTH, EMAIL_MIN_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH,
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_BAD_LENGTH_MSG
) )
from ..accounts.api import get_account_settings from ..accounts.api import get_account_settings
from ..models import UserOrgTag from ..models import UserOrgTag
...@@ -1198,6 +1198,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): ...@@ -1198,6 +1198,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
{"value": "none", "name": "No formal education", "default": False}, {"value": "none", "name": "No formal education", "default": False},
{"value": "other", "name": "Other education", "default": False}, {"value": "other", "name": "Other education", "default": False},
], ],
"errorMessages": {
"required": "Please select your highest level of education completed."
}
} }
) )
...@@ -1224,6 +1227,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): ...@@ -1224,6 +1227,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
{"value": "none", "name": "No formal education TRANSLATED", "default": False}, {"value": "none", "name": "No formal education TRANSLATED", "default": False},
{"value": "other", "name": "Other education TRANSLATED", "default": False}, {"value": "other", "name": "Other education TRANSLATED", "default": False},
], ],
"errorMessages": {
"required": "Please select your highest level of education completed."
}
} }
) )
...@@ -1301,6 +1307,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): ...@@ -1301,6 +1307,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"type": "textarea", "type": "textarea",
"required": False, "required": False,
"label": "Mailing address", "label": "Mailing address",
"errorMessages": {
"required": "Please enter your mailing address."
}
} }
) )
...@@ -1313,7 +1322,10 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): ...@@ -1313,7 +1322,10 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"required": False, "required": False,
"label": u"Tell us why you're interested in {platform_name}".format( "label": u"Tell us why you're interested in {platform_name}".format(
platform_name=settings.PLATFORM_NAME platform_name=settings.PLATFORM_NAME
) ),
"errorMessages": {
"required": "Please tell us your goals."
}
} }
) )
...@@ -1325,6 +1337,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): ...@@ -1325,6 +1337,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"type": "text", "type": "text",
"required": False, "required": False,
"label": "City", "label": "City",
"errorMessages": {
"required": "Please enter your City."
}
} }
) )
...@@ -1992,8 +2007,8 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): ...@@ -1992,8 +2007,8 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
self.assertEqual( self.assertEqual(
response_json, response_json,
{ {
"username": [{"user_message": "Username must be minimum of two characters long"}], u"username": [{u"user_message": USERNAME_BAD_LENGTH_MSG}],
"password": [{"user_message": "A valid password is required"}], u"password": [{u"user_message": u"A valid password is required"}],
} }
) )
......
...@@ -10,19 +10,8 @@ from django.conf import settings ...@@ -10,19 +10,8 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from openedx.core.djangoapps.user_api.accounts import ( from openedx.core.djangoapps.user_api import accounts
EMAIL_BAD_LENGTH_MSG, EMAIL_INVALID_MSG, from openedx.core.djangoapps.user_api.accounts.tests import testutils
EMAIL_CONFLICT_MSG, EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH,
PASSWORD_BAD_LENGTH_MSG, PASSWORD_CANT_EQUAL_USERNAME_MSG,
PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH,
USERNAME_BAD_LENGTH_MSG, USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE,
USERNAME_CONFLICT_MSG, USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH
)
from openedx.core.djangoapps.user_api.accounts.tests.testutils import (
VALID_EMAILS, VALID_PASSWORDS, VALID_USERNAMES,
INVALID_EMAILS, INVALID_PASSWORDS, INVALID_USERNAMES,
INVALID_USERNAMES_ASCII, INVALID_USERNAMES_UNICODE
)
from openedx.core.lib.api import test_utils from openedx.core.lib.api import test_utils
...@@ -45,16 +34,30 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase): ...@@ -45,16 +34,30 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase):
decision decision
) )
def assertNotValidationDecision(self, data, decision):
self.assertNotEqual(
self.get_validation_decision(data),
decision
)
def test_no_decision_for_empty_request(self): def test_no_decision_for_empty_request(self):
self.assertValidationDecision({}, {}) self.assertValidationDecision(
{},
{}
)
def test_no_decision_for_invalid_request(self): def test_no_decision_for_invalid_request(self):
self.assertValidationDecision({'invalid_field': 'random_user_data'}, {}) self.assertValidationDecision(
{'invalid_field': 'random_user_data'},
{}
)
@ddt.data( @ddt.data(
['email', (email for email in VALID_EMAILS)], ['name', (name for name in testutils.VALID_NAMES)],
['password', (password for password in VALID_PASSWORDS)], ['email', (email for email in testutils.VALID_EMAILS)],
['username', (username for username in VALID_USERNAMES)] ['password', (password for password in testutils.VALID_PASSWORDS)],
['username', (username for username in testutils.VALID_USERNAMES)],
['country', (country for country in testutils.VALID_COUNTRIES)]
) )
@ddt.unpack @ddt.unpack
def test_positive_validation_decision(self, form_field_name, user_data): def test_positive_validation_decision(self, form_field_name, user_data):
...@@ -68,17 +71,19 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase): ...@@ -68,17 +71,19 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase):
@ddt.data( @ddt.data(
# Skip None type for invalidity checks. # Skip None type for invalidity checks.
['email', (email for email in INVALID_EMAILS[1:])], ['name', (name for name in testutils.INVALID_NAMES[1:])],
['password', (password for password in INVALID_PASSWORDS[1:])], ['email', (email for email in testutils.INVALID_EMAILS[1:])],
['username', (username for username in INVALID_USERNAMES[1:])] ['password', (password for password in testutils.INVALID_PASSWORDS[1:])],
['username', (username for username in testutils.INVALID_USERNAMES[1:])],
['country', (country for country in testutils.INVALID_COUNTRIES[1:])]
) )
@ddt.unpack @ddt.unpack
def test_negative_validation_decision(self, form_field_name, user_data): def test_negative_validation_decision(self, form_field_name, user_data):
""" """
Test if {0} as any item in {1} gives a negative validation decision. Test if {0} as any item in {1} gives a negative validation decision.
""" """
self.assertNotEqual( self.assertNotValidationDecision(
self.get_validation_decision({form_field_name: user_data}), {form_field_name: user_data},
{form_field_name: ''} {form_field_name: ''}
) )
...@@ -101,71 +106,91 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase): ...@@ -101,71 +106,91 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase):
'email': email 'email': email
}, },
{ {
"username": USERNAME_CONFLICT_MSG.format(username=user.username) if username == user.username else '', "username": accounts.USERNAME_CONFLICT_MSG.format(
"email": EMAIL_CONFLICT_MSG.format(email_address=user.email) if email == user.email else '' username=user.username
) if username == user.username else '',
"email": accounts.EMAIL_CONFLICT_MSG.format(
email_address=user.email
) if email == user.email else ''
} }
) )
@ddt.data('', ('e' * EMAIL_MAX_LENGTH) + '@email.com') @ddt.data('', ('e' * accounts.EMAIL_MAX_LENGTH) + '@email.com')
def test_email_less_than_min_length_validation_decision(self, email): def test_email_bad_length_validation_decision(self, email):
self.assertValidationDecision( self.assertValidationDecision(
{'email': email}, {'email': email},
{'email': EMAIL_BAD_LENGTH_MSG.format(email=email, min=EMAIL_MIN_LENGTH, max=EMAIL_MAX_LENGTH)} {'email': accounts.EMAIL_BAD_LENGTH_MSG}
) )
def test_email_generically_invalid_validation_decision(self): def test_email_generically_invalid_validation_decision(self):
email = 'email' email = 'email'
self.assertValidationDecision( self.assertValidationDecision(
{'email': email}, {'email': email},
{'email': EMAIL_INVALID_MSG.format(email=email)} {'email': accounts.EMAIL_INVALID_MSG.format(email=email)}
)
def test_confirm_email_matches_email(self):
email = 'user@email.com'
self.assertValidationDecision(
{'email': email, 'confirm_email': email},
{'email': '', 'confirm_email': ''}
)
@ddt.data('', 'users@other.email')
def test_confirm_email_doesnt_equal_email(self, confirm_email):
self.assertValidationDecision(
{'email': 'user@email.com', 'confirm_email': confirm_email},
{'email': '', 'confirm_email': accounts.REQUIRED_FIELD_CONFIRM_EMAIL_MSG}
) )
@ddt.data( @ddt.data(
'u' * (USERNAME_MIN_LENGTH - 1), 'u' * (accounts.USERNAME_MIN_LENGTH - 1),
'u' * (USERNAME_MAX_LENGTH + 1) 'u' * (accounts.USERNAME_MAX_LENGTH + 1)
) )
def test_username_less_than_min_length_validation_decision(self, username): def test_username_bad_length_validation_decision(self, username):
self.assertValidationDecision( self.assertValidationDecision(
{'username': username}, {'username': username},
{ {'username': accounts.USERNAME_BAD_LENGTH_MSG}
'username': USERNAME_BAD_LENGTH_MSG.format(
username=username,
min=USERNAME_MIN_LENGTH,
max=USERNAME_MAX_LENGTH
)
}
) )
@unittest.skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.") @unittest.skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.")
@ddt.data(*INVALID_USERNAMES_UNICODE) @ddt.data(*testutils.INVALID_USERNAMES_UNICODE)
@ddt.unpack
def test_username_invalid_unicode_validation_decision(self, username): def test_username_invalid_unicode_validation_decision(self, username):
self.assertValidationDecision( self.assertValidationDecision(
{'username': username}, {'username': username},
{'username': USERNAME_INVALID_CHARS_UNICODE} {'username': accounts.USERNAME_INVALID_CHARS_UNICODE}
) )
@unittest.skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.") @unittest.skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.")
@ddt.data(*INVALID_USERNAMES_ASCII) @ddt.data(*testutils.INVALID_USERNAMES_ASCII)
@ddt.unpack
def test_username_invalid_ascii_validation_decision(self, username): def test_username_invalid_ascii_validation_decision(self, username):
self.assertValidationDecision( self.assertValidationDecision(
{'username': username}, {'username': username},
{"username": USERNAME_INVALID_CHARS_ASCII} {"username": accounts.USERNAME_INVALID_CHARS_ASCII}
) )
@ddt.data( def test_password_empty_validation_decision(self):
'p' * (PASSWORD_MIN_LENGTH - 1), self.assertValidationDecision(
'p' * (PASSWORD_MAX_LENGTH + 1) {'password': ''},
{"password": accounts.PASSWORD_EMPTY_MSG}
) )
def test_password_less_than_min_length_validation_decision(self, password):
def test_password_bad_min_length_validation_decision(self):
password = 'p' * (accounts.PASSWORD_MIN_LENGTH - 1)
self.assertValidationDecision(
{'password': password},
{"password": accounts.PASSWORD_BAD_MIN_LENGTH_MSG}
)
def test_password_bad_max_length_validation_decision(self):
password = 'p' * (accounts.PASSWORD_MAX_LENGTH + 1)
self.assertValidationDecision( self.assertValidationDecision(
{'password': password}, {'password': password},
{"password": PASSWORD_BAD_LENGTH_MSG.format(min=PASSWORD_MIN_LENGTH, max=PASSWORD_MAX_LENGTH)} {"password": accounts.PASSWORD_BAD_MAX_LENGTH_MSG}
) )
def test_password_equals_username_validation_decision(self): def test_password_equals_username_validation_decision(self):
self.assertValidationDecision( self.assertValidationDecision(
{"username": "somephrase", "password": "somephrase"}, {"username": "somephrase", "password": "somephrase"},
{"username": "", "password": PASSWORD_CANT_EQUAL_USERNAME_MSG} {"username": "", "password": accounts.PASSWORD_CANT_EQUAL_USERNAME_MSG}
) )
...@@ -9,6 +9,9 @@ from rest_framework.views import APIView ...@@ -9,6 +9,9 @@ from rest_framework.views import APIView
from openedx.core.djangoapps.user_api.accounts.api import ( from openedx.core.djangoapps.user_api.accounts.api import (
get_email_validation_error, get_email_validation_error,
get_email_existence_validation_error, get_email_existence_validation_error,
get_confirm_email_validation_error,
get_country_validation_error,
get_name_validation_error,
get_password_validation_error, get_password_validation_error,
get_username_validation_error, get_username_validation_error,
get_username_existence_validation_error get_username_existence_validation_error
...@@ -85,38 +88,66 @@ class RegistrationValidationView(APIView): ...@@ -85,38 +88,66 @@ class RegistrationValidationView(APIView):
**Available Handlers** **Available Handlers**
"name":
A handler to check the validity of the user's real name.
"username": "username":
A handler to check the validity of usernames. A handler to check the validity of usernames.
"email": "email":
A handler to check the validity of emails. A handler to check the validity of emails.
"confirm_email":
A handler to check whether the confirmation email field matches
the email field.
"password": "password":
A handler to check the validity of passwords; a compatibility A handler to check the validity of passwords; a compatibility
decision with the username is made if it exists in the input. decision with the username is made if it exists in the input.
"country":
A handler to check whether the validity of country fields.
""" """
# This end-point is available to anonymous users, so no authentication is needed.
authentication_classes = []
def name_handler(self, request):
name = request.data.get('name')
return get_name_validation_error(name)
def username_handler(self, request): def username_handler(self, request):
username = request.data.get('username') username = request.data.get('username')
invalid_username_error = get_username_validation_error(username) invalid_username_error = get_username_validation_error(username)
username_exists_error = get_username_existence_validation_error(username) username_exists_error = get_username_existence_validation_error(username)
# Existing usernames are already valid, so we prefer that error. # We prefer seeing for invalidity first.
return username_exists_error or invalid_username_error # Some invalid usernames (like for superusers) may exist.
return invalid_username_error or username_exists_error
def email_handler(self, request): def email_handler(self, request):
email = request.data.get('email') email = request.data.get('email')
invalid_email_error = get_email_validation_error(email) invalid_email_error = get_email_validation_error(email)
email_exists_error = get_email_existence_validation_error(email) email_exists_error = get_email_existence_validation_error(email)
# Existing emails are already valid, so we prefer that error. # We prefer seeing for invalidity first.
return email_exists_error or invalid_email_error # Some invalid emails (like a blank one for superusers) may exist.
return invalid_email_error or email_exists_error
def confirm_email_handler(self, request):
email = request.data.get('email', None)
confirm_email = request.data.get('confirm_email')
return get_confirm_email_validation_error(confirm_email, email)
def password_handler(self, request): def password_handler(self, request):
username = request.data.get('username') or None username = request.data.get('username', None)
password = request.data.get('password') password = request.data.get('password')
return get_password_validation_error(password, username) return get_password_validation_error(password, username)
def country_handler(self, request):
country = request.data.get('country')
return get_country_validation_error(country)
validation_handlers = { validation_handlers = {
"name": name_handler,
"username": username_handler, "username": username_handler,
"email": email_handler, "email": email_handler,
"confirm_email": confirm_email_handler,
"password": password_handler, "password": password_handler,
"country": country_handler
} }
def post(self, request): def post(self, request):
...@@ -125,9 +156,12 @@ class RegistrationValidationView(APIView): ...@@ -125,9 +156,12 @@ class RegistrationValidationView(APIView):
Expects request of the form Expects request of the form
>>> { >>> {
>>> "name": "Dan the Validator",
>>> "username": "mslm", >>> "username": "mslm",
>>> "email": "mslm@gmail.com", >>> "email": "mslm@gmail.com",
>>> "password": "password123" >>> "confirm_email": "mslm@gmail.com",
>>> "password": "password123",
>>> "country": "PK"
>>> } >>> }
where each key is the appropriate form field name and the value is where each key is the appropriate form field name and the value is
user input. One may enter individual inputs if needed. Some inputs user input. One may enter individual inputs if needed. Some inputs
......
...@@ -31,15 +31,7 @@ from student.forms import get_registration_extension_form ...@@ -31,15 +31,7 @@ from student.forms import get_registration_extension_form
from student.views import create_account_with_params, AccountValidationError from student.views import create_account_with_params, AccountValidationError
from util.json_request import JsonResponse from util.json_request import JsonResponse
from .accounts import ( import accounts
EMAIL_MAX_LENGTH, EMAIL_MIN_LENGTH,
NAME_MAX_LENGTH,
PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH,
USERNAME_MAX_LENGTH, USERNAME_MIN_LENGTH,
EMAIL_CONFLICT_MSG,
USERNAME_CONFLICT_MSG
)
from .accounts.api import check_account_exists
from .helpers import FormDescription, require_post_params, shim_student_view from .helpers import FormDescription, require_post_params, shim_student_view
from .models import UserPreference, UserProfile from .models import UserPreference, UserProfile
from .preferences.api import get_country_time_zones, update_email_opt_in from .preferences.api import get_country_time_zones, update_email_opt_in
...@@ -91,8 +83,8 @@ class LoginSessionView(APIView): ...@@ -91,8 +83,8 @@ class LoginSessionView(APIView):
placeholder=email_placeholder, placeholder=email_placeholder,
instructions=email_instructions, instructions=email_instructions,
restrictions={ restrictions={
"min_length": EMAIL_MIN_LENGTH, "min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH, "max_length": accounts.EMAIL_MAX_LENGTH,
} }
) )
...@@ -105,8 +97,8 @@ class LoginSessionView(APIView): ...@@ -105,8 +97,8 @@ class LoginSessionView(APIView):
label=password_label, label=password_label,
field_type="password", field_type="password",
restrictions={ restrictions={
"min_length": PASSWORD_MIN_LENGTH, "min_length": accounts.PASSWORD_MIN_LENGTH,
"max_length": PASSWORD_MAX_LENGTH, "max_length": accounts.PASSWORD_MAX_LENGTH,
} }
) )
...@@ -336,11 +328,11 @@ class RegistrationView(APIView): ...@@ -336,11 +328,11 @@ class RegistrationView(APIView):
username = data.get('username') username = data.get('username')
# Handle duplicate email/username # Handle duplicate email/username
conflicts = check_account_exists(email=email, username=username) conflicts = accounts.api.check_account_exists(email=email, username=username)
if conflicts: if conflicts:
conflict_messages = { conflict_messages = {
"email": EMAIL_CONFLICT_MSG.format(email_address=email), "email": accounts.EMAIL_CONFLICT_MSG.format(email_address=email),
"username": USERNAME_CONFLICT_MSG.format(username=username), "username": accounts.USERNAME_CONFLICT_MSG.format(username=username),
} }
errors = { errors = {
field: [{"user_message": conflict_messages[field]}] field: [{"user_message": conflict_messages[field]}]
...@@ -414,8 +406,8 @@ class RegistrationView(APIView): ...@@ -414,8 +406,8 @@ class RegistrationView(APIView):
placeholder=email_placeholder, placeholder=email_placeholder,
instructions=email_instructions, instructions=email_instructions,
restrictions={ restrictions={
"min_length": EMAIL_MIN_LENGTH, "min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH, "max_length": accounts.EMAIL_MAX_LENGTH,
}, },
required=required required=required
) )
...@@ -433,7 +425,7 @@ class RegistrationView(APIView): ...@@ -433,7 +425,7 @@ class RegistrationView(APIView):
# Translators: This label appears above a field on the registration form # Translators: This label appears above a field on the registration form
# meant to confirm the user's email address. # meant to confirm the user's email address.
email_label = _(u"Confirm Email") email_label = _(u"Confirm Email")
error_msg = _(u"The email addresses do not match.") error_msg = accounts.REQUIRED_FIELD_CONFIRM_EMAIL_MSG
form_desc.add_field( form_desc.add_field(
"confirm_email", "confirm_email",
...@@ -472,7 +464,7 @@ class RegistrationView(APIView): ...@@ -472,7 +464,7 @@ class RegistrationView(APIView):
placeholder=name_placeholder, placeholder=name_placeholder,
instructions=name_instructions, instructions=name_instructions,
restrictions={ restrictions={
"max_length": NAME_MAX_LENGTH, "max_length": accounts.NAME_MAX_LENGTH,
}, },
required=required required=required
) )
...@@ -508,8 +500,8 @@ class RegistrationView(APIView): ...@@ -508,8 +500,8 @@ class RegistrationView(APIView):
instructions=username_instructions, instructions=username_instructions,
placeholder=username_placeholder, placeholder=username_placeholder,
restrictions={ restrictions={
"min_length": USERNAME_MIN_LENGTH, "min_length": accounts.USERNAME_MIN_LENGTH,
"max_length": USERNAME_MAX_LENGTH, "max_length": accounts.USERNAME_MAX_LENGTH,
}, },
required=required required=required
) )
...@@ -533,8 +525,8 @@ class RegistrationView(APIView): ...@@ -533,8 +525,8 @@ class RegistrationView(APIView):
label=password_label, label=password_label,
field_type="password", field_type="password",
restrictions={ restrictions={
"min_length": PASSWORD_MIN_LENGTH, "min_length": accounts.PASSWORD_MIN_LENGTH,
"max_length": PASSWORD_MAX_LENGTH, "max_length": accounts.PASSWORD_MAX_LENGTH,
}, },
required=required required=required
) )
...@@ -552,6 +544,7 @@ class RegistrationView(APIView): ...@@ -552,6 +544,7 @@ class RegistrationView(APIView):
# Translators: This label appears above a dropdown menu on the registration # Translators: This label appears above a dropdown menu on the registration
# form used to select the user's highest completed level of education. # form used to select the user's highest completed level of education.
education_level_label = _(u"Highest level of education completed") education_level_label = _(u"Highest level of education completed")
error_msg = accounts.REQUIRED_FIELD_LEVEL_OF_EDUCATION_MSG
# The labels are marked for translation in UserProfile model definition. # The labels are marked for translation in UserProfile model definition.
options = [(name, _(label)) for name, label in UserProfile.LEVEL_OF_EDUCATION_CHOICES] # pylint: disable=translation-of-non-string options = [(name, _(label)) for name, label in UserProfile.LEVEL_OF_EDUCATION_CHOICES] # pylint: disable=translation-of-non-string
...@@ -561,7 +554,10 @@ class RegistrationView(APIView): ...@@ -561,7 +554,10 @@ class RegistrationView(APIView):
field_type="select", field_type="select",
options=options, options=options,
include_default_option=True, include_default_option=True,
required=required required=required,
error_messages={
"required": error_msg
}
) )
def _add_gender_field(self, form_desc, required=True): def _add_gender_field(self, form_desc, required=True):
...@@ -626,12 +622,16 @@ class RegistrationView(APIView): ...@@ -626,12 +622,16 @@ class RegistrationView(APIView):
# Translators: This label appears above a field on the registration form # Translators: This label appears above a field on the registration form
# meant to hold the user's mailing address. # meant to hold the user's mailing address.
mailing_address_label = _(u"Mailing address") mailing_address_label = _(u"Mailing address")
error_msg = accounts.REQUIRED_FIELD_MAILING_ADDRESS_MSG
form_desc.add_field( form_desc.add_field(
"mailing_address", "mailing_address",
label=mailing_address_label, label=mailing_address_label,
field_type="textarea", field_type="textarea",
required=required required=required,
error_messages={
"required": error_msg
}
) )
def _add_goals_field(self, form_desc, required=True): def _add_goals_field(self, form_desc, required=True):
...@@ -649,12 +649,16 @@ class RegistrationView(APIView): ...@@ -649,12 +649,16 @@ class RegistrationView(APIView):
goals_label = _(u"Tell us why you're interested in {platform_name}").format( goals_label = _(u"Tell us why you're interested in {platform_name}").format(
platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME) platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME)
) )
error_msg = accounts.REQUIRED_FIELD_GOALS_MSG
form_desc.add_field( form_desc.add_field(
"goals", "goals",
label=goals_label, label=goals_label,
field_type="textarea", field_type="textarea",
required=required required=required,
error_messages={
"required": error_msg
}
) )
def _add_city_field(self, form_desc, required=True): def _add_city_field(self, form_desc, required=True):
...@@ -670,11 +674,15 @@ class RegistrationView(APIView): ...@@ -670,11 +674,15 @@ class RegistrationView(APIView):
# Translators: This label appears above a field on the registration form # Translators: This label appears above a field on the registration form
# which allows the user to input the city in which they live. # which allows the user to input the city in which they live.
city_label = _(u"City") city_label = _(u"City")
error_msg = accounts.REQUIRED_FIELD_CITY_MSG
form_desc.add_field( form_desc.add_field(
"city", "city",
label=city_label, label=city_label,
required=required required=required,
error_messages={
"required": error_msg
}
) )
def _add_state_field(self, form_desc, required=False): def _add_state_field(self, form_desc, required=False):
...@@ -790,7 +798,7 @@ class RegistrationView(APIView): ...@@ -790,7 +798,7 @@ class RegistrationView(APIView):
# Translators: This label appears above a dropdown menu on the registration # Translators: This label appears above a dropdown menu on the registration
# form used to select the country in which the user lives. # form used to select the country in which the user lives.
country_label = _(u"Country") country_label = _(u"Country")
error_msg = _(u"Please select your Country.") error_msg = accounts.REQUIRED_FIELD_COUNTRY_MSG
# If we set a country code, make sure it's uppercase for the sake of the form. # If we set a country code, make sure it's uppercase for the sake of the form.
default_country = form_desc._field_overrides.get('country', {}).get('defaultValue') default_country = form_desc._field_overrides.get('country', {}).get('defaultValue')
...@@ -1025,8 +1033,8 @@ class PasswordResetView(APIView): ...@@ -1025,8 +1033,8 @@ class PasswordResetView(APIView):
placeholder=email_placeholder, placeholder=email_placeholder,
instructions=email_instructions, instructions=email_instructions,
restrictions={ restrictions={
"min_length": EMAIL_MIN_LENGTH, "min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH, "max_length": accounts.EMAIL_MAX_LENGTH,
} }
) )
...@@ -1094,7 +1102,9 @@ class PreferenceUsersListView(generics.ListAPIView): ...@@ -1094,7 +1102,9 @@ class PreferenceUsersListView(generics.ListAPIView):
paginate_by_param = "page_size" paginate_by_param = "page_size"
def get_queryset(self): def get_queryset(self):
return User.objects.filter(preferences__key=self.kwargs["pref_key"]).prefetch_related("preferences").select_related("profile") return User.objects.filter(
preferences__key=self.kwargs["pref_key"]
).prefetch_related("preferences").select_related("profile")
class UpdateEmailOptInPreference(APIView): class UpdateEmailOptInPreference(APIView):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment