diff --git a/lms/envs/aws.py b/lms/envs/aws.py index a88fcd1873..d786a3e87b 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -168,6 +168,7 @@ AWS_SES_REGION_ENDPOINT = ENV_TOKENS.get('AWS_SES_REGION_ENDPOINT', 'email.us-ea REGISTRATION_EXTRA_FIELDS = ENV_TOKENS.get('REGISTRATION_EXTRA_FIELDS', REGISTRATION_EXTRA_FIELDS) REGISTRATION_EXTENSION_FORM = ENV_TOKENS.get('REGISTRATION_EXTENSION_FORM', REGISTRATION_EXTENSION_FORM) REGISTRATION_EMAIL_PATTERNS_ALLOWED = ENV_TOKENS.get('REGISTRATION_EMAIL_PATTERNS_ALLOWED') +REGISTRATION_FIELD_ORDER = ENV_TOKENS.get('REGISTRATION_FIELD_ORDER', REGISTRATION_FIELD_ORDER) # Set the names of cookies shared with the marketing site # These have the same cookie domain as the session, which in production diff --git a/lms/envs/common.py b/lms/envs/common.py index b8f3a97e7e..b5215f9864 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2419,6 +2419,7 @@ XDOMAIN_PROXY_CACHE_TIMEOUT = 60 * 15 # - 'hidden': to not display the field REGISTRATION_EXTRA_FIELDS = { + 'confirm_email': 'hidden', 'level_of_education': 'optional', 'gender': 'optional', 'year_of_birth': 'optional', @@ -2430,6 +2431,28 @@ REGISTRATION_EXTRA_FIELDS = { 'country': 'hidden', } +REGISTRATION_FIELD_ORDER = [ + "email", + "confirm_email", + "name", + "username", + "password", + "first_name", + "last_name", + "city", + "state", + "country", + "gender", + "year_of_birth", + "level_of_education", + "company", + "title", + "mailing_address", + "goals", + "honor_code", + "terms_of_service", +] + # Optional setting to restrict registration / account creation to only emails # that match a regex in this list. Set to None to allow any email (default). REGISTRATION_EMAIL_PATTERNS_ALLOWED = None diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index 3947aceee8..f32db1b0de 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -26,6 +26,7 @@ year_of_birth: 2014, mailing_address: '141 Portland', goals: 'To boldly learn what no letter of the alphabet has learned before', + confirm_email: 'xsy@edx.org', honor_code: true }, THIRD_PARTY_AUTH = { @@ -61,6 +62,16 @@ instructions: 'Enter your email.', restrictions: {} }, + { + placeholder: '', + name: 'confirm_email', + label: 'Confirm Email', + defaultValue: '', + type: 'text', + required: true, + instructions: 'Enter your email.', + restrictions: {} + }, { placeholder: 'Jane Doe', name: 'name', @@ -203,6 +214,7 @@ // Simulate manual entry of registration form data $('#register-email').val(USER_DATA.email); + $('#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); diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index db54ce14f7..3d47fbdefe 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -127,6 +127,37 @@ jsHook: this.authWarningJsHook, message: fullMsg }); + }, + + getFormData: function() { + var obj = FormView.prototype.getFormData.apply(this, arguments), + $form = this.$form, + $label, + $emailElement, + $confirmEmailElement, + email = '', + confirmEmail = ''; + + $emailElement = $form.find('input[name=email]'); + $confirmEmailElement = $form.find('input[name=confirm_email]'); + + if ($confirmEmailElement.length) { + email = $emailElement.val(); + confirmEmail = $confirmEmailElement.val(); + $label = $form.find('label[for=' + $confirmEmailElement.attr('id') + ']'); + + if (confirmEmail !== '' && email !== confirmEmail) { + this.errors.push('
  • ' + $confirmEmailElement.data('errormsg-required') + '
  • '); + $confirmEmailElement.addClass('error'); + $label.addClass('error'); + } else if (confirmEmail !== '') { + obj.confirm_email = confirmEmail; + $confirmEmailElement.removeClass('error'); + $label.removeClass('error'); + } + } + + return obj; } }); }); diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 1aee8ecd9f..10f0fa2685 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -1188,6 +1188,20 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): } ) + def test_registration_form_confirm_email(self): + self._assert_reg_field( + {"confirm_email": "required"}, + { + "name": "confirm_email", + "type": "text", + "required": True, + "label": "Confirm Email", + "errorMessages": { + "required": "Please confirm your email.", + } + } + ) + @override_settings( MKTG_URLS={"ROOT": "https://www.test.com/", "HONOR": "honor"}, ) @@ -1343,6 +1357,7 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): "state": "optional", "country": "required", "honor_code": "required", + "confirm_email": "required", }, REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm', ) @@ -1360,6 +1375,123 @@ class RegistrationViewTest(ThirdPartyAuthTestMixin, UserAPITestCase): "password", "favorite_movie", "favorite_editor", + "confirm_email", + "city", + "state", + "country", + "gender", + "year_of_birth", + "level_of_education", + "mailing_address", + "goals", + "honor_code", + ]) + + @override_settings( + REGISTRATION_EXTRA_FIELDS={ + "level_of_education": "optional", + "gender": "optional", + "year_of_birth": "optional", + "mailing_address": "optional", + "goals": "optional", + "city": "optional", + "state": "optional", + "country": "required", + "honor_code": "required", + "confirm_email": "required", + }, + REGISTRATION_FIELD_ORDER=[ + "name", + "username", + "email", + "confirm_email", + "password", + "first_name", + "last_name", + "city", + "state", + "country", + "gender", + "year_of_birth", + "level_of_education", + "company", + "title", + "mailing_address", + "goals", + "honor_code", + "terms_of_service", + ], + ) + def test_field_order_override(self): + response = self.client.get(self.url) + self.assertHttpOK(response) + + # Verify that all fields render in the correct order + form_desc = json.loads(response.content) + field_names = [field["name"] for field in form_desc["fields"]] + self.assertEqual(field_names, [ + "name", + "username", + "email", + "confirm_email", + "password", + "city", + "state", + "country", + "gender", + "year_of_birth", + "level_of_education", + "mailing_address", + "goals", + "honor_code", + ]) + + @override_settings( + REGISTRATION_EXTRA_FIELDS={ + "level_of_education": "optional", + "gender": "optional", + "year_of_birth": "optional", + "mailing_address": "optional", + "goals": "optional", + "city": "optional", + "state": "optional", + "country": "required", + "honor_code": "required", + "confirm_email": "required", + }, + REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm', + REGISTRATION_FIELD_ORDER=[ + "name", + "confirm_email", + "password", + "first_name", + "last_name", + "gender", + "year_of_birth", + "level_of_education", + "company", + "title", + "mailing_address", + "goals", + "honor_code", + "terms_of_service", + ], + ) + def test_field_order_invalid_override(self): + response = self.client.get(self.url) + self.assertHttpOK(response) + + # Verify that all fields render in the correct order + form_desc = json.loads(response.content) + field_names = [field["name"] for field in form_desc["fields"]] + self.assertEqual(field_names, [ + "email", + "name", + "username", + "password", + "favorite_movie", + "favorite_editor", + "confirm_email", "city", "state", "country", diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 5da932dad6..0627bf7cc4 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -160,6 +160,7 @@ class RegistrationView(APIView): DEFAULT_FIELDS = ["email", "name", "username", "password"] EXTRA_FIELDS = [ + "confirm_email", "first_name", "last_name", "city", @@ -206,10 +207,21 @@ class RegistrationView(APIView): # Map field names to the instance method used to add the field to the form self.field_handlers = {} - for field_name in self.DEFAULT_FIELDS + self.EXTRA_FIELDS: + valid_fields = self.DEFAULT_FIELDS + self.EXTRA_FIELDS + for field_name in valid_fields: handler = getattr(self, "_add_{field_name}_field".format(field_name=field_name)) self.field_handlers[field_name] = handler + field_order = configuration_helpers.get_value('REGISTRATION_FIELD_ORDER') + if not field_order: + field_order = settings.REGISTRATION_FIELD_ORDER or valid_fields + + # Check that all of the valid_fields are in the field order and vice versa, if not set to the default order + if set(valid_fields) != set(field_order): + field_order = valid_fields + + self.field_order = field_order + @method_decorator(ensure_csrf_cookie) def get(self, request): """Return a description of the registration form. @@ -235,14 +247,14 @@ class RegistrationView(APIView): form_desc = FormDescription("post", reverse("user_api_registration")) self._apply_third_party_auth_overrides(request, form_desc) - # Default fields are always required - for field_name in self.DEFAULT_FIELDS: - self.field_handlers[field_name](form_desc, required=True) - # Custom form fields can be added via the form set in settings.REGISTRATION_EXTENSION_FORM custom_form = get_registration_extension_form() if custom_form: + # Default fields are always required + for field_name in self.DEFAULT_FIELDS: + self.field_handlers[field_name](form_desc, required=True) + for field_name, field in custom_form.fields.items(): restrictions = {} if getattr(field, 'max_length', None): @@ -270,14 +282,24 @@ class RegistrationView(APIView): include_default_option=field_options.get('include_default_option'), ) - # Extra fields configured in Django settings - # may be required, optional, or hidden - for field_name in self.EXTRA_FIELDS: - if self._is_field_visible(field_name): - self.field_handlers[field_name]( - form_desc, - required=self._is_field_required(field_name) - ) + # Extra fields configured in Django settings + # may be required, optional, or hidden + for field_name in self.EXTRA_FIELDS: + if self._is_field_visible(field_name): + self.field_handlers[field_name]( + form_desc, + required=self._is_field_required(field_name) + ) + else: + # Go through the fields in the fields order and add them if they are required or visible + for field_name in self.field_order: + if field_name in self.DEFAULT_FIELDS: + self.field_handlers[field_name](form_desc, required=True) + elif self._is_field_visible(field_name): + self.field_handlers[field_name]( + form_desc, + required=self._is_field_required(field_name) + ) return HttpResponse(form_desc.to_json(), content_type="application/json") @@ -386,6 +408,30 @@ class RegistrationView(APIView): required=required ) + def _add_confirm_email_field(self, form_desc, required=True): + """Add an email confirmation field to a form description. + + Arguments: + form_desc: A form description + + Keyword Arguments: + required (bool): Whether this field is required; defaults to True + + """ + # 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"Please confirm your email.") + + form_desc.add_field( + "confirm_email", + label=email_label, + required=required, + error_messages={ + "required": error_msg + } + ) + def _add_name_field(self, form_desc, required=True): """Add a name field to a form description.