Commit 4c41b777 by Uman Shahzad Committed by GitHub

Merge pull request #15375 from open-craft/uman/ent-334

[ENT-334] Add client-side registration form validation.
parents 78708e41 4b47d4af
......@@ -23,18 +23,6 @@ from student.models import CourseEnrollmentAllowed
from util.password_policy_validators import validate_password_strength
USERNAME_TOO_SHORT_MSG = _("Username must be minimum of two characters long")
USERNAME_TOO_LONG_MSG = _("Username cannot be more than %(limit_value)s characters long")
# Translators: This message is shown when the Unicode usernames are NOT allowed
USERNAME_INVALID_CHARS_ASCII = _("Usernames can only contain Roman letters, western numerals (0-9), "
"underscores (_), and hyphens (-).")
# Translators: This message is shown only when the Unicode usernames are allowed
USERNAME_INVALID_CHARS_UNICODE = _("Usernames can only contain letters, numerals, underscore (_), numbers "
"and @/./+/-/_ characters.")
class PasswordResetFormNoActive(PasswordResetForm):
error_messages = {
'unknown': _("That e-mail address doesn't have an associated "
......@@ -127,12 +115,12 @@ def validate_username(username):
username_re = slug_re
flags = None
message = USERNAME_INVALID_CHARS_ASCII
message = accounts_settings.USERNAME_INVALID_CHARS_ASCII
if settings.FEATURES.get("ENABLE_UNICODE_USERNAME"):
username_re = r"^{regex}$".format(regex=settings.USERNAME_REGEX_PARTIAL)
flags = re.UNICODE
message = USERNAME_INVALID_CHARS_UNICODE
message = accounts_settings.USERNAME_INVALID_CHARS_UNICODE
validator = RegexValidator(
regex=username_re,
......@@ -156,9 +144,9 @@ class UsernameField(forms.CharField):
min_length=accounts_settings.USERNAME_MIN_LENGTH,
max_length=accounts_settings.USERNAME_MAX_LENGTH,
error_messages={
"required": USERNAME_TOO_SHORT_MSG,
"min_length": USERNAME_TOO_SHORT_MSG,
"max_length": USERNAME_TOO_LONG_MSG,
"required": accounts_settings.USERNAME_BAD_LENGTH_MSG,
"min_length": accounts_settings.USERNAME_BAD_LENGTH_MSG,
"max_length": accounts_settings.USERNAME_BAD_LENGTH_MSG,
}
)
......
......@@ -22,8 +22,10 @@ from notification_prefs import NOTIFICATION_PREF_KEY
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
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 student.forms import USERNAME_INVALID_CHARS_ASCII, USERNAME_INVALID_CHARS_UNICODE
from student.models import UserAttribute
from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_CREATED_AT, REGISTRATION_UTM_PARAMETERS
......@@ -476,16 +478,16 @@ class TestCreateAccountValidation(TestCase):
# Missing
del params["username"]
assert_username_error("Username must be minimum of two characters long")
assert_username_error(USERNAME_BAD_LENGTH_MSG)
# Empty, too short
for username in ["", "a"]:
params["username"] = username
assert_username_error("Username must be minimum of two characters long")
assert_username_error(USERNAME_BAD_LENGTH_MSG)
# Too long
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
params["username"] = "invalid username"
......
......@@ -5,6 +5,8 @@ import json
from django.core.urlresolvers import reverse
from django.test import TestCase
from openedx.core.djangoapps.user_api.accounts import USERNAME_BAD_LENGTH_MSG
class TestLongUsernameEmail(TestCase):
......@@ -34,7 +36,7 @@ class TestLongUsernameEmail(TestCase):
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Username cannot be more than 30 characters long",
USERNAME_BAD_LENGTH_MSG,
)
def test_long_email(self):
......
......@@ -21,7 +21,7 @@
var _fn = {
validate: {
template: _.template('<li><%= content %></li>'),
template: _.template('<li><%- content %></li>'),
msg: {
email: gettext("The email address you've provided isn't formatted correctly."),
......@@ -107,7 +107,7 @@
regex: new RegExp(
[
'(^[-!#$%&\'*+/=?^_`{}|~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}\\.?$)',
'|\\[(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'
......@@ -124,7 +124,7 @@
getLabel: function(id) {
// 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) {
......@@ -132,16 +132,21 @@
label,
context,
content,
customMsg;
customMsg,
liveValidationMsg;
_.each(tests, function(value, key) {
if (!value) {
label = _fn.validate.getLabel($el.attr('id'));
customMsg = $el.data('errormsg-' + key) || false;
liveValidationMsg =
$('#' + $el.attr('id') + '-validation-error-msg').text() || false;
// If the field has a custom error msg attached, use it
if (customMsg) {
content = customMsg;
} else if (liveValidationMsg) {
content = liveValidationMsg;
} else {
context = {field: label};
......@@ -154,7 +159,9 @@
content = _.sprintf(_fn.validate.msg[key], context);
}
txt.push(_fn.validate.template({content: content}));
txt.push(_fn.validate.template({
content: content
}));
}
});
......@@ -173,7 +180,7 @@
return {
validate: _fn.validate.field
};
})();
}());
return utils;
});
......
......@@ -344,10 +344,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
# Verify that the expected errors are displayed.
errors = self.register_page.wait_for_errors()
self.assertIn(u'Please enter your Public Username.', errors)
self.assertIn(
u'You must agree to the édX Terms of Service and Honor Code',
errors
)
self.assertIn(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 tell us your favorite movie.', errors)
......
......@@ -32,8 +32,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2017-08-01 21:22+0000\n"
"PO-Revision-Date: 2017-08-01 21:22:49.685108\n"
"POT-Creation-Date: 2017-08-02 12:48+0000\n"
"PO-Revision-Date: 2017-08-02 12:48:04.261143\n"
"Last-Translator: \n"
"Language-Team: openedx-translation <openedx-translation@googlegroups.com>\n"
"MIME-Version: 1.0\n"
......@@ -331,31 +331,6 @@ msgid "User profile"
msgstr ""
#: 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 ""
"That e-mail address doesn't have an associated user account. Are you sure "
"you've registered?"
......@@ -9307,6 +9282,101 @@ msgstr ""
msgid "Theming Administration"
msgstr ""
#: 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
msgid "\"{email}\" is not a valid email address."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
#, python-brace-format
msgid ""
"It looks like {email_address} belongs to an existing account. Try again with"
" a different email address."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
#, python-brace-format
msgid ""
"It looks like {username} belongs to an existing account. Try again with a "
"different username."
msgstr ""
#. Translators: This message is shown to users who enter a
#. username/email/password
#. 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 ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Please enter your City."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Please tell us your goals."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Please select your highest level of education completed."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/__init__.py
msgid "Please enter your mailing address."
msgstr ""
#: openedx/core/djangoapps/user_api/accounts/api.py
#, python-brace-format
msgid "The '{field_name}' field cannot be edited."
......@@ -9392,24 +9462,6 @@ msgstr ""
msgid "Remember me"
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,
#. immediately
#. below a field meant to hold the user's email address.
......@@ -9423,10 +9475,6 @@ msgstr ""
msgid "Confirm Email"
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
#. a field on the registration form meant to hold the user's name.
#: openedx/core/djangoapps/user_api/views.py
......@@ -9506,10 +9554,6 @@ msgid "Company"
msgstr ""
#: 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"
msgstr ""
......@@ -19611,6 +19655,10 @@ msgid ""
msgstr ""
#: cms/templates/index.html
msgid "Archived Courses"
msgstr ""
#: cms/templates/index.html
msgid "Libraries"
msgstr ""
......@@ -21665,7 +21713,7 @@ msgid "Your changes were saved."
msgstr ""
#: wiki/views/article.py
msgid "A new revision of the article was succesfully added."
msgid "A new revision of the article was successfully added."
msgstr ""
#: wiki/views/article.py
......
......@@ -26,8 +26,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2017-08-01 21:22+0000\n"
"PO-Revision-Date: 2017-08-01 21:22:50.339993\n"
"POT-Creation-Date: 2017-08-02 12:47+0000\n"
"PO-Revision-Date: 2017-08-02 12:48:04.567443\n"
"Last-Translator: \n"
"Language-Team: openedx-translation <openedx-translation@googlegroups.com>\n"
"MIME-Version: 1.0\n"
......@@ -4219,6 +4219,10 @@ msgid "We couldn't create your account."
msgstr ""
#: 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."
msgstr ""
......
......@@ -36,7 +36,6 @@ from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factor
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme_context
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH
from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
from openedx.core.djangolib.js_utils import dump_js_escaped_json
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
......@@ -62,24 +61,6 @@ class StudentAccountUpdateTest(CacheIsolationTestCase, UrlResetMixin):
NEW_EMAIL = u"walt@savewalterwhite.com"
INVALID_ATTEMPTS = 100
INVALID_EMAILS = [
None,
u"",
u"a",
"no_domain",
"no+domain",
"@",
"@domain.com",
"test@no_extension",
# Long email -- subtract the length of the @domain
# except for one character (so we exceed the max length limit)
u"{user}@example.com".format(
user=(u'e' * (EMAIL_MAX_LENGTH - 11))
)
]
INVALID_KEY = u"123abc"
URLCONF_MODULES = ['student_accounts.urls']
......
......@@ -79,6 +79,7 @@
var buildIframe = function(link, modalSelector, contentSelector, tosLinkSelector) {
// Create an iframe with contents from the link and set its height to match the content area
return $('<iframe>', {
title: 'Terms of Service and Honor Code',
src: link.href,
load: function() {
var $iframeHead = $(this).contents().find('head'),
......
......@@ -6,43 +6,30 @@
'backbone',
'common/js/utils/edx.utils.validate',
'edx-ui-toolkit/js/utils/html-utils',
'edx-ui-toolkit/js/utils/string-utils',
'text!templates/student_account/form_errors.underscore'
],
function($, _, Backbone, EdxUtilsValidate, HtmlUtils, formErrorsTpl) {
], function($, _, Backbone, EdxUtilsValidate, HtmlUtils, StringUtils, formErrorsTpl) {
return Backbone.View.extend({
tagName: 'form',
el: '',
tpl: '',
fieldTpl: '#form_field-tpl',
formErrorsTpl: formErrorsTpl,
formErrorsJsHook: 'js-form-errors',
defaultFormErrorsTitle: gettext('An error occurred.'),
events: {},
errors: [],
formType: '',
$form: {},
fields: [],
liveValidationFields: [],
// String to append to required label fields
requiredStr: '',
/*
Translators: This string is appended to optional field labels on the student login, registration, and
profile forms.
Translators: This string is appended to optional field labels on the student login, registration, and
profile forms.
*/
optionalStr: gettext('(optional)'),
submitButton: '',
initialize: function(data) {
......@@ -157,7 +144,7 @@
$label,
key = '',
errors = [],
test = {};
validation = {};
for (i = 0; i < len; i++) {
$el = $(elements[i]);
......@@ -171,13 +158,13 @@
}
if (key) {
test = this.validate(elements[i]);
if (test.isValid) {
validation = this.validate(elements[i]);
if (validation.isValid) {
obj[key] = $el.attr('type') === 'checkbox' ? $el.is(':checked') : $el.val();
$el.removeClass('error');
$label.removeClass('error');
} else {
errors.push(test.message);
errors.push(validation.message);
$el.addClass('error');
$label.addClass('error');
}
......@@ -190,8 +177,15 @@
},
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.scrollToFormFeedback();
this.toggleDisableButton(false);
},
......@@ -200,7 +194,6 @@
*/
renderErrors: function(title, errorMessages) {
this.clearFormErrors();
this.renderFormFeedback(this.formErrorsTpl, {
jsHook: this.formErrorsJsHook,
title: title,
......@@ -211,14 +204,6 @@
renderFormFeedback: function(template, context) {
var tpl = HtmlUtils.template(template);
HtmlUtils.prepend(this.$formFeedback, tpl(context));
// 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();
},
/* Allows extended views to add non-form attributes
......@@ -244,6 +229,7 @@
this.clearFormErrors();
} else {
this.renderErrors(this.defaultFormErrorsTitle, this.errors);
this.scrollToFormFeedback();
this.toggleDisableButton(false);
}
......@@ -257,6 +243,10 @@
return true;
},
resetValidationVariables: function() {
return true;
},
clearFormErrors: function() {
var query = '.' + this.formErrorsJsHook;
this.clearFormFeedbackItems(query);
......@@ -283,8 +273,44 @@
}
},
scrollToFormFeedback: function() {
var self = this;
// Scroll to feedback container
$('html,body').animate({
scrollTop: this.$formFeedback.offset().top
}, 'slow', function() {
self.resetValidationVariables();
});
// Focus on the feedback container to ensure screen readers see the messages.
this.$formFeedback.focus();
},
validate: function($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;
}
});
});
......
......@@ -296,10 +296,6 @@
display: inline;
}
&.error {
color: $red;
}
&[for="register-data_sharing_consent"],
&[for="register-honor_code"],
&[for="register-terms_of_service"] {
......@@ -365,7 +361,22 @@
}
&.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 @@
&:active, &:focus {
outline: auto;
}
}
span,
label {
&.error {
outline-color: $error-color;
color: $red;
}
&.success {
color: $success-color-hover;
}
}
......@@ -394,6 +412,7 @@
@extend %t-copy-sub1;
color: $uxpl-gray-base;
}
.tip {
display: block;
}
......
<div class="form-field <%=type%>-<%= name %>">
<div class="form-field <%- type %>-<%- name %>">
<% if ( type !== 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>">
<span class="label-text"><%= label %></span>
<% if ( required && requiredStr && (type !== 'hidden') ) { %><span class="label-required"><%= requiredStr %></span><% } %>
<% if ( !required && optionalStr && (type !== 'hidden') ) { %><span class="label-optional"><%= optionalStr %></span><% } %>
<label for="<%- form %>-<%- name %>">
<span class="label-text"><%- label %></span>
<% if ( required && type !== 'hidden' ) { %>
<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>
<% if (supplementalLink && supplementalText) { %>
<div class="supplemental-link">
......@@ -13,50 +21,59 @@
<% } %>
<% if ( type === 'select' ) { %>
<select id="<%= form %>-<%= name %>"
name="<%= name %>"
<select id="<%- form %>-<%- name %>"
name="<%- name %>"
class="input-inline"
<% if ( instructions ) { %>
aria-describedby="<%= form %>-<%= name %>-desc"
aria-describedby="<%- form %>-<%- name %>-desc <%- form %>-<%- name %>-validation-error"
<% } %>
<% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>"
data-errormsg-<%- type %>="<%- msg %>"
<% });
} %>
<% if ( required ) { %> aria-required="true" required<% } %>>
<% _.each(options, function(el) { %>
<option value="<%= el.value%>"<% if ( el.default ) { %> data-isdefault="true" selected<% } %>><%= el.name %></option>
<% }); %>
<% if ( required ) { %> aria-required="true" required<% } %>
>
<% _.each(options, function(el) { %>
<option value="<%- el.value%>"<% if ( el.default ) { %> data-isdefault="true" selected<% } %>><%- el.name %></option>
<% }); %>
</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) { %>
<div class="supplemental-link">
<a href="<%- supplementalLink %>" target="_blank"><%- supplementalText %></a>
</div>
<% } %>
<% } else if ( type === 'textarea' ) { %>
<textarea id="<%= form %>-<%= name %>"
type="<%= type %>"
name="<%= name %>"
<textarea id="<%- form %>-<%- name %>"
type="<%- type %>"
name="<%- name %>"
class="input-block"
<% 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.max_length ) { %> maxlength="<%= restrictions.max_length %>"<% } %>
<% if ( restrictions.min_length ) { %> minlength="<%- restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%- restrictions.max_length %>"<% } %>
<% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>"
data-errormsg-<%- type %>="<%- msg %>"
<% });
} %>
<% if ( required ) { %> aria-required="true" required<% } %> ></textarea>
<% if ( instructions ) { %> <span class="tip tip-input" id="<%= form %>-<%= name %>-desc"><%= instructions %></span><% } %>
<% if (supplementalLink && supplementalText) { %>
<div class="supplemental-link">
<a href="<%- supplementalLink %>" target="_blank"><%- supplementalText %></a>
</div>
<% } %>
<% if ( required ) { %> aria-required="true" required<% } %>></textarea>
<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) { %>
<div class="supplemental-link">
<a href="<%- supplementalLink %>" target="_blank"><%- supplementalText %></a>
</div>
<% } %>
<% } else { %>
<% if ( type === 'checkbox' ) { %>
<% if (supplementalLink && supplementalText) { %>
......@@ -65,30 +82,44 @@
</div>
<% } %>
<% } %>
<input id="<%= form %>-<%= name %>"
type="<%= type %>"
name="<%= name %>"
<input id="<%- form %>-<%- name %>"
type="<%- type %>"
name="<%- name %>"
class="input-block <% if ( type === 'checkbox' ) { %>checkbox<% } %>"
<% if ( instructions ) { %> aria-describedby="<%= form %>-<%= name %>-desc" <% } %>
<% if ( restrictions.min_length ) { %> minlength="<%= restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%= restrictions.max_length %>"<% } %>
<% if ( instructions ) { %>
aria-describedby="<%- form %>-<%- name %>-desc <%- form %>-<%- name %>-validation-error"
<% } %>
<% if ( restrictions.min_length ) { %> minlength="<%- restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%- restrictions.max_length %>"<% } %>
<% if ( required ) { %> required<% } %>
<% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>"
data-errormsg-<%- type %>="<%- msg %>"
<% });
} %>
<% if ( placeholder ) { %> placeholder="<%= placeholder %>"<% } %>
<% if ( placeholder ) { %> placeholder="<%- placeholder %>"<% } %>
value="<%- defaultValue %>"
/>
<% if ( type === 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>">
<span class="label-text"><%= label %></span>
<% if ( required && requiredStr ) { %><span class="label-required"><%= requiredStr %></span><% } %>
<% if ( !required && optionalStr ) { %><span class="label-optional"><%= optionalStr %></span><% } %>
<label for="<%- form %>-<%- name %>">
<span class="label-text"><%- label %></span>
<% if ( required && type !== 'hidden' ) { %>
<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>
<% } %>
<% 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' ) { %>
......
......@@ -2,6 +2,9 @@
Account constants
"""
from django.utils.translation import ugettext as _
# The minimum and maximum length for the name ("full name") account field
NAME_MIN_LENGTH = 2
NAME_MAX_LENGTH = 255
......@@ -25,3 +28,63 @@ ALL_USERS_VISIBILITY = 'all_users'
# Indicates the user's preference that all their account information be private.
PRIVATE_VISIBILITY = 'private'
# Translators: This message is shown when the Unicode usernames are NOT allowed.
# It is shown to users who attempt to create a new account using invalid characters
# in the username.
USERNAME_INVALID_CHARS_ASCII = _(
u"Usernames can only contain letters (A-Z, a-z), numerals (0-9), underscores (_), and hyphens (-)."
)
# Translators: This message is shown only when the Unicode usernames are allowed.
# It is shown to users who attempt to create a new account using invalid characters
# in the username.
USERNAME_INVALID_CHARS_UNICODE = _(
u"Usernames can only contain letters, numerals, and @/./+/-/_ characters."
)
# Translators: This message is shown to users who attempt to create a new account using
# an invalid email format.
EMAIL_INVALID_MSG = _(u'"{email}" is not a valid email address.')
# Translators: This message is shown to users who attempt to create a new
# account using an username/email associated with an existing account.
EMAIL_CONFLICT_MSG = _(
u"It looks like {email_address} belongs to an existing account. "
u"Try again with a different email address."
)
USERNAME_CONFLICT_MSG = _(
u"It looks like {username} belongs to an existing account. "
u"Try again with a different username."
)
# Translators: This message is shown to users who enter a username/email/password
# with an inappropriate length (too short or too long).
USERNAME_BAD_LENGTH_MSG = _(u"Username must be between {min} and {max} characters long.").format(
min=USERNAME_MIN_LENGTH, max=USERNAME_MAX_LENGTH
)
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.
USERNAME_BAD_TYPE_MSG = u"Username must be a string."
EMAIL_BAD_TYPE_MSG = u"Email 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
# the username they enter(ed).
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.")
......@@ -3,6 +3,7 @@
Unit tests for behavior that is specific to the api methods (vs. the view methods).
Most of the functionality is covered in test_views.py.
"""
import re
import ddt
from dateutil.parser import parse as parse_datetime
......@@ -17,17 +18,29 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.core import mail
from django.test.client import RequestFactory
from openedx.core.djangoapps.user_api.accounts import (
USERNAME_MAX_LENGTH,
PRIVATE_VISIBILITY
)
from openedx.core.djangoapps.user_api.accounts.api import (
get_account_settings,
update_account_settings,
create_account,
activate_account,
request_password_change
)
from openedx.core.djangoapps.user_api.errors import (
UserNotFound, UserNotAuthorized,
AccountUpdateError, AccountValidationError, AccountUserAlreadyExists,
AccountUsernameInvalid, AccountEmailInvalid, AccountPasswordInvalid,
AccountRequestError
)
from openedx.core.djangoapps.user_api.accounts.tests.testutils import (
INVALID_EMAILS, INVALID_PASSWORDS, INVALID_USERNAMES, VALID_USERNAMES_UNICODE
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.models import PendingEmailChange
from student.tests.tests import UserSettingsEventTestMixin
from ...errors import (
UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError,
AccountUserAlreadyExists, AccountUsernameInvalid, AccountEmailInvalid, AccountPasswordInvalid, AccountRequestError
)
from ..api import (
get_account_settings, update_account_settings, create_account, activate_account, request_password_change
)
from .. import USERNAME_MAX_LENGTH, EMAIL_MAX_LENGTH, PASSWORD_MAX_LENGTH, PRIVATE_VISIBILITY
def mock_render_to_string(template_name, context):
......@@ -310,40 +323,6 @@ class AccountCreationActivationAndPasswordChangeTest(TestCase):
ORIG_HOST = 'example.com'
IS_SECURE = False
INVALID_USERNAMES = [
None,
u'',
u'a',
u'a' * (USERNAME_MAX_LENGTH + 1),
u'invalid_symbol_@',
u'invalid-unicode_fŕáńḱ',
]
INVALID_EMAILS = [
None,
u'',
u'a',
'no_domain',
'no+domain',
'@',
'@domain.com',
'test@no_extension',
u'fŕáńḱ@example.com',
# Long email -- subtract the length of the @domain
# except for one character (so we exceed the max length limit)
u'{user}@example.com'.format(
user=(u'e' * (EMAIL_MAX_LENGTH - 11))
)
]
INVALID_PASSWORDS = [
None,
u'',
u'a',
u'a' * (PASSWORD_MAX_LENGTH + 1)
]
@skip_unless_lms
def test_activate_account(self):
# Create the account, which is initially inactive
......@@ -471,14 +450,7 @@ class AccountCreationUnicodeUsernameTest(TestCase):
PASSWORD = u'unicode-user-password'
EMAIL = u'unicode-user-username@example.com'
UNICODE_USERNAMES = [
u'Enchanté',
u'username_with_@',
u'username with spaces',
u'eastern_arabic_numbers_١٢٣',
]
@ddt.data(*UNICODE_USERNAMES)
@ddt.data(*VALID_USERNAMES_UNICODE)
def test_unicode_usernames(self, unicode_username):
with patch.dict(settings.FEATURES, {'ENABLE_UNICODE_USERNAME': False}):
with self.assertRaises(AccountUsernameInvalid):
......
# -*- coding: utf-8 -*-
"""
Utility functions, constants, etc. for testing.
"""
from openedx.core.djangoapps.user_api.accounts import (
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH,
EMAIL_MAX_LENGTH,
PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH
)
INVALID_NAMES = [
None,
'',
u''
]
INVALID_USERNAMES_ASCII = [
'$invalid-ascii$',
'invalid-fŕáńḱ',
'@invalid-ascii@'
]
INVALID_USERNAMES_UNICODE = [
u'invalid-unicode_fŕáńḱ',
]
INVALID_USERNAMES = [
None,
u'',
u'a',
u'a' * (USERNAME_MAX_LENGTH + 1),
] + INVALID_USERNAMES_ASCII + INVALID_USERNAMES_UNICODE
INVALID_EMAILS = [
None,
u'',
u'a',
'no_domain',
'no+domain',
'@',
'@domain.com',
'test@no_extension',
u'fŕáńḱ@example.com',
# Long email -- subtract the length of the @domain
# except for one character (so we exceed the max length limit)
u'{user}@example.com'.format(
user=(u'e' * (EMAIL_MAX_LENGTH - 11))
)
]
INVALID_PASSWORDS = [
None,
u'',
u'a',
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 = [
u'username',
u'a' * USERNAME_MIN_LENGTH,
u'a' * USERNAME_MAX_LENGTH,
u'-' * USERNAME_MIN_LENGTH,
u'-' * USERNAME_MAX_LENGTH,
u'_username_',
u'-username-',
u'-_username_-'
]
VALID_EMAILS = [
'has@domain.com'
]
VALID_PASSWORDS = [
u'password', # :)
u'a' * PASSWORD_MIN_LENGTH,
u'a' * PASSWORD_MAX_LENGTH
]
VALID_COUNTRIES = [
u'PK',
u'Pakistan',
u'US'
]
......@@ -33,6 +33,16 @@ class AccountUserAlreadyExists(AccountRequestError):
pass
class AccountUsernameAlreadyExists(AccountRequestError):
"""User with the same username already exists. """
pass
class AccountEmailAlreadyExists(AccountRequestError):
"""User with the same email already exists. """
pass
class AccountUsernameInvalid(AccountRequestError):
"""The requested username is not in a valid format. """
pass
......@@ -48,6 +58,21 @@ class AccountPasswordInvalid(AccountRequestError):
pass
class AccountCountryInvalid(AccountRequestError):
"""The requested country does not exist. """
pass
class AccountDataBadLength(AccountRequestError):
"""The requested account data is either too short or too long. """
pass
class AccountDataBadType(AccountRequestError):
"""The requested account data is of the wrong type. """
pass
class AccountUpdateError(AccountRequestError):
"""
An update to the account failed. More detailed information is present in developer_message,
......
......@@ -31,10 +31,9 @@ from third_party_auth.tests.utils import (
from .test_helpers import TestCaseForm
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from ..helpers import FormDescription
from ..accounts import (
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 ..models import UserOrgTag
......@@ -1199,6 +1198,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
{"value": "none", "name": "No formal education", "default": False},
{"value": "other", "name": "Other education", "default": False},
],
"errorMessages": {
"required": "Please select your highest level of education completed."
}
}
)
......@@ -1225,6 +1227,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
{"value": "none", "name": "No formal education TRANSLATED", "default": False},
{"value": "other", "name": "Other education TRANSLATED", "default": False},
],
"errorMessages": {
"required": "Please select your highest level of education completed."
}
}
)
......@@ -1302,6 +1307,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"type": "textarea",
"required": False,
"label": "Mailing address",
"errorMessages": {
"required": "Please enter your mailing address."
}
}
)
......@@ -1314,7 +1322,10 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"required": False,
"label": u"Tell us why you're interested in {platform_name}".format(
platform_name=settings.PLATFORM_NAME
)
),
"errorMessages": {
"required": "Please tell us your goals."
}
}
)
......@@ -1326,6 +1337,9 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
"type": "text",
"required": False,
"label": "City",
"errorMessages": {
"required": "Please enter your City."
}
}
)
......@@ -1993,8 +2007,8 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase):
self.assertEqual(
response_json,
{
"username": [{"user_message": "Username must be minimum of two characters long"}],
"password": [{"user_message": "A valid password is required"}],
u"username": [{u"user_message": USERNAME_BAD_LENGTH_MSG}],
u"password": [{u"user_message": u"A valid password is required"}],
}
)
......
......@@ -9,6 +9,7 @@ from ..profile_images.views import ProfileImageView
from .accounts.views import AccountDeactivationView, AccountViewSet
from .preferences.views import PreferencesDetailView, PreferencesView
from .verification_api.views import PhotoVerificationStatusView
from .validation.views import RegistrationValidationView
ME = AccountViewSet.as_view({
'get': 'get',
......@@ -25,9 +26,21 @@ ACCOUNT_DETAIL = AccountViewSet.as_view({
urlpatterns = patterns(
'',
url(r'^v1/me$', ME, name='own_username_api'),
url(r'^v1/accounts/{}$'.format(settings.USERNAME_PATTERN), ACCOUNT_DETAIL, name='accounts_api'),
url(r'^v1/accounts$', ACCOUNT_LIST, name='accounts_detail_api'),
url(
r'^v1/me$',
ME,
name='own_username_api'
),
url(
r'^v1/accounts$',
ACCOUNT_LIST,
name='accounts_detail_api'
),
url(
r'^v1/accounts/{}$'.format(settings.USERNAME_PATTERN),
ACCOUNT_DETAIL,
name='accounts_api'
),
url(
r'^v1/accounts/{}/image$'.format(settings.USERNAME_PATTERN),
ProfileImageView.as_view(),
......@@ -44,6 +57,11 @@ urlpatterns = patterns(
name='verification_status'
),
url(
r'^v1/validation/registration$',
RegistrationValidationView.as_view(),
name='registration_validation'
),
url(
r'^v1/preferences/{}$'.format(settings.USERNAME_PATTERN),
PreferencesView.as_view(),
name='preferences_api'
......
# -*- coding: utf-8 -*-
"""
Tests for an API endpoint for client-side user data validation.
"""
import unittest
import ddt
from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from openedx.core.djangoapps.user_api import accounts
from openedx.core.djangoapps.user_api.accounts.tests import testutils
from openedx.core.lib.api import test_utils
@ddt.ddt
class RegistrationValidationViewTests(test_utils.ApiTestCase):
"""
Tests for validity of user data in registration forms.
"""
endpoint_name = 'registration_validation'
path = reverse(endpoint_name)
def get_validation_decision(self, data):
response = self.client.post(self.path, data)
return response.data.get('validation_decisions', {})
def assertValidationDecision(self, data, decision):
self.assertEqual(
self.get_validation_decision(data),
decision
)
def assertNotValidationDecision(self, data, decision):
self.assertNotEqual(
self.get_validation_decision(data),
decision
)
def test_no_decision_for_empty_request(self):
self.assertValidationDecision(
{},
{}
)
def test_no_decision_for_invalid_request(self):
self.assertValidationDecision(
{'invalid_field': 'random_user_data'},
{}
)
@ddt.data(
['name', (name for name in testutils.VALID_NAMES)],
['email', (email for email in testutils.VALID_EMAILS)],
['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
def test_positive_validation_decision(self, form_field_name, user_data):
"""
Test if {0} as any item in {1} gives a positive validation decision.
"""
self.assertValidationDecision(
{form_field_name: user_data},
{form_field_name: ''}
)
@ddt.data(
# Skip None type for invalidity checks.
['name', (name for name in testutils.INVALID_NAMES[1:])],
['email', (email for email in testutils.INVALID_EMAILS[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
def test_negative_validation_decision(self, form_field_name, user_data):
"""
Test if {0} as any item in {1} gives a negative validation decision.
"""
self.assertNotValidationDecision(
{form_field_name: user_data},
{form_field_name: ''}
)
@ddt.data(
['username', 'username@email.com'], # No conflict
['user', 'username@email.com'], # Username conflict
['username', 'user@email.com'], # Email conflict
['user', 'user@email.com'] # Both conflict
)
@ddt.unpack
def test_existence_conflict(self, username, email):
"""
Test if username '{0}' and email '{1}' have conflicts with
username 'user' and email 'user@email.com'.
"""
user = User.objects.create_user(username='user', email='user@email.com')
self.assertValidationDecision(
{
'username': username,
'email': email
},
{
"username": accounts.USERNAME_CONFLICT_MSG.format(
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' * accounts.EMAIL_MAX_LENGTH) + '@email.com')
def test_email_bad_length_validation_decision(self, email):
self.assertValidationDecision(
{'email': email},
{'email': accounts.EMAIL_BAD_LENGTH_MSG}
)
def test_email_generically_invalid_validation_decision(self):
email = 'email'
self.assertValidationDecision(
{'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(
'u' * (accounts.USERNAME_MIN_LENGTH - 1),
'u' * (accounts.USERNAME_MAX_LENGTH + 1)
)
def test_username_bad_length_validation_decision(self, username):
self.assertValidationDecision(
{'username': username},
{'username': accounts.USERNAME_BAD_LENGTH_MSG}
)
@unittest.skipUnless(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames disabled.")
@ddt.data(*testutils.INVALID_USERNAMES_UNICODE)
def test_username_invalid_unicode_validation_decision(self, username):
self.assertValidationDecision(
{'username': username},
{'username': accounts.USERNAME_INVALID_CHARS_UNICODE}
)
@unittest.skipIf(settings.FEATURES.get("ENABLE_UNICODE_USERNAME"), "Unicode usernames enabled.")
@ddt.data(*testutils.INVALID_USERNAMES_ASCII)
def test_username_invalid_ascii_validation_decision(self, username):
self.assertValidationDecision(
{'username': username},
{"username": accounts.USERNAME_INVALID_CHARS_ASCII}
)
def test_password_empty_validation_decision(self):
self.assertValidationDecision(
{'password': ''},
{"password": accounts.PASSWORD_EMPTY_MSG}
)
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(
{'password': password},
{"password": accounts.PASSWORD_BAD_MAX_LENGTH_MSG}
)
def test_password_equals_username_validation_decision(self):
self.assertValidationDecision(
{"username": "somephrase", "password": "somephrase"},
{"username": "", "password": accounts.PASSWORD_CANT_EQUAL_USERNAME_MSG}
)
# -*- coding: utf-8 -*-
"""
An API for client-side validation of (potential) user data.
"""
from rest_framework.response import Response
from rest_framework.views import APIView
from openedx.core.djangoapps.user_api.accounts.api import (
get_email_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_username_validation_error,
get_username_existence_validation_error
)
class RegistrationValidationView(APIView):
"""
**Use Cases**
Get validation information about user data during registration.
Client-side may request validation for any number of form fields,
and the API will return a conclusion from its analysis for each
input (i.e. valid or not valid, or a custom, detailed message).
**Example Requests and Responses**
- Checks the validity of the username and email inputs separately.
POST /api/user/v1/validation/registration/
>>> {
>>> "username": "hi_im_new",
>>> "email": "newguy101@edx.org"
>>> }
RESPONSE
>>> {
>>> "validation_decisions": {
>>> "username": "",
>>> "email": ""
>>> }
>>> }
Empty strings indicate that there was no problem with the input.
- Checks the validity of the password field (its validity depends
upon both the username and password fields, so we need both). If
only password is input, we don't check for password/username
compatibility issues.
POST /api/user/v1/validation/registration/
>>> {
>>> "username": "myname",
>>> "password": "myname"
>>> }
RESPONSE
>>> {
>>> "validation_decisions": {
>>> "username": "",
>>> "password": "Password cannot be the same as the username"
>>> }
>>> }
- Checks the validity of the username, email, and password fields
separately, and also tells whether an account exists. The password
field's validity depends upon both the username and password, and
the account's existence depends upon both the username and email.
POST /api/user/v1/validation/registration/
>>> {
>>> "username": "hi_im_new",
>>> "email": "cto@edx.org",
>>> "password": "p"
>>> }
RESPONSE
>>> {
>>> "validation_decisions": {
>>> "username": "",
>>> "email": "It looks like cto@edx.org belongs to an existing account. Try again with a different email address.",
>>> "password": "Password must be at least 2 characters long",
>>> }
>>> }
In this example, username is valid and (we assume) there is
a preexisting account with that email. The password also seems
to contain the username.
Note that a validation decision is returned *for all* inputs, whether
positive or negative.
**Available Handlers**
"name":
A handler to check the validity of the user's real name.
"username":
A handler to check the validity of usernames.
"email":
A handler to check the validity of emails.
"confirm_email":
A handler to check whether the confirmation email field matches
the email field.
"password":
A handler to check the validity of passwords; a compatibility
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):
username = request.data.get('username')
invalid_username_error = get_username_validation_error(username)
username_exists_error = get_username_existence_validation_error(username)
# We prefer seeing for invalidity first.
# Some invalid usernames (like for superusers) may exist.
return invalid_username_error or username_exists_error
def email_handler(self, request):
email = request.data.get('email')
invalid_email_error = get_email_validation_error(email)
email_exists_error = get_email_existence_validation_error(email)
# We prefer seeing for invalidity first.
# 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):
username = request.data.get('username', None)
password = request.data.get('password')
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 = {
"name": name_handler,
"username": username_handler,
"email": email_handler,
"confirm_email": confirm_email_handler,
"password": password_handler,
"country": country_handler
}
def post(self, request):
"""
POST /api/user/v1/validation/registration/
Expects request of the form
>>> {
>>> "name": "Dan the Validator",
>>> "username": "mslm",
>>> "email": "mslm@gmail.com",
>>> "confirm_email": "mslm@gmail.com",
>>> "password": "password123",
>>> "country": "PK"
>>> }
where each key is the appropriate form field name and the value is
user input. One may enter individual inputs if needed. Some inputs
can get extra verification checks if entered along with others,
like when the password may not equal the username.
"""
validation_decisions = {}
for form_field_key in self.validation_handlers:
# For every field requiring validation from the client,
# request a decision for it from the appropriate handler.
if form_field_key in request.data:
handler = self.validation_handlers[form_field_key]
validation_decisions.update({
form_field_key: handler(self, request)
})
return Response({"validation_decisions": validation_decisions})
......@@ -23,6 +23,7 @@ import third_party_auth
from django_comment_common.models import Role
from edxmako.shortcuts import marketing_link
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api.accounts.api import check_account_exists
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission
from openedx.features.enterprise_support.api import enterprise_customer_for_request
......@@ -31,16 +32,7 @@ from student.forms import get_registration_extension_form
from student.views import create_account_with_params, AccountValidationError
from util.json_request import JsonResponse
from .accounts import (
EMAIL_MAX_LENGTH,
EMAIL_MIN_LENGTH,
NAME_MAX_LENGTH,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
USERNAME_MAX_LENGTH,
USERNAME_MIN_LENGTH
)
from .accounts.api import check_account_exists
import accounts
from .helpers import FormDescription, require_post_params, shim_student_view
from .models import UserPreference, UserProfile
from .preferences.api import get_country_time_zones, update_email_opt_in
......@@ -92,8 +84,8 @@ class LoginSessionView(APIView):
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH,
"min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": accounts.EMAIL_MAX_LENGTH,
}
)
......@@ -106,8 +98,8 @@ class LoginSessionView(APIView):
label=password_label,
field_type="password",
restrictions={
"min_length": PASSWORD_MIN_LENGTH,
"max_length": PASSWORD_MAX_LENGTH,
"min_length": accounts.PASSWORD_MIN_LENGTH,
"max_length": accounts.PASSWORD_MAX_LENGTH,
}
)
......@@ -340,18 +332,8 @@ class RegistrationView(APIView):
conflicts = check_account_exists(email=email, username=username)
if conflicts:
conflict_messages = {
"email": _(
# Translators: This message is shown to users who attempt to create a new
# account using an email address associated with an existing account.
u"It looks like {email_address} belongs to an existing account. "
u"Try again with a different email address."
).format(email_address=email),
"username": _(
# Translators: This message is shown to users who attempt to create a new
# account using a username associated with an existing account.
u"It looks like {username} belongs to an existing account. "
u"Try again with a different username."
).format(username=username),
"email": accounts.EMAIL_CONFLICT_MSG.format(email_address=email),
"username": accounts.USERNAME_CONFLICT_MSG.format(username=username),
}
errors = {
field: [{"user_message": conflict_messages[field]}]
......@@ -425,8 +407,8 @@ class RegistrationView(APIView):
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH,
"min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": accounts.EMAIL_MAX_LENGTH,
},
required=required
)
......@@ -444,7 +426,7 @@ class RegistrationView(APIView):
# Translators: This label appears above a field on the registration form
# meant to confirm the user's email address.
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(
"confirm_email",
......@@ -483,7 +465,7 @@ class RegistrationView(APIView):
placeholder=name_placeholder,
instructions=name_instructions,
restrictions={
"max_length": NAME_MAX_LENGTH,
"max_length": accounts.NAME_MAX_LENGTH,
},
required=required
)
......@@ -519,8 +501,8 @@ class RegistrationView(APIView):
instructions=username_instructions,
placeholder=username_placeholder,
restrictions={
"min_length": USERNAME_MIN_LENGTH,
"max_length": USERNAME_MAX_LENGTH,
"min_length": accounts.USERNAME_MIN_LENGTH,
"max_length": accounts.USERNAME_MAX_LENGTH,
},
required=required
)
......@@ -544,8 +526,8 @@ class RegistrationView(APIView):
label=password_label,
field_type="password",
restrictions={
"min_length": PASSWORD_MIN_LENGTH,
"max_length": PASSWORD_MAX_LENGTH,
"min_length": accounts.PASSWORD_MIN_LENGTH,
"max_length": accounts.PASSWORD_MAX_LENGTH,
},
required=required
)
......@@ -563,6 +545,7 @@ class RegistrationView(APIView):
# Translators: This label appears above a dropdown menu on the registration
# form used to select the user's highest completed level of education.
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.
options = [(name, _(label)) for name, label in UserProfile.LEVEL_OF_EDUCATION_CHOICES] # pylint: disable=translation-of-non-string
......@@ -572,7 +555,10 @@ class RegistrationView(APIView):
field_type="select",
options=options,
include_default_option=True,
required=required
required=required,
error_messages={
"required": error_msg
}
)
def _add_gender_field(self, form_desc, required=True):
......@@ -637,12 +623,16 @@ class RegistrationView(APIView):
# Translators: This label appears above a field on the registration form
# meant to hold the user's mailing address.
mailing_address_label = _(u"Mailing address")
error_msg = accounts.REQUIRED_FIELD_MAILING_ADDRESS_MSG
form_desc.add_field(
"mailing_address",
label=mailing_address_label,
field_type="textarea",
required=required
required=required,
error_messages={
"required": error_msg
}
)
def _add_goals_field(self, form_desc, required=True):
......@@ -660,12 +650,16 @@ class RegistrationView(APIView):
goals_label = _(u"Tell us why you're interested in {platform_name}").format(
platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME)
)
error_msg = accounts.REQUIRED_FIELD_GOALS_MSG
form_desc.add_field(
"goals",
label=goals_label,
field_type="textarea",
required=required
required=required,
error_messages={
"required": error_msg
}
)
def _add_city_field(self, form_desc, required=True):
......@@ -681,11 +675,15 @@ class RegistrationView(APIView):
# Translators: This label appears above a field on the registration form
# which allows the user to input the city in which they live.
city_label = _(u"City")
error_msg = accounts.REQUIRED_FIELD_CITY_MSG
form_desc.add_field(
"city",
label=city_label,
required=required
required=required,
error_messages={
"required": error_msg
}
)
def _add_state_field(self, form_desc, required=False):
......@@ -801,7 +799,7 @@ class RegistrationView(APIView):
# Translators: This label appears above a dropdown menu on the registration
# form used to select the country in which the user lives.
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.
default_country = form_desc._field_overrides.get('country', {}).get('defaultValue')
......@@ -1036,8 +1034,8 @@ class PasswordResetView(APIView):
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": EMAIL_MIN_LENGTH,
"max_length": EMAIL_MAX_LENGTH,
"min_length": accounts.EMAIL_MIN_LENGTH,
"max_length": accounts.EMAIL_MAX_LENGTH,
}
)
......@@ -1105,7 +1103,9 @@ class PreferenceUsersListView(generics.ListAPIView):
paginate_by_param = "page_size"
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):
......
......@@ -28,7 +28,7 @@ class ApiTestCase(TestCase):
return getattr(self.client, method)(*args, HTTP_X_EDX_API_KEY=TEST_API_KEY, **kwargs)
def get_json(self, *args, **kwargs):
"""Make a request with the given args and return the parsed JSON repsonse"""
"""Make a request with the given args and return the parsed JSON response"""
resp = self.request_with_auth("get", *args, **kwargs)
self.assertHttpOK(resp)
self.assertTrue(resp["Content-Type"].startswith("application/json"))
......
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