From bea5f362846c6b003b67f71226eeba260d0b915e Mon Sep 17 00:00:00 2001 From: Maria Fernanda Magallanes Zubillaga Date: Sun, 25 Sep 2022 16:50:53 -0500 Subject: [PATCH 01/25] feat: add exception message in the user interface --- common/djangoapps/student/views/management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index 4cf8fad8ae..2bd91d5d64 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -424,8 +424,8 @@ def change_enrollment(request, check_access=True): enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) if enroll_mode: CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) - except EnrollmentNotAllowed as exc: - return HttpResponseBadRequest(str(exc)) + except EnrollmentNotAllowed as exec: # pylint: disable=broad-except + return HttpResponseBadRequest(str(exec)) except Exception: # pylint: disable=broad-except return HttpResponseBadRequest(_("Could not enroll")) From 534084ec6cb0e1f1555d816f74ac4097dac481ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Fernanda=20Magallanes=20Z?= Date: Mon, 16 May 2022 17:37:26 -0400 Subject: [PATCH 02/25] fix: correct test that fails due to custom changes --- lms/static/js/spec/student_account/register_spec.js | 8 ++++++-- .../djangoapps/user_authn/views/tests/test_register.py | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index 17d82e2b13..506b03b697 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -536,17 +536,21 @@ expect(elementChildren.length).toEqual(1); }); - it('hides optional fields by default', function() { + // These tests behave contrary to the use case of nau + xit('hides optional fields by default', function() { createRegisterView(this); expect(view.$('.optional-fields')).toHaveClass('hidden'); }); - it('displays optional fields when checkbox is selected', function() { + // These tests behave contrary to the use case of nau + xit('displays optional fields when checkbox is selected', function() { createRegisterView(this); $('#toggle_optional_fields').click(); expect(view.$('.optional-fields')).not.toHaveClass('hidden'); }); + + it('displays a modal with the terms of service', function() { var $modal, $content; diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 396513f496..992a81f418 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -695,6 +695,7 @@ def test_register_form_password_complexity(self): ) @override_settings(REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm') + @pytest.mark.skip(reason="fails due to nau custom registration form") def test_extension_form_fields(self): no_extra_fields_setting = {} @@ -1334,6 +1335,7 @@ def test_registration_separate_terms_of_service_mktg_site_disabled(self): REGISTRATION_FIELD_ORDER=None, REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm', ) + @pytest.mark.skip(reason="fails due to nau custom registration form") def test_field_order(self): response = self.client.get(self.url) self.assertHttpOK(response) @@ -1396,6 +1398,7 @@ def test_field_order(self): "profession", ], ) + @pytest.mark.skip(reason="fails due to nau custom registration form") def test_field_order_override(self): response = self.client.get(self.url) self.assertHttpOK(response) @@ -1437,6 +1440,7 @@ def test_field_order_override(self): "terms_of_service", ], ) + @pytest.mark.skip(reason="fails due to nau custom registration form") def test_field_order_invalid_override(self): response = self.client.get(self.url) self.assertHttpOK(response) @@ -2123,6 +2127,7 @@ def setUp(self): # pylint: disable=arguments-differ "terms_of_service", ], ) + @pytest.mark.skip(reason="fails due to nau custom registration form") def test_field_order_invalid_override(self): response = self.client.get(self.url) self.assertHttpOK(response) @@ -2215,6 +2220,7 @@ def test_field_order_override(self): REGISTRATION_FIELD_ORDER=None, REGISTRATION_EXTENSION_FORM='openedx.core.djangoapps.user_api.tests.test_helpers.TestCaseForm', ) + @pytest.mark.skip(reason="fails due to nau custom registration form") def test_field_order(self): response = self.client.get(self.url) self.assertHttpOK(response) From 6ce0c463423d6374cd62002b16d075090360ad15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Fernanda=20Magallanes=20Z?= Date: Mon, 16 May 2022 15:59:27 -0400 Subject: [PATCH 03/25] fix: pep8 and pylint issues --- .../js/student_account/views/RegisterView.js | 149 ++++++++++++++++-- .../user_authn/views/registration_form.py | 12 +- 2 files changed, 144 insertions(+), 17 deletions(-) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 42ab7c8857..d5f4db6f8f 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -152,16 +152,145 @@ /* We pass the context object to the template so that * we can perform variable interpolation using sprintf */ - context: { - fields: fields, - currentProvider: this.currentProvider, - syncLearnerProfileData: this.syncLearnerProfileData, - providers: this.providers, - hasSecondaryProviders: this.hasSecondaryProviders, - platformName: this.platformName, - autoRegisterWelcomeMessage: this.autoRegisterWelcomeMessage, - registerFormSubmitButtonText: this.registerFormSubmitButtonText, - is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled + context: { + fields: fields, + currentProvider: this.currentProvider, + syncLearnerProfileData: this.syncLearnerProfileData, + providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, + platformName: this.platformName, + autoRegisterWelcomeMessage: this.autoRegisterWelcomeMessage, + registerFormSubmitButtonText: this.registerFormSubmitButtonText, + is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled + } + }); + + HtmlUtils.setHtml($(this.el), HtmlUtils.HTML(renderHtml)); + + this.postRender(); + + // Must be called after postRender, since postRender sets up $formFeedback. + if (this.errorMessage) { + this.renderErrors(formErrorsTitle, [this.errorMessage]); + } else if (this.currentProvider && !this.hideAuthWarnings) { + this.renderAuthWarning(); + } + + if (this.autoSubmit) { + $(this.el).hide(); + $('#register-honor_code, #register-terms_of_service').prop('checked', true); + this.submitForm(); + } + + return this; + }, + + postRender: function() { + var inputs = this.$('.form-field'), + inputSelectors = 'input, select, textarea', + inputTipSelectors = ['tip error', 'tip tip-input'], + inputTipSelectorsHidden = ['tip error hidden', 'tip tip-input hidden'], + onInputFocus = function() { + // Apply on focus styles to input + $(this).find('label').addClass('focus-in') + .removeClass('focus-out'); + + // Show each input tip + $(this).children().each(function() { + if (inputTipSelectorsHidden.indexOf($(this).attr('class')) >= 0) { + $(this).removeClass('hidden'); + } + }); + }, + onInputFocusOut = function() { + // If input has no text apply focus out styles + if ($(this).find(inputSelectors).val().length === 0) { + $(this).find('label').addClass('focus-out') + .removeClass('focus-in'); + } + + // Hide each input tip + $(this).children().each(function() { + // This is a 1 instead of 0 so the error message for a field is not + // hidden on blur and only the help tip is hidden. + if (inputTipSelectors.indexOf($(this).attr('class')) >= 1) { + $(this).addClass('hidden'); + } + }); + }, + handleInputBehavior = function(input) { + // Initially put label in input + if (input.find(inputSelectors).val().length === 0) { + input.find('label').addClass('focus-out') + .removeClass('focus-in'); + } + + // Initially hide each input tip + input.children().each(function() { + if (inputTipSelectors.indexOf($(this).attr('class')) >= 0) { + $(this).addClass('hidden'); + } + }); + + input.focusin(onInputFocus); + input.focusout(onInputFocusOut); + }, + handleAutocomplete = function() { + $(inputs).each(function() { + var $input = $(this), + isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; + + if (!isCheckbox) { + if ($input.find(inputSelectors).val().length === 0 + && !$input.is(':-webkit-autofill')) { + $input.find('label').addClass('focus-out') + .removeClass('focus-in'); + } else { + $input.find('label').addClass('focus-in') + .removeClass('focus-out'); + } + } + }); + }; + + FormView.prototype.postRender.call(this); + + // NAU always show optional fields + /*$('.optional-fields').addClass('hidden');*/ + $('#toggle_optional_fields').change(function() { + window.analytics.track('edx.bi.user.register.optional_fields_selected'); + $('.optional-fields').toggleClass('hidden'); + }); + + // We are swapping the order of these elements here because the honor code agreement + // is a required checkbox field and the optional fields toggle is a cosmetic + // improvement so that we don't have to show all the optional fields. + // xss-lint: disable=javascript-jquery-insert-into-target + $('.checkbox-optional_fields_toggle').insertAfter('.required-fields'); + + // NAU always hide checkbox to show/hide optional fields + /*if (!this.hasOptionalFields) {*/ + $('.checkbox-optional_fields_toggle').addClass('hidden'); + /*}*/ + // xss-lint: disable=javascript-jquery-insert-into-target + $('.checkbox-honor_code').insertAfter('.optional-fields'); + // xss-lint: disable=javascript-jquery-insert-into-target + $('.checkbox-terms_of_service').insertAfter('.optional-fields'); + + // Clicking on links inside a label should open that link. + $('label a').click(function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + window.open($(this).attr('href'), $(this).attr('target'), 'noopener'); + }); + $('.form-field').each(function() { + $(this).find('option:first').html(''); + }); + $(inputs).each(function() { + var $input = $(this), + isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; + if ($input.length > 0 && !isCheckbox) { + handleInputBehavior($input); } }); diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index efee92e700..6961841541 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -456,9 +456,9 @@ def get_registration_form(self, request): field_options = getattr( getattr(custom_form, 'Meta', None), 'serialization_options', {} ).get(field_name, {}) - field_type = field_options.get( - 'field_type', - FormDescription.FIELD_TYPE_MAP.get(field.__class__)) + + field_type = field_options.get('field_type', + FormDescription.FIELD_TYPE_MAP.get(field.__class__)) if not field_type: raise ImproperlyConfigured( "Field type '{}' not recognized for registration extension field '{}'.".format( @@ -471,12 +471,10 @@ def get_registration_form(self, request): field_name, label=field.label, default=field_options.get('default'), - field_type=field_options.get( - 'field_type', - FormDescription.FIELD_TYPE_MAP.get(field.__class__)), + field_type=field_options.get('field_type', + FormDescription.FIELD_TYPE_MAP.get(field.__class__)), placeholder=field.initial, instructions=field.help_text, - exposed=self._is_field_exposed(field_name), required=(self._is_field_required(field_name) or field.required), restrictions=restrictions, options=getattr(field, 'choices', None), error_messages=field.error_messages, From d02f983750acee35b6384e47fa6940e3092a0147 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Mon, 15 Feb 2021 11:43:57 +0000 Subject: [PATCH 04/25] GN-265 Always show optional fields on registry. --- lms/static/js/student_account/views/RegisterView.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index d5f4db6f8f..ab0aa77c92 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -254,7 +254,11 @@ }; FormView.prototype.postRender.call(this); +<<<<<<< HEAD +======= + +>>>>>>> 941d8f2722 (GN-265 Always show optional fields on registry.) // NAU always show optional fields /*$('.optional-fields').addClass('hidden');*/ $('#toggle_optional_fields').change(function() { @@ -270,7 +274,7 @@ // NAU always hide checkbox to show/hide optional fields /*if (!this.hasOptionalFields) {*/ - $('.checkbox-optional_fields_toggle').addClass('hidden'); + $('.checkbox-optional_fields_toggle').addClass('hidden'); /*}*/ // xss-lint: disable=javascript-jquery-insert-into-target $('.checkbox-honor_code').insertAfter('.optional-fields'); From 435f891cece2c7fea637a37cf308a51441f7315a Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Fri, 19 Feb 2021 13:05:08 +0000 Subject: [PATCH 05/25] Fix use a custom registration field order when using a registration extension form. --- .../user_authn/views/registration_form.py | 31 ++++++------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 6961841541..91c2ee302e 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -396,7 +396,7 @@ def __init__(self): custom_form = get_registration_extension_form() if custom_form: - custom_form_field_names = [field_name for field_name, field in custom_form.fields.items()] + custom_form_field_names = [ field_name for field_name, field in custom_form.fields.items()] valid_fields.extend(custom_form_field_names) field_order = configuration_helpers.get_value('REGISTRATION_FIELD_ORDER') @@ -426,15 +426,12 @@ def get_registration_form(self, request): Returns: HttpResponse """ - form_desc = FormDescription("post", self._get_registration_submit_url(request)) + form_desc = FormDescription("post", reverse("user_api_registration")) self._apply_third_party_auth_overrides(request, form_desc) # Custom form fields can be added via the form set in settings.REGISTRATION_EXTENSION_FORM custom_form = get_registration_extension_form() - if custom_form: - custom_form_field_names = [field_name for field_name, field in custom_form.fields.items()] - else: - custom_form_field_names = [] + custom_form_field_names = [ field_name for field_name, field in custom_form.fields.items()] if custom_form 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: @@ -457,36 +454,26 @@ def get_registration_form(self, request): getattr(custom_form, 'Meta', None), 'serialization_options', {} ).get(field_name, {}) - field_type = field_options.get('field_type', - FormDescription.FIELD_TYPE_MAP.get(field.__class__)) + field_type = field_options.get('field_type', FormDescription.FIELD_TYPE_MAP.get(field.__class__)) if not field_type: raise ImproperlyConfigured( - "Field type '{}' not recognized for registration extension field '{}'.".format( + u"Field type '{}' not recognized for registration extension field '{}'.".format( + field_type, field_name ) ) if self._is_field_visible(field_name) or field.required: form_desc.add_field( - field_name, - label=field.label, + field_name, label=field.label, default=field_options.get('default'), - field_type=field_options.get('field_type', - FormDescription.FIELD_TYPE_MAP.get(field.__class__)), - placeholder=field.initial, - instructions=field.help_text, - required=(self._is_field_required(field_name) or field.required), + field_type=field_options.get('field_type', FormDescription.FIELD_TYPE_MAP.get(field.__class__)), + placeholder=field.initial, instructions=field.help_text, required=(self._is_field_required(field_name) or field.required), restrictions=restrictions, options=getattr(field, 'choices', None), error_messages=field.error_messages, include_default_option=field_options.get('include_default_option'), ) - # remove confirm_email form v1 registration form - if is_api_v1(request): - for index, field in enumerate(form_desc.fields): - if field['name'] == 'confirm_email': - del form_desc.fields[index] - break return form_desc def _get_registration_submit_url(self, request): From 4e8800e636e024d8efafdf28406ff74d57b6a454 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Thu, 29 Aug 2019 19:04:43 -0500 Subject: [PATCH 06/25] Adding the capacity to see and edit nau extension fields on the accounts page --- .../core/djangoapps/user_api/accounts/api.py | 8 + .../user_api/accounts/serializers.py | 10 +- .../user_api/accounts/settings_views.py | 308 ++++++++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 openedx/core/djangoapps/user_api/accounts/settings_views.py diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 6970ea6f85..76142ec1b9 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -28,6 +28,7 @@ from lms.djangoapps.certificates.data import CertificateStatuses from openedx.core.djangoapps.embargo.models import GlobalRestrictedCountry from openedx.core.djangoapps.enrollments.api import get_verified_enrollments +from openedx.core.djangoapps.plugins.plugin_extension_points import run_extension_point from openedx.core.djangoapps.user_api import accounts, errors, helpers from openedx.core.djangoapps.user_api.errors import ( AccountUpdateError, @@ -179,6 +180,13 @@ def update_account_settings(requesting_user, update, username=None): _update_extended_profile_if_needed(update, user_profile) _update_state_if_needed(update, user_profile) + # Allow a plugin to save the updated values + run_extension_point( + 'NAU_STUDENT_ACCOUNT_PARTIAL_UPDATE', + update=update, + user=user, + ) + except PreferenceValidationError as err: raise AccountValidationError(err.preference_errors) # lint-amnesty, pylint: disable=raise-missing-from except (AccountUpdateError, AccountValidationError) as err: diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index f7ffe15d2a..3e6cab1f51 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -13,7 +13,6 @@ from django.urls import reverse from rest_framework import serializers - from common.djangoapps.student.models import ( LanguageProficiency, PendingNameChange, @@ -21,6 +20,8 @@ UserPasswordToggleHistory, UserProfile ) + +from openedx.core.djangoapps.plugins.plugin_extension_points import run_extension_point from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import errors from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled @@ -232,6 +233,13 @@ def to_representation(self, user): # lint-amnesty, pylint: disable=arguments-di } ) + # Append/Override the existing data values with plugin defined values + run_extension_point( + 'NAU_STUDENT_SERIALIZER_CONTEXT_EXTENSION', + data=data, + user=user, + ) + if self.custom_fields: fields = self.custom_fields elif user_profile: diff --git a/openedx/core/djangoapps/user_api/accounts/settings_views.py b/openedx/core/djangoapps/user_api/accounts/settings_views.py new file mode 100644 index 0000000000..e9c0fa712e --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/settings_views.py @@ -0,0 +1,308 @@ +""" Views related to Account Settings. """ + + +import logging +import urllib +from datetime import datetime + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.decorators.http import require_http_methods +from django_countries import countries + +from openedx_filters.learning.filters import AccountSettingsRenderStarted +from common.djangoapps import third_party_auth +from common.djangoapps.edxmako.shortcuts import render_to_response +from common.djangoapps.student.models import UserProfile +from common.djangoapps.third_party_auth import pipeline +from common.djangoapps.util.date_utils import strftime_localized +from lms.djangoapps.commerce.models import CommerceConfiguration +from lms.djangoapps.commerce.utils import EcommerceService +from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client +from openedx.core.djangoapps.dark_lang.models import DarkLangConfig +from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages +from openedx.core.djangoapps.plugins.plugin_extension_points import run_extension_point +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_api.accounts.toggles import ( + should_redirect_to_account_microfrontend, + should_redirect_to_order_history_microfrontend +) +from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences +from openedx.core.lib.edx_api_utils import get_api_data +from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES +from openedx.features.enterprise_support.api import enterprise_customer_for_request +from openedx.features.enterprise_support.utils import update_account_settings_context_for_enterprise + +log = logging.getLogger(__name__) + + +@login_required +@require_http_methods(['GET']) +def account_settings(request): + """Render the current user's account settings page. + + Args: + request (HttpRequest) + + Returns: + HttpResponse: 200 if the page was sent successfully + HttpResponse: 302 if not logged in (redirect to login page) + HttpResponse: 405 if using an unsupported HTTP method + + Example usage: + + GET /account/settings + + """ + if should_redirect_to_account_microfrontend(): + url = settings.ACCOUNT_MICROFRONTEND_URL + + duplicate_provider = pipeline.get_duplicate_provider(messages.get_messages(request)) + if duplicate_provider: + url = '{url}?{params}'.format( + url=url, + params=urllib.parse.urlencode({ + 'duplicate_provider': duplicate_provider, + }), + ) + + return redirect(url) + + context = account_settings_context(request) + + account_settings_template = 'student_account/account_settings.html' + + try: + # .. filter_implemented_name: AccountSettingsRenderStarted + # .. filter_type: org.openedx.learning.student.settings.render.started.v1 + context, account_settings_template = AccountSettingsRenderStarted.run_filter( + context=context, template_name=account_settings_template, + ) + except AccountSettingsRenderStarted.RenderInvalidAccountSettings as exc: + response = render_to_response(exc.account_settings_template, exc.template_context) + except AccountSettingsRenderStarted.RedirectToPage as exc: + response = HttpResponseRedirect(exc.redirect_to or reverse('dashboard')) + except AccountSettingsRenderStarted.RenderCustomResponse as exc: + response = exc.response + else: + response = render_to_response(account_settings_template, context) + + return response + + +def account_settings_context(request): + """ Context for the account settings page. + + Args: + request: The request object. + + Returns: + dict + + """ + user = request.user + + year_of_birth_options = [(str(year), str(year)) for year in UserProfile.VALID_YEARS] + try: + user_orders = get_user_orders(user) + except: # pylint: disable=bare-except + log.exception('Error fetching order history from Otto.') + # Return empty order list as account settings page expect a list and + # it will be broken if exception raised + user_orders = [] + + beta_language = {} + dark_lang_config = DarkLangConfig.current() + if dark_lang_config.enable_beta_languages: + user_preferences = get_user_preferences(user) + pref_language = user_preferences.get('pref-lang') + if pref_language in dark_lang_config.beta_languages_list: + beta_language['code'] = pref_language + beta_language['name'] = settings.LANGUAGE_DICT.get(pref_language) + + context = { + 'auth': {}, + 'duplicate_provider': None, + 'nav_hidden': True, + 'fields': { + 'country': { + 'options': list(countries), + }, 'gender': { + 'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], # lint-amnesty, pylint: disable=translation-of-non-string + }, 'language': { + 'options': released_languages(), + }, 'level_of_education': { + 'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES], # lint-amnesty, pylint: disable=translation-of-non-string + }, 'password': { + 'url': reverse('password_reset'), + }, 'year_of_birth': { + 'options': year_of_birth_options, + }, 'preferred_language': { + 'options': all_languages(), + }, 'time_zone': { + 'options': TIME_ZONE_CHOICES, + } + }, + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'password_reset_support_link': configuration_helpers.get_value( + 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK + ) or settings.SUPPORT_SITE_LINK, + 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), + 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), + 'disable_courseware_js': True, + 'show_program_listing': ProgramsApiConfig.is_enabled(), + 'show_dashboard_tabs': True, + 'order_history': user_orders, + 'disable_order_history_tab': should_redirect_to_order_history_microfrontend(), + 'enable_account_deletion': configuration_helpers.get_value( + 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False) + ), + 'extended_profile_fields': _get_extended_profile_fields(), + 'beta_language': beta_language, + 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE, + } + + enterprise_customer = enterprise_customer_for_request(request) + update_account_settings_context_for_enterprise(context, enterprise_customer, user) + + if third_party_auth.is_enabled(): + # If the account on the third party provider is already connected with another edX account, + # we display a message to the user. + context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) + + auth_states = pipeline.get_provider_user_states(user) + + context['auth']['providers'] = [{ + 'id': state.provider.provider_id, + 'name': state.provider.name, # The name of the provider e.g. Facebook + 'connected': state.has_account, # Whether the user's edX account is connected with the provider. + # If the user is not connected, they should be directed to this page to authenticate + # with the particular provider, as long as the provider supports initiating a login. + 'connect_url': pipeline.get_login_url( + state.provider.provider_id, + pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, + # The url the user should be directed to after the auth process has completed. + redirect_url=reverse('account_settings'), + ), + 'accepts_logins': state.provider.accepts_logins, + # If the user is connected, sending a POST request to this url removes the connection + # information for this provider from their edX account. + 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), + # We only want to include providers if they are either currently available to be logged + # in with, or if the user is already authenticated with them. + } for state in auth_states if state.provider.display_for_login or state.has_account] + + # Append/Override the existing view context values with plugin defined values + run_extension_point( + 'NAU_STUDENT_ACCOUNT_CONTEXT_EXTENSION', + context=context, + request=request, + user=user, + ) + return context + + +def get_user_orders(user): + """Given a user, get the detail of all the orders from the Ecommerce service. + + Args: + user (User): The user to authenticate as when requesting ecommerce. + + Returns: + list of dict, representing orders returned by the Ecommerce service. + """ + user_orders = [] + commerce_configuration = CommerceConfiguration.current() + user_query = {'username': user.username} + + use_cache = commerce_configuration.is_cache_enabled + cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None + commerce_user_orders = get_api_data( + commerce_configuration, + 'orders', + api_client=get_ecommerce_api_client(user), + base_api_url=get_ecommerce_api_base_url(), + querystring=user_query, + cache_key=cache_key + ) + + for order in commerce_user_orders: + if order['status'].lower() == 'complete': + date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ") + order_data = { + 'number': order['number'], + 'price': order['total_excl_tax'], + 'order_date': strftime_localized(date_placed, 'SHORT_DATE'), + 'receipt_url': EcommerceService().get_receipt_page_url(order['number']), + 'lines': order['lines'], + } + user_orders.append(order_data) + + return user_orders + + +def _get_extended_profile_fields(): + """Retrieve the extended profile fields from site configuration to be shown on the + Account Settings page + + Returns: + A list of dicts. Each dict corresponds to a single field. The keys per field are: + "field_name" : name of the field stored in user_profile.meta + "field_label" : The label of the field. + "field_type" : TextField or ListField + "field_options": a list of tuples for options in the dropdown in case of ListField + """ + + extended_profile_fields = [] + fields_already_showing = ['username', 'name', 'email', 'pref-lang', 'country', 'time_zone', 'level_of_education', + 'gender', 'year_of_birth', 'language_proficiencies', 'social_links'] + + field_labels_map = { + "first_name": _("First Name"), + "last_name": _("Last Name"), + "city": _("City"), + "state": _("State/Province/Region"), + "company": _("Company"), + "title": _("Title"), + "job_title": _("Job Title"), + "mailing_address": _("Mailing address"), + "goals": _("Tell us why you're interested in {platform_name}").format( + platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME) + ), + "profession": _("Profession"), + "specialty": _("Specialty"), + "work_experience": _("Work experience") + } + + extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', []) + for field_to_exclude in fields_already_showing: + if field_to_exclude in extended_profile_field_names: + extended_profile_field_names.remove(field_to_exclude) + + extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', []) + extended_profile_field_option_tuples = {} + for field in extended_profile_field_options.keys(): + field_options = extended_profile_field_options[field] + extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options] + + for field in extended_profile_field_names: + field_dict = { + "field_name": field, + "field_label": field_labels_map.get(field, field), + } + + field_options = extended_profile_field_option_tuples.get(field) + if field_options: + field_dict["field_type"] = "ListField" + field_dict["field_options"] = field_options + else: + field_dict["field_type"] = "TextField" + extended_profile_fields.append(field_dict) + + return extended_profile_fields From a14f90dfcbf8e4d43c03fa6c6e091422a63beac6 Mon Sep 17 00:00:00 2001 From: henrry pulgarin Date: Mon, 2 May 2022 17:30:27 -0500 Subject: [PATCH 07/25] fix: missing module --- .../plugins/plugin_extension_points.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 openedx/core/djangoapps/plugins/plugin_extension_points.py diff --git a/openedx/core/djangoapps/plugins/plugin_extension_points.py b/openedx/core/djangoapps/plugins/plugin_extension_points.py new file mode 100644 index 0000000000..f54230aa02 --- /dev/null +++ b/openedx/core/djangoapps/plugins/plugin_extension_points.py @@ -0,0 +1,43 @@ +""" +Plugin extension points module +""" +import logging +from importlib import import_module + +from django.conf import settings + +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + +log = logging.getLogger(__name__) + + +def run_extension_point(extension_point, *args, **kwargs): + """ + Wrapper function to execute any extension point at platform level + If exceptions occurs returns None + """ + path = None + try: + path = configuration_helpers.get_value( + extension_point, + getattr(settings, extension_point, None) + ) + if not path: + return + except AttributeError: + return + + try: + module, func_name = path.rsplit('.', 1) + module = import_module(module) + extension_function = getattr(module, func_name) + return extension_function(*args, **kwargs) + except ImportError as e: + log.error('Could not import the extension point %s : %s with message: %s', extension_point, module, str(e)) + return + except AttributeError: + log.error('Could not import the function %s in the module %s', func_name, module) + return + except ValueError: + log.error('Could not load the information from \"%s\"', path) + return From 2b83ce41a736cecf5d9fc405ff78e9b6aab25276 Mon Sep 17 00:00:00 2001 From: henrry pulgarin Date: Tue, 3 May 2022 17:13:37 -0500 Subject: [PATCH 08/25] fix: import modules path --- openedx/core/djangoapps/user_authn/utils.py | 92 +++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index 1ed91efd71..98d5469d6b 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -60,9 +60,101 @@ def is_safe_login_or_logout_redirect(redirect_to, request_host, dot_client_id, r redirect_to, allowed_hosts=login_redirect_whitelist, require_https=require_https ) + return url_has_allowed_host_and_scheme +def password_complexity(): + """ + Inspect AUTH_PASSWORD_VALIDATORS setting and generate a dict with the requirements for + usage in the generate_password function + """ + password_validators = settings.AUTH_PASSWORD_VALIDATORS + known_validators = { + "common.djangoapps.util.password_policy_validators.MinimumLengthValidator": "min_length", + "common.djangoapps.util.password_policy_validators.MaximumLengthValidator": "max_length", + "common.djangoapps.util.password_policy_validators.AlphabeticValidator": "min_alphabetic", + "common.djangoapps.util.password_policy_validators.UppercaseValidator": "min_upper", + "common.djangoapps.util.password_policy_validators.LowerValidator": "min_lower", + "common.djangoapps.util.password_policy_validators.NumericValidator": "min_numeric", + "common.djangoapps.util.password_policy_validators.PunctuationValidator": "min_punctuation", + "common.djangoapps.util.password_policy_validators.SymbolValidator": "min_symbol", + } + complexity = {} + + for validator in password_validators: + param_name = known_validators.get(validator["NAME"], None) + if param_name is not None: + complexity[param_name] = validator["OPTIONS"].get(param_name, 0) + + # merge alphabetic with lower and uppercase + if complexity.get("min_alphabetic") and ( + complexity.get("min_lower") or complexity.get("min_upper") + ): + complexity["min_alphabetic"] = max( + 0, + complexity["min_alphabetic"] + - complexity.get("min_lower", 0) + - complexity.get("min_upper", 0), + ) + + return complexity + + +def generate_password(length=12, chars=string.ascii_letters + string.digits): + """Generate a valid random password""" + if length < 8: + raise ValueError("password must be at least 8 characters") + + password = '' + choice = random.SystemRandom().choice + non_ascii_characters = [ + '£', + '¥', + '€', + '©', + '®', + '™', + '†', + '§', + '¶', + 'π', + 'μ', + '±', + ] + + complexity = password_complexity() + password_length = max(length, complexity.get('min_length')) + + password_rules = { + 'min_lower': list(string.ascii_lowercase), + 'min_upper': list(string.ascii_uppercase), + 'min_alphabetic': list(string.ascii_letters), + 'min_numeric': list(string.digits), + 'min_punctuation': list(string.punctuation), + 'min_symbol': list(non_ascii_characters), + } + + for rule, elems in password_rules.items(): + needed = complexity.get(rule, 0) + for _ in range(needed): + next_char = choice(elems) + password += next_char + elems.remove(next_char) + + # fill the password to reach password_length + if len(password) < password_length: + password += ''.join( + [choice(chars) for _ in range(password_length - len(password))] + ) + + password_list = list(password) + random.shuffle(password_list) + + password = ''.join(password_list) + return password + + def is_registration_api_v1(request): """ Checks if registration api is v1 From 52a693eae69bef7742b0cc15456116dbcd011c70 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Fri, 12 Apr 2024 17:10:18 +0100 Subject: [PATCH 09/25] feat: move custom data authorization to register bottom The NAU custom field data_authorization should be put after the optional fields. The motivation is that we need to add multiple authorization checkboxes to the register page, requested by the DPO. fccn/nau-technical#83 --- lms/static/js/student_account/views/RegisterView.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index ab0aa77c92..dac32b8a70 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -281,6 +281,9 @@ // xss-lint: disable=javascript-jquery-insert-into-target $('.checkbox-terms_of_service').insertAfter('.optional-fields'); + // NAU custom, move this to be the first after the optional fields + $('.checkbox-data_authorization').insertAfter('.optional-fields'); + // Clicking on links inside a label should open that link. $('label a').click(function(ev) { ev.stopPropagation(); From 028b3782d9555ce64a1c60faa35a439164aea4ca Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Tue, 12 Sep 2023 15:23:54 +0100 Subject: [PATCH 10/25] fix: generate password with configured validators GN-1236 --- lms/djangoapps/support/views/manage_user.py | 2 +- openedx/core/djangoapps/user_api/accounts/forms.py | 1 + .../management/commands/cancel_user_retirement_request.py | 2 ++ openedx/core/djangoapps/user_authn/utils.py | 2 +- openedx/core/djangoapps/user_authn/views/auto_auth.py | 2 +- openedx/core/djangoapps/user_authn/views/register.py | 2 +- 6 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/support/views/manage_user.py b/lms/djangoapps/support/views/manage_user.py index e29652a905..958092982a 100644 --- a/lms/djangoapps/support/views/manage_user.py +++ b/lms/djangoapps/support/views/manage_user.py @@ -18,7 +18,7 @@ from openedx.core.djangoapps.user_api.accounts.serializers import AccountUserSerializer from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models -from edx_django_utils.user import generate_password # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.user_authn.utils import generate_password # lint-amnesty, pylint: disable=wrong-import-order class ManageUserSupportView(View): diff --git a/openedx/core/djangoapps/user_api/accounts/forms.py b/openedx/core/djangoapps/user_api/accounts/forms.py index 771b6a7ccf..4eb292c06a 100644 --- a/openedx/core/djangoapps/user_api/accounts/forms.py +++ b/openedx/core/djangoapps/user_api/accounts/forms.py @@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_cancellation +from openedx.core.djangoapps.user_authn.utils import generate_password class RetirementQueueDeletionForm(forms.Form): diff --git a/openedx/core/djangoapps/user_api/management/commands/cancel_user_retirement_request.py b/openedx/core/djangoapps/user_api/management/commands/cancel_user_retirement_request.py index adb29a4fd3..9b535a2996 100644 --- a/openedx/core/djangoapps/user_api/management/commands/cancel_user_retirement_request.py +++ b/openedx/core/djangoapps/user_api/management/commands/cancel_user_retirement_request.py @@ -12,6 +12,8 @@ from openedx.core.djangoapps.user_api.accounts.utils import handle_retirement_cancellation from openedx.core.djangoapps.user_api.models import UserRetirementStatus +from openedx.core.djangoapps.user_authn.utils import generate_password # lint-amnesty, pylint: disable=wrong-import-order + LOGGER = logging.getLogger(__name__) diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index 98d5469d6b..54835ddd3f 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -75,7 +75,7 @@ def password_complexity(): "common.djangoapps.util.password_policy_validators.MaximumLengthValidator": "max_length", "common.djangoapps.util.password_policy_validators.AlphabeticValidator": "min_alphabetic", "common.djangoapps.util.password_policy_validators.UppercaseValidator": "min_upper", - "common.djangoapps.util.password_policy_validators.LowerValidator": "min_lower", + "common.djangoapps.util.password_policy_validators.LowercaseValidator": "min_lower", "common.djangoapps.util.password_policy_validators.NumericValidator": "min_numeric", "common.djangoapps.util.password_policy_validators.PunctuationValidator": "min_punctuation", "common.djangoapps.util.password_policy_validators.SymbolValidator": "min_symbol", diff --git a/openedx/core/djangoapps/user_authn/views/auto_auth.py b/openedx/core/djangoapps/user_authn/views/auto_auth.py index 324a0c1959..b96d74b832 100644 --- a/openedx/core/djangoapps/user_authn/views/auto_auth.py +++ b/openedx/core/djangoapps/user_authn/views/auto_auth.py @@ -36,7 +36,7 @@ ) from common.djangoapps.util.json_request import JsonResponse -from edx_django_utils.user import generate_password # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.user_authn.utils import generate_password # lint-amnesty, pylint: disable=wrong-import-order def auto_auth(request): # pylint: disable=too-many-statements diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 1529cc12f7..34b71a6513 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -89,7 +89,7 @@ from common.djangoapps.util.db import outer_atomic from common.djangoapps.util.json_request import JsonResponse -from edx_django_utils.user import generate_password # lint-amnesty, pylint: disable=wrong-import-order +from openedx.core.djangoapps.user_authn.utils import generate_password # lint-amnesty, pylint: disable=wrong-import-order log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") From 2397f182b038ae769c5ca2e269d7afa569615771 Mon Sep 17 00:00:00 2001 From: Luis Moreno Date: Thu, 12 Nov 2020 14:19:41 -0400 Subject: [PATCH 11/25] Adding password police support to generate password. --- openedx/core/djangoapps/user_authn/utils.py | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index 54835ddd3f..25ec58548e 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -101,6 +101,43 @@ def password_complexity(): return complexity +def password_complexity(): + """ + Inspect AUTH_PASSWORD_VALIDATORS setting and generate a dict with the requirements for + usage in the generate_password function + """ + password_validators = settings.AUTH_PASSWORD_VALIDATORS + known_validators = { + "util.password_policy_validators.MinimumLengthValidator": "min_length", + "util.password_policy_validators.MaximumLengthValidator": "max_length", + "util.password_policy_validators.AlphabeticValidator": "min_alphabetic", + "util.password_policy_validators.UppercaseValidator": "min_upper", + "util.password_policy_validators.LowerValidator": "min_lower", + "util.password_policy_validators.NumericValidator": "min_numeric", + "util.password_policy_validators.PunctuationValidator": "min_punctuation", + "util.password_policy_validators.SymbolValidator": "min_symbol", + } + complexity = {} + + for validator in password_validators: + param_name = known_validators.get(validator["NAME"], None) + if param_name is not None: + complexity[param_name] = validator["OPTIONS"].get(param_name, 0) + + # merge alphabetic with lower and uppercase + if complexity.get("min_alphabetic") and ( + complexity.get("min_lower") or complexity.get("min_upper") + ): + complexity["min_alphabetic"] = max( + 0, + complexity["min_alphabetic"] + - complexity.get("min_lower", 0) + - complexity.get("min_upper", 0), + ) + + return complexity + + def generate_password(length=12, chars=string.ascii_letters + string.digits): """Generate a valid random password""" if length < 8: From 1898b1e0f413eed4e509e72cb0a5596d796768ea Mon Sep 17 00:00:00 2001 From: Beto Fandino Date: Tue, 2 Jul 2024 15:47:49 -0400 Subject: [PATCH 12/25] fix: import string --- openedx/core/djangoapps/user_authn/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index 25ec58548e..e6ae1d9265 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -6,6 +6,7 @@ import math import random import re +import string from urllib.parse import urlparse # pylint: disable=import-error from uuid import uuid4 # lint-amnesty, pylint: disable=unused-import From 902a9ee07b22abb236009287913933aff85fa514 Mon Sep 17 00:00:00 2001 From: Beto Fandino Date: Tue, 2 Jul 2024 20:47:49 -0400 Subject: [PATCH 13/25] feat: fix cherry-pick --- lms/static/js/student_account/views/RegisterView.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index dac32b8a70..db28d27d23 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -254,11 +254,6 @@ }; FormView.prototype.postRender.call(this); -<<<<<<< HEAD - -======= - ->>>>>>> 941d8f2722 (GN-265 Always show optional fields on registry.) // NAU always show optional fields /*$('.optional-fields').addClass('hidden');*/ $('#toggle_optional_fields').change(function() { From 3cd9c769b32ce33f7a2e7f4a4209d79660f39396 Mon Sep 17 00:00:00 2001 From: henrry pulgarin Date: Tue, 3 May 2022 17:13:37 -0500 Subject: [PATCH 14/25] fix: import modules path --- openedx/core/djangoapps/user_authn/utils.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index e6ae1d9265..f1d517a02a 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -109,14 +109,14 @@ def password_complexity(): """ password_validators = settings.AUTH_PASSWORD_VALIDATORS known_validators = { - "util.password_policy_validators.MinimumLengthValidator": "min_length", - "util.password_policy_validators.MaximumLengthValidator": "max_length", - "util.password_policy_validators.AlphabeticValidator": "min_alphabetic", - "util.password_policy_validators.UppercaseValidator": "min_upper", - "util.password_policy_validators.LowerValidator": "min_lower", - "util.password_policy_validators.NumericValidator": "min_numeric", - "util.password_policy_validators.PunctuationValidator": "min_punctuation", - "util.password_policy_validators.SymbolValidator": "min_symbol", + "common.djangoapps.util.password_policy_validators.MinimumLengthValidator": "min_length", + "common.djangoapps.util.password_policy_validators.MaximumLengthValidator": "max_length", + "common.djangoapps.util.password_policy_validators.AlphabeticValidator": "min_alphabetic", + "common.djangoapps.util.password_policy_validators.UppercaseValidator": "min_upper", + "common.djangoapps.util.password_policy_validators.LowerValidator": "min_lower", + "common.djangoapps.util.password_policy_validators.NumericValidator": "min_numeric", + "common.djangoapps.util.password_policy_validators.PunctuationValidator": "min_punctuation", + "common.djangoapps.util.password_policy_validators.SymbolValidator": "min_symbol", } complexity = {} From 888e52377992cc5e579d35212169bcd2a7d67d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Fernanda=20Magallanes=20Z?= Date: Mon, 16 May 2022 15:59:27 -0400 Subject: [PATCH 15/25] fix: pep8 and pylint issues --- .../js/student_account/views/RegisterView.js | 2 +- .../user_authn/views/registration_form.py | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index db28d27d23..9dfeffa11e 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -269,7 +269,7 @@ // NAU always hide checkbox to show/hide optional fields /*if (!this.hasOptionalFields) {*/ - $('.checkbox-optional_fields_toggle').addClass('hidden'); + $('.checkbox-optional_fields_toggle').addClass('hidden'); /*}*/ // xss-lint: disable=javascript-jquery-insert-into-target $('.checkbox-honor_code').insertAfter('.optional-fields'); diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 91c2ee302e..62baf3947c 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -396,7 +396,7 @@ def __init__(self): custom_form = get_registration_extension_form() if custom_form: - custom_form_field_names = [ field_name for field_name, field in custom_form.fields.items()] + custom_form_field_names = [field_name for field_name, field in custom_form.fields.items()] valid_fields.extend(custom_form_field_names) field_order = configuration_helpers.get_value('REGISTRATION_FIELD_ORDER') @@ -431,7 +431,10 @@ def get_registration_form(self, request): # Custom form fields can be added via the form set in settings.REGISTRATION_EXTENSION_FORM custom_form = get_registration_extension_form() - custom_form_field_names = [ field_name for field_name, field in custom_form.fields.items()] if custom_form else [] + if custom_form: + custom_form_field_names = [field_name for field_name, field in custom_form.fields.items()] + else: + custom_form_field_names = [] # Go through the fields in the fields order and add them if they are required or visible for field_name in self.field_order: @@ -454,7 +457,8 @@ def get_registration_form(self, request): getattr(custom_form, 'Meta', None), 'serialization_options', {} ).get(field_name, {}) - field_type = field_options.get('field_type', FormDescription.FIELD_TYPE_MAP.get(field.__class__)) + field_type = field_options.get('field_type', + FormDescription.FIELD_TYPE_MAP.get(field.__class__)) if not field_type: raise ImproperlyConfigured( u"Field type '{}' not recognized for registration extension field '{}'.".format( @@ -467,8 +471,11 @@ def get_registration_form(self, request): form_desc.add_field( field_name, label=field.label, default=field_options.get('default'), - field_type=field_options.get('field_type', FormDescription.FIELD_TYPE_MAP.get(field.__class__)), - placeholder=field.initial, instructions=field.help_text, required=(self._is_field_required(field_name) or field.required), + field_type=field_options.get('field_type', + FormDescription.FIELD_TYPE_MAP.get(field.__class__)), + placeholder=field.initial, + instructions=field.help_text, + required=(self._is_field_required(field_name) or field.required), restrictions=restrictions, options=getattr(field, 'choices', None), error_messages=field.error_messages, include_default_option=field_options.get('include_default_option'), From ab1b787794fec6cd955995ddb577130701345c19 Mon Sep 17 00:00:00 2001 From: Donato Bracuto Date: Wed, 24 Jul 2024 04:53:43 -0400 Subject: [PATCH 16/25] fix: remove extra code on RegisterView.js --- .../js/student_account/views/RegisterView.js | 121 +----------------- 1 file changed, 2 insertions(+), 119 deletions(-) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 9dfeffa11e..0cf65685a2 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -185,7 +185,7 @@ return this; }, - postRender: function() { + postRender: function() { var inputs = this.$('.form-field'), inputSelectors = 'input, select, textarea', inputTipSelectors = ['tip error', 'tip tip-input'], @@ -254,6 +254,7 @@ }; FormView.prototype.postRender.call(this); + // NAU always show optional fields /*$('.optional-fields').addClass('hidden');*/ $('#toggle_optional_fields').change(function() { @@ -316,124 +317,6 @@ return this; }, - postRender: function() { - var inputs = this.$('.form-field'), - inputSelectors = 'input, select, textarea', - inputTipSelectors = ['tip error', 'tip tip-input'], - inputTipSelectorsHidden = ['tip error hidden', 'tip tip-input hidden'], - onInputFocus = function() { - // Apply on focus styles to input - $(this).find('label').addClass('focus-in') - .removeClass('focus-out'); - - // Show each input tip - $(this).children().each(function() { - if (inputTipSelectorsHidden.indexOf($(this).attr('class')) >= 0) { - $(this).removeClass('hidden'); - } - }); - }, - onInputFocusOut = function() { - // If input has no text apply focus out styles - if ($(this).find(inputSelectors).val().length === 0) { - $(this).find('label').addClass('focus-out') - .removeClass('focus-in'); - } - - // Hide each input tip - $(this).children().each(function() { - // This is a 1 instead of 0 so the error message for a field is not - // hidden on blur and only the help tip is hidden. - if (inputTipSelectors.indexOf($(this).attr('class')) >= 1) { - $(this).addClass('hidden'); - } - }); - }, - handleInputBehavior = function(input) { - // Initially put label in input - if (input.find(inputSelectors).val().length === 0) { - input.find('label').addClass('focus-out') - .removeClass('focus-in'); - } - - // Initially hide each input tip - input.children().each(function() { - if (inputTipSelectors.indexOf($(this).attr('class')) >= 0) { - $(this).addClass('hidden'); - } - }); - - input.focusin(onInputFocus); - input.focusout(onInputFocusOut); - }, - handleAutocomplete = function() { - $(inputs).each(function() { - var $input = $(this), - isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; - - if (!isCheckbox) { - if ($input.find(inputSelectors).val().length === 0 - && !$input.is(':-webkit-autofill')) { - $input.find('label').addClass('focus-out') - .removeClass('focus-in'); - } else { - $input.find('label').addClass('focus-in') - .removeClass('focus-out'); - } - } - }); - }; - - FormView.prototype.postRender.call(this); - $('.optional-fields').addClass('hidden'); - $('#toggle_optional_fields').change(function() { - window.analytics.track('edx.bi.user.register.optional_fields_selected'); - $('.optional-fields').toggleClass('hidden'); - }); - - // Since the honor TOS text has a composed css selector, it is more future proof - // to insert the not toggled optional fields before .honor_tos_combined's parent - // that is the container for the honor TOS text and checkbox. - // xss-lint: disable=javascript-jquery-insert-into-target - $('.exposed-optional-fields').insertBefore( - $('.honor_tos_combined').parent() - ); - - // We are swapping the order of these elements here because the honor code agreement - // is a required checkbox field and the optional fields toggle is a cosmetic - // improvement so that we don't have to show all the optional fields. - // xss-lint: disable=javascript-jquery-insert-into-target - $('.checkbox-optional_fields_toggle').insertAfter('.required-fields'); - if (!this.hasOptionalFields) { - $('.checkbox-optional_fields_toggle').addClass('hidden'); - } - // xss-lint: disable=javascript-jquery-insert-into-target - $('.checkbox-honor_code').insertAfter('.optional-fields'); - // xss-lint: disable=javascript-jquery-insert-into-target - $('.checkbox-terms_of_service').insertAfter('.optional-fields'); - - // Clicking on links inside a label should open that link. - $('label a').click(function(ev) { - ev.stopPropagation(); - ev.preventDefault(); - window.open($(this).attr('href'), $(this).attr('target'), 'noopener'); - }); - $('.form-field').each(function() { - $(this).find('option:first').html(''); - }); - $(inputs).each(function() { - var $input = $(this), - isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; - if ($input.length > 0 && !isCheckbox) { - handleInputBehavior($input); - } - }); - $('#register-confirm_email').bind('cut copy paste', function(e) { - e.preventDefault(); - }); - setTimeout(handleAutocomplete, 1000); - }, - hideRequiredMessageExceptOnError: function($el) { // We only handle blur if not in an error state. if (!$el.hasClass('error')) { From b644449c5ab5b464c63a16c63432d9100a9d5213 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Fri, 15 Nov 2024 11:54:04 +0000 Subject: [PATCH 17/25] fix: registration Get Redwood RegisterView.js file and apply custom NAU code. fccn/nau-technical#337 --- .../js/student_account/views/RegisterView.js | 275 +++++++++--------- 1 file changed, 133 insertions(+), 142 deletions(-) diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 0cf65685a2..8a6e17ef01 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -152,148 +152,16 @@ /* We pass the context object to the template so that * we can perform variable interpolation using sprintf */ - context: { - fields: fields, - currentProvider: this.currentProvider, - syncLearnerProfileData: this.syncLearnerProfileData, - providers: this.providers, - hasSecondaryProviders: this.hasSecondaryProviders, - platformName: this.platformName, - autoRegisterWelcomeMessage: this.autoRegisterWelcomeMessage, - registerFormSubmitButtonText: this.registerFormSubmitButtonText, - is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled - } - }); - - HtmlUtils.setHtml($(this.el), HtmlUtils.HTML(renderHtml)); - - this.postRender(); - - // Must be called after postRender, since postRender sets up $formFeedback. - if (this.errorMessage) { - this.renderErrors(formErrorsTitle, [this.errorMessage]); - } else if (this.currentProvider && !this.hideAuthWarnings) { - this.renderAuthWarning(); - } - - if (this.autoSubmit) { - $(this.el).hide(); - $('#register-honor_code, #register-terms_of_service').prop('checked', true); - this.submitForm(); - } - - return this; - }, - - postRender: function() { - var inputs = this.$('.form-field'), - inputSelectors = 'input, select, textarea', - inputTipSelectors = ['tip error', 'tip tip-input'], - inputTipSelectorsHidden = ['tip error hidden', 'tip tip-input hidden'], - onInputFocus = function() { - // Apply on focus styles to input - $(this).find('label').addClass('focus-in') - .removeClass('focus-out'); - - // Show each input tip - $(this).children().each(function() { - if (inputTipSelectorsHidden.indexOf($(this).attr('class')) >= 0) { - $(this).removeClass('hidden'); - } - }); - }, - onInputFocusOut = function() { - // If input has no text apply focus out styles - if ($(this).find(inputSelectors).val().length === 0) { - $(this).find('label').addClass('focus-out') - .removeClass('focus-in'); - } - - // Hide each input tip - $(this).children().each(function() { - // This is a 1 instead of 0 so the error message for a field is not - // hidden on blur and only the help tip is hidden. - if (inputTipSelectors.indexOf($(this).attr('class')) >= 1) { - $(this).addClass('hidden'); - } - }); - }, - handleInputBehavior = function(input) { - // Initially put label in input - if (input.find(inputSelectors).val().length === 0) { - input.find('label').addClass('focus-out') - .removeClass('focus-in'); - } - - // Initially hide each input tip - input.children().each(function() { - if (inputTipSelectors.indexOf($(this).attr('class')) >= 0) { - $(this).addClass('hidden'); - } - }); - - input.focusin(onInputFocus); - input.focusout(onInputFocusOut); - }, - handleAutocomplete = function() { - $(inputs).each(function() { - var $input = $(this), - isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; - - if (!isCheckbox) { - if ($input.find(inputSelectors).val().length === 0 - && !$input.is(':-webkit-autofill')) { - $input.find('label').addClass('focus-out') - .removeClass('focus-in'); - } else { - $input.find('label').addClass('focus-in') - .removeClass('focus-out'); - } - } - }); - }; - - FormView.prototype.postRender.call(this); - - // NAU always show optional fields - /*$('.optional-fields').addClass('hidden');*/ - $('#toggle_optional_fields').change(function() { - window.analytics.track('edx.bi.user.register.optional_fields_selected'); - $('.optional-fields').toggleClass('hidden'); - }); - - // We are swapping the order of these elements here because the honor code agreement - // is a required checkbox field and the optional fields toggle is a cosmetic - // improvement so that we don't have to show all the optional fields. - // xss-lint: disable=javascript-jquery-insert-into-target - $('.checkbox-optional_fields_toggle').insertAfter('.required-fields'); - - // NAU always hide checkbox to show/hide optional fields - /*if (!this.hasOptionalFields) {*/ - $('.checkbox-optional_fields_toggle').addClass('hidden'); - /*}*/ - // xss-lint: disable=javascript-jquery-insert-into-target - $('.checkbox-honor_code').insertAfter('.optional-fields'); - // xss-lint: disable=javascript-jquery-insert-into-target - $('.checkbox-terms_of_service').insertAfter('.optional-fields'); - - // NAU custom, move this to be the first after the optional fields - $('.checkbox-data_authorization').insertAfter('.optional-fields'); - - // Clicking on links inside a label should open that link. - $('label a').click(function(ev) { - ev.stopPropagation(); - ev.preventDefault(); - window.open($(this).attr('href'), $(this).attr('target'), 'noopener'); - }); - $('.form-field').each(function() { - $(this).find('option:first').html(''); - }); - $(inputs).each(function() { - var $input = $(this), - isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; - if ($input.length > 0 && !isCheckbox) { - handleInputBehavior($input); + context: { + fields: fields, + currentProvider: this.currentProvider, + syncLearnerProfileData: this.syncLearnerProfileData, + providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, + platformName: this.platformName, + autoRegisterWelcomeMessage: this.autoRegisterWelcomeMessage, + registerFormSubmitButtonText: this.registerFormSubmitButtonText, + is_require_third_party_auth_enabled: this.is_require_third_party_auth_enabled } }); @@ -317,6 +185,129 @@ return this; }, + postRender: function() { + var inputs = this.$('.form-field'), + inputSelectors = 'input, select, textarea', + inputTipSelectors = ['tip error', 'tip tip-input'], + inputTipSelectorsHidden = ['tip error hidden', 'tip tip-input hidden'], + onInputFocus = function() { + // Apply on focus styles to input + $(this).find('label').addClass('focus-in') + .removeClass('focus-out'); + + // Show each input tip + $(this).children().each(function() { + if (inputTipSelectorsHidden.indexOf($(this).attr('class')) >= 0) { + $(this).removeClass('hidden'); + } + }); + }, + onInputFocusOut = function() { + // If input has no text apply focus out styles + if ($(this).find(inputSelectors).val().length === 0) { + $(this).find('label').addClass('focus-out') + .removeClass('focus-in'); + } + + // Hide each input tip + $(this).children().each(function() { + // This is a 1 instead of 0 so the error message for a field is not + // hidden on blur and only the help tip is hidden. + if (inputTipSelectors.indexOf($(this).attr('class')) >= 1) { + $(this).addClass('hidden'); + } + }); + }, + handleInputBehavior = function(input) { + // Initially put label in input + if (input.find(inputSelectors).val().length === 0) { + input.find('label').addClass('focus-out') + .removeClass('focus-in'); + } + + // Initially hide each input tip + input.children().each(function() { + if (inputTipSelectors.indexOf($(this).attr('class')) >= 0) { + $(this).addClass('hidden'); + } + }); + + input.focusin(onInputFocus); + input.focusout(onInputFocusOut); + }, + handleAutocomplete = function() { + $(inputs).each(function() { + var $input = $(this), + isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; + + if (!isCheckbox) { + if ($input.find(inputSelectors).val().length === 0 + && !$input.is(':-webkit-autofill')) { + $input.find('label').addClass('focus-out') + .removeClass('focus-in'); + } else { + $input.find('label').addClass('focus-in') + .removeClass('focus-out'); + } + } + }); + }; + + FormView.prototype.postRender.call(this); + // NAU always show optional fields + // $('.optional-fields').addClass('hidden'); + $('#toggle_optional_fields').change(function() { + window.analytics.track('edx.bi.user.register.optional_fields_selected'); + $('.optional-fields').toggleClass('hidden'); + }); + + // Since the honor TOS text has a composed css selector, it is more future proof + // to insert the not toggled optional fields before .honor_tos_combined's parent + // that is the container for the honor TOS text and checkbox. + // xss-lint: disable=javascript-jquery-insert-into-target + $('.exposed-optional-fields').insertBefore( + $('.honor_tos_combined').parent() + ); + + // We are swapping the order of these elements here because the honor code agreement + // is a required checkbox field and the optional fields toggle is a cosmetic + // improvement so that we don't have to show all the optional fields. + // xss-lint: disable=javascript-jquery-insert-into-target + $('.checkbox-optional_fields_toggle').insertAfter('.required-fields'); + // NAU always hide checkbox to show/hide optional fields + // if (!this.hasOptionalFields) { + $('.checkbox-optional_fields_toggle').addClass('hidden'); + // } + // xss-lint: disable=javascript-jquery-insert-into-target + $('.checkbox-honor_code').insertAfter('.optional-fields'); + // xss-lint: disable=javascript-jquery-insert-into-target + $('.checkbox-terms_of_service').insertAfter('.optional-fields'); + + // NAU custom, move this to be the first after the optional fields + $('.checkbox-data_authorization').insertAfter('.optional-fields') + + // Clicking on links inside a label should open that link. + $('label a').click(function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + window.open($(this).attr('href'), $(this).attr('target'), 'noopener'); + }); + $('.form-field').each(function() { + $(this).find('option:first').html(''); + }); + $(inputs).each(function() { + var $input = $(this), + isCheckbox = $input.attr('class').indexOf('checkbox') !== -1; + if ($input.length > 0 && !isCheckbox) { + handleInputBehavior($input); + } + }); + $('#register-confirm_email').bind('cut copy paste', function(e) { + e.preventDefault(); + }); + setTimeout(handleAutocomplete, 1000); + }, + hideRequiredMessageExceptOnError: function($el) { // We only handle blur if not in an error state. if (!$el.hasClass('error')) { From 973f557595f5c675bbcbc9f7e296d9741ccd6310 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Fri, 15 Nov 2024 17:32:26 +0000 Subject: [PATCH 18/25] fix: merge for redwood Just copy the missing code that was lost during upgrade NAU code for redwood. --- .../djangoapps/user_authn/views/registration_form.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 62baf3947c..fa36af50ac 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -426,7 +426,7 @@ def get_registration_form(self, request): Returns: HttpResponse """ - form_desc = FormDescription("post", reverse("user_api_registration")) + form_desc = FormDescription("post", self._get_registration_submit_url(request)) self._apply_third_party_auth_overrides(request, form_desc) # Custom form fields can be added via the form set in settings.REGISTRATION_EXTENSION_FORM @@ -461,7 +461,7 @@ def get_registration_form(self, request): FormDescription.FIELD_TYPE_MAP.get(field.__class__)) if not field_type: raise ImproperlyConfigured( - u"Field type '{}' not recognized for registration extension field '{}'.".format( + "Field type '{}' not recognized for registration extension field '{}'.".format( field_type, field_name @@ -475,12 +475,19 @@ def get_registration_form(self, request): FormDescription.FIELD_TYPE_MAP.get(field.__class__)), placeholder=field.initial, instructions=field.help_text, + exposed=self._is_field_exposed(field_name), required=(self._is_field_required(field_name) or field.required), restrictions=restrictions, options=getattr(field, 'choices', None), error_messages=field.error_messages, include_default_option=field_options.get('include_default_option'), ) + # remove confirm_email form v1 registration form + if is_api_v1(request): + for index, field in enumerate(form_desc.fields): + if field['name'] == 'confirm_email': + del form_desc.fields[index] + break return form_desc def _get_registration_submit_url(self, request): From af47f1ad496d67d06ba21c8fe36ae0697fc1bd49 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 11 Dec 2024 17:13:36 -0500 Subject: [PATCH 19/25] feat: add support for extended checkbox field in account settings --- .../views/account_settings_factory.js | 505 +++++++++++++++++ .../views/account_settings_fields.js | 526 ++++++++++++++++++ .../fields/field_checkbox_account.underscore | 13 + 3 files changed, 1044 insertions(+) create mode 100644 lms/static/js/student_account/views/account_settings_factory.js create mode 100644 lms/static/js/student_account/views/account_settings_fields.js create mode 100644 lms/templates/fields/field_checkbox_account.underscore diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js new file mode 100644 index 0000000000..c39eecff14 --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -0,0 +1,505 @@ +// eslint-disable-next-line no-shadow-restricted-names +(function(define, undefined) { + 'use strict'; + + define([ + 'gettext', 'jquery', 'underscore', 'backbone', 'logger', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/student_account/views/account_settings_fields', + 'js/student_account/views/account_settings_view', + 'edx-ui-toolkit/js/utils/string-utils', + 'edx-ui-toolkit/js/utils/html-utils' + ], function(gettext, $, _, Backbone, Logger, UserAccountModel, UserPreferencesModel, + AccountSettingsFieldViews, AccountSettingsView, StringUtils, HtmlUtils) { + return function( + fieldsData, + disableOrderHistoryTab, + ordersHistoryData, + authData, + passwordResetSupportUrl, + userAccountsApiUrl, + userPreferencesApiUrl, + accountUserId, + platformName, + contactEmail, + allowEmailChange, + enableCoppaCompliance, + socialPlatforms, + syncLearnerProfileData, + enterpriseName, + enterpriseReadonlyAccountFields, + edxSupportUrl, + extendedProfileFields, + displayAccountDeletion, + isSecondaryEmailFeatureEnabled, + betaLanguage + ) { + var $accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData, + accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage, + showLoadingError, orderNumber, getUserField, userFields, timeZoneDropdownField, countryDropdownField, + emailFieldView, secondaryEmailFieldView, socialFields, accountDeletionFields, platformData, + aboutSectionMessageType, aboutSectionMessage, fullnameFieldView, countryFieldView, + fullNameFieldData, emailFieldData, secondaryEmailFieldData, countryFieldData, additionalFields, + fieldItem, emailFieldViewIndex, focusId, yearOfBirthViewIndex, levelOfEducationFieldData, + tabIndex = 0; + + $accountSettingsElement = $('.wrapper-account-settings'); + + userAccountModel = new UserAccountModel(); + userAccountModel.url = userAccountsApiUrl; + + userPreferencesModel = new UserPreferencesModel(); + userPreferencesModel.url = userPreferencesApiUrl; + + if (syncLearnerProfileData && enterpriseName) { + aboutSectionMessageType = 'info'; + aboutSectionMessage = HtmlUtils.interpolateHtml( + gettext('Your profile settings are managed by {enterprise_name}. Contact your administrator or {link_start}edX Support{link_end} for help.'), // eslint-disable-line max-len + { + enterprise_name: enterpriseName, + link_start: HtmlUtils.HTML( + StringUtils.interpolate( + '', { + edx_support_url: edxSupportUrl + } + ) + ), + link_end: HtmlUtils.HTML('') + } + ); + } + + emailFieldData = { + model: userAccountModel, + title: gettext('Email Address (Sign In)'), + valueAttribute: 'email', + helpMessage: StringUtils.interpolate( + gettext('You receive messages from {platform_name} and course teams at this address.'), // eslint-disable-line max-len + {platform_name: platformName} + ), + persistChanges: true + }; + if (!allowEmailChange || (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('email') !== -1)) { // eslint-disable-line max-len + emailFieldView = { + view: new AccountSettingsFieldViews.ReadonlyFieldView(emailFieldData) + }; + } else { + emailFieldView = { + view: new AccountSettingsFieldViews.EmailFieldView(emailFieldData) + }; + } + + secondaryEmailFieldData = { + model: userAccountModel, + title: gettext('Recovery Email Address'), + valueAttribute: 'secondary_email', + helpMessage: gettext('You may access your account with this address if single-sign on or access to your primary email is not available.'), // eslint-disable-line max-len + persistChanges: true + }; + + fullNameFieldData = { + model: userAccountModel, + title: gettext('Full Name'), + valueAttribute: 'name', + helpMessage: gettext('The name that is used for ID verification and that appears on your certificates.'), // eslint-disable-line max-len, + persistChanges: true + }; + if (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('name') !== -1) { + fullnameFieldView = { + view: new AccountSettingsFieldViews.ReadonlyFieldView(fullNameFieldData) + }; + } else { + fullnameFieldView = { + view: new AccountSettingsFieldViews.TextFieldView(fullNameFieldData) + }; + } + + countryFieldData = { + model: userAccountModel, + required: true, + title: gettext('Country or Region of Residence'), + valueAttribute: 'country', + options: fieldsData.country.options, + persistChanges: true, + helpMessage: gettext('The country or region where you live.') + }; + if (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('country') !== -1) { + countryFieldData.editable = 'never'; + countryFieldView = { + view: new AccountSettingsFieldViews.DropdownFieldView( + countryFieldData + ) + }; + } else { + countryFieldView = { + view: new AccountSettingsFieldViews.DropdownFieldView(countryFieldData) + }; + } + + levelOfEducationFieldData = fieldsData.level_of_education.options; + if (enableCoppaCompliance) { + levelOfEducationFieldData = levelOfEducationFieldData.filter(option => option[0] !== 'el'); + } + + aboutSectionsData = [ + { + title: gettext('Basic Account Information'), + subtitle: gettext('These settings include basic information about your account.'), + + messageType: aboutSectionMessageType, + message: aboutSectionMessage, + + fields: [ + { + view: new AccountSettingsFieldViews.ReadonlyFieldView({ + model: userAccountModel, + title: gettext('Username'), + valueAttribute: 'username', + helpMessage: StringUtils.interpolate( + gettext('The name that identifies you on {platform_name}. You cannot change your username.'), // eslint-disable-line max-len + {platform_name: platformName} + ) + }) + }, + fullnameFieldView, + emailFieldView, + { + view: new AccountSettingsFieldViews.PasswordFieldView({ + model: userAccountModel, + title: gettext('Password'), + screenReaderTitle: gettext('Reset Your Password'), + valueAttribute: 'password', + emailAttribute: 'email', + passwordResetSupportUrl: passwordResetSupportUrl, + linkTitle: gettext('Reset Your Password'), + linkHref: fieldsData.password.url, + helpMessage: gettext('Check your email account for instructions to reset your password.') // eslint-disable-line max-len + }) + }, + { + view: new AccountSettingsFieldViews.LanguagePreferenceFieldView({ + model: userPreferencesModel, + title: gettext('Language'), + valueAttribute: 'pref-lang', + required: true, + refreshPageOnSave: true, + helpMessage: StringUtils.interpolate( + gettext('The language used throughout this site. This site is currently available in a limited number of languages. Changing the value of this field will cause the page to refresh.'), // eslint-disable-line max-len + {platform_name: platformName} + ), + options: fieldsData.language.options, + persistChanges: true, + focusNextID: '#u-field-select-country' + }) + }, + countryFieldView, + { + view: new AccountSettingsFieldViews.TimeZoneFieldView({ + model: userPreferencesModel, + required: true, + title: gettext('Time Zone'), + valueAttribute: 'time_zone', + helpMessage: gettext('Select the time zone for displaying course dates. If you do not specify a time zone, course dates, including assignment deadlines, will be displayed in your browser\'s local time zone.'), // eslint-disable-line max-len + groupOptions: [{ + groupTitle: gettext('All Time Zones'), + selectOptions: fieldsData.time_zone.options, + nullValueOptionLabel: gettext('Default (Local Time Zone)') + }], + persistChanges: true + }) + } + ] + }, + { + title: gettext('Additional Information'), + fields: [ + { + view: new AccountSettingsFieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Education Completed'), + valueAttribute: 'level_of_education', + options: levelOfEducationFieldData, + persistChanges: true + }) + }, + { + view: new AccountSettingsFieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Gender'), + valueAttribute: 'gender', + options: fieldsData.gender.options, + persistChanges: true + }) + }, + { + view: new AccountSettingsFieldViews.DropdownFieldView({ + model: userAccountModel, + title: gettext('Year of Birth'), + valueAttribute: 'year_of_birth', + options: fieldsData.year_of_birth.options, + persistChanges: true + }) + }, + { + view: new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + model: userAccountModel, + title: gettext('Preferred Language'), + valueAttribute: 'language_proficiencies', + options: fieldsData.preferred_language.options, + persistChanges: true + }) + } + ] + } + ]; + + if (enableCoppaCompliance) { + yearOfBirthViewIndex = aboutSectionsData[1].fields.findIndex(function(field) { + return field.view.options.valueAttribute === 'year_of_birth'; + }); + aboutSectionsData[1].fields.splice(yearOfBirthViewIndex, 1); + } + + // Secondary email address + if (isSecondaryEmailFeatureEnabled) { + secondaryEmailFieldView = { + view: new AccountSettingsFieldViews.EmailFieldView(secondaryEmailFieldData), + successMessage: function() { + return HtmlUtils.joinHtml( + this.indicators.success, + StringUtils.interpolate( + gettext('We\'ve sent a confirmation message to {new_secondary_email_address}. Click the link in the message to update your secondary email address.'), // eslint-disable-line max-len + { + new_secondary_email_address: this.fieldValue() + } + ) + ); + } + }; + emailFieldViewIndex = aboutSectionsData[0].fields.indexOf(emailFieldView); + + // Insert secondary email address after email address field. + aboutSectionsData[0].fields.splice( + emailFieldViewIndex + 1, 0, secondaryEmailFieldView + ); + } + + // Add the extended profile fields + additionalFields = aboutSectionsData[1]; + for (var field in extendedProfileFields) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len + fieldItem = extendedProfileFields[field]; + if (fieldItem.field_type === 'TextField') { + additionalFields.fields.push({ + view: new AccountSettingsFieldViews.ExtendedFieldTextFieldView({ + model: userAccountModel, + title: fieldItem.field_label, + fieldName: fieldItem.field_name, + valueAttribute: 'extended_profile', + persistChanges: true + }) + }); + } else if (fieldItem.field_type === 'ListField') { + additionalFields.fields.push({ + view: new AccountSettingsFieldViews.ExtendedFieldListFieldView({ + model: userAccountModel, + title: fieldItem.field_label, + fieldName: fieldItem.field_name, + options: fieldItem.field_options, + valueAttribute: 'extended_profile', + persistChanges: true + }) + }); + } else { + if (fieldItem.field_type === 'CheckboxField') { + additionalFields.fields.push({ + view: new AccountSettingsFieldViews.ExtendedFieldCheckboxFieldView({ + model: userAccountModel, + title: fieldItem.field_label, + fieldName: fieldItem.field_name, + valueAttribute: 'extended_profile', + persistChanges: true + }) + }); + } + } + } + + // Add the social link fields + socialFields = { + title: gettext('Social Media Links'), + subtitle: gettext('Optionally, link your personal accounts to the social media icons on your edX profile.'), // eslint-disable-line max-len + fields: [] + }; + + for (var socialPlatform in socialPlatforms) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len + platformData = socialPlatforms[socialPlatform]; + socialFields.fields.push( + { + view: new AccountSettingsFieldViews.SocialLinkTextFieldView({ + model: userAccountModel, + title: StringUtils.interpolate( + gettext('{platform_display_name} Link'), + {platform_display_name: platformData.display_name} + ), + valueAttribute: 'social_links', + helpMessage: StringUtils.interpolate( + gettext('Enter your {platform_display_name} username or the URL to your {platform_display_name} page. Delete the URL to remove the link.'), // eslint-disable-line max-len + {platform_display_name: platformData.display_name} + ), + platform: socialPlatform, + persistChanges: true, + placeholder: platformData.example + }) + } + ); + } + aboutSectionsData.push(socialFields); + + // Add account deletion fields + if (displayAccountDeletion) { + accountDeletionFields = { + title: gettext('Delete My Account'), + fields: [], + // Used so content can be rendered external to Backbone + domHookId: 'account-deletion-container' + }; + aboutSectionsData.push(accountDeletionFields); + } + + // set TimeZoneField to listen to CountryField + + getUserField = function(list, search) { + // eslint-disable-next-line no-shadow + return _.find(list, function(field) { + return field.view.options.valueAttribute === search; + }).view; + }; + userFields = _.find(aboutSectionsData, function(section) { + return section.title === gettext('Basic Account Information'); + }).fields; + timeZoneDropdownField = getUserField(userFields, 'time_zone'); + countryDropdownField = getUserField(userFields, 'country'); + timeZoneDropdownField.listenToCountryView(countryDropdownField); + + accountsSectionData = [ + { + title: gettext('Linked Accounts'), + subtitle: StringUtils.interpolate( + gettext('You can link your social media accounts to simplify signing in to {platform_name}.'), + {platform_name: platformName} + ), + fields: _.map(authData.providers, function(provider) { + return { + view: new AccountSettingsFieldViews.AuthFieldView({ + title: provider.name, + valueAttribute: 'auth-' + provider.id, + helpMessage: '', + connected: provider.connected, + connectUrl: provider.connect_url, + acceptsLogins: provider.accepts_logins, + disconnectUrl: provider.disconnect_url, + platformName: platformName + }) + }; + }) + } + ]; + + ordersHistoryData.unshift( + { + title: gettext('ORDER NAME'), + order_date: gettext('ORDER PLACED'), + price: gettext('TOTAL'), + number: gettext('ORDER NUMBER') + } + ); + + ordersSectionData = [ + { + title: gettext('My Orders'), + subtitle: StringUtils.interpolate( + gettext('This page contains information about orders that you have placed with {platform_name}.'), // eslint-disable-line max-len + {platform_name: platformName} + ), + fields: _.map(ordersHistoryData, function(order) { + orderNumber = order.number; + if (orderNumber === 'ORDER NUMBER') { + orderNumber = 'orderId'; + } + return { + view: new AccountSettingsFieldViews.OrderHistoryFieldView({ + totalPrice: order.price, + orderId: order.number, + orderDate: order.order_date, + receiptUrl: order.receipt_url, + valueAttribute: 'order-' + orderNumber, + lines: order.lines + }) + }; + }) + } + ]; + + accountSettingsView = new AccountSettingsView({ + model: userAccountModel, + accountUserId: accountUserId, + el: $accountSettingsElement, + tabSections: { + aboutTabSections: aboutSectionsData, + accountsTabSections: accountsSectionData, + ordersTabSections: ordersSectionData + }, + userPreferencesModel: userPreferencesModel, + disableOrderHistoryTab: disableOrderHistoryTab, + betaLanguage: betaLanguage + }); + + accountSettingsView.render(); + focusId = $.cookie('focus_id'); + if (focusId) { + // eslint-disable-next-line no-bitwise + if (~focusId.indexOf('beta-language')) { + tabIndex = -1; + + // Scroll to top of selected element + $('html, body').animate({ + scrollTop: $(focusId).offset().top + }, 'slow'); + } + $(focusId).attr({tabindex: tabIndex}).focus(); + // Deleting the cookie + document.cookie = 'focus_id=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/account;'; + } + showAccountSettingsPage = function() { + // Record that the account settings page was viewed. + Logger.log('edx.user.settings.viewed', { + page: 'account', + visibility: null, + user_id: accountUserId + }); + }; + + showLoadingError = function() { + accountSettingsView.showLoadingError(); + }; + + userAccountModel.fetch({ + success: function() { + // Fetch the user preferences model + userPreferencesModel.fetch({ + success: showAccountSettingsPage, + error: showLoadingError + }); + }, + error: showLoadingError + }); + + return { + userAccountModel: userAccountModel, + userPreferencesModel: userPreferencesModel, + accountSettingsView: accountSettingsView + }; + }; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js new file mode 100644 index 0000000000..a6566ea25f --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -0,0 +1,526 @@ +// eslint-disable-next-line no-shadow-restricted-names +(function(define, undefined) { + 'use strict'; + + define([ + 'gettext', + 'jquery', + 'underscore', + 'backbone', + 'js/views/fields', + 'text!templates/fields/field_checkbox_account.underscore', + 'text!templates/fields/field_text_account.underscore', + 'text!templates/fields/field_readonly_account.underscore', + 'text!templates/fields/field_link_account.underscore', + 'text!templates/fields/field_dropdown_account.underscore', + 'text!templates/fields/field_social_link_account.underscore', + 'text!templates/fields/field_order_history.underscore', + 'edx-ui-toolkit/js/utils/string-utils', + 'edx-ui-toolkit/js/utils/html-utils' + ], function( + gettext, $, _, Backbone, + FieldViews, + field_checkbox_account_template, + field_text_account_template, + field_readonly_account_template, + field_link_account_template, + field_dropdown_account_template, + field_social_link_template, + field_order_history_template, + StringUtils, + HtmlUtils + ) { + var AccountSettingsFieldViews = { + ReadonlyFieldView: FieldViews.ReadonlyFieldView.extend({ + fieldTemplate: field_readonly_account_template + }), + TextFieldView: FieldViews.TextFieldView.extend({ + fieldTemplate: field_text_account_template + }), + DropdownFieldView: FieldViews.DropdownFieldView.extend({ + fieldTemplate: field_dropdown_account_template + }), + EmailFieldView: FieldViews.TextFieldView.extend({ + fieldTemplate: field_text_account_template, + successMessage: function() { + return HtmlUtils.joinHtml( + this.indicators.success, + StringUtils.interpolate( + gettext('We\'ve sent a confirmation message to {new_email_address}. Click the link in the message to update your email address.'), // eslint-disable-line max-len + {new_email_address: this.fieldValue()} + ) + ); + } + }), + LanguagePreferenceFieldView: FieldViews.DropdownFieldView.extend({ + fieldTemplate: field_dropdown_account_template, + + initialize: function(options) { + this._super(options); // eslint-disable-line no-underscore-dangle + this.listenTo(this.model, 'revertValue', this.revertValue); + }, + + revertValue: function(event) { + var attributes = {}, + oldPrefLang = $(event.target).data('old-lang-code'); + + if (oldPrefLang) { + attributes['pref-lang'] = oldPrefLang; + this.saveAttributes(attributes); + } + }, + + saveSucceeded: function() { + var data = { + language: this.modelValue(), + next: window.location.href + }; + + var view = this; + $.ajax({ + type: 'POST', + url: '/i18n/setlang/', + data: data, + dataType: 'html', + success: function() { + view.showSuccessMessage(); + }, + error: function() { + view.showNotificationMessage( + HtmlUtils.joinHtml( + view.indicators.error, + gettext('You must sign out and sign back in before your language changes take effect.') // eslint-disable-line max-len + ) + ); + } + }); + } + + }), + TimeZoneFieldView: FieldViews.DropdownFieldView.extend({ + fieldTemplate: field_dropdown_account_template, + + initialize: function(options) { + this.options = _.extend({}, options); + _.bindAll(this, 'listenToCountryView', 'updateCountrySubheader', 'replaceOrAddGroupOption'); + this._super(options); // eslint-disable-line no-underscore-dangle + }, + + listenToCountryView: function(view) { + this.listenTo(view.model, 'change:country', this.updateCountrySubheader); + }, + + updateCountrySubheader: function(user) { + var view = this; + $.ajax({ + type: 'GET', + url: '/api/user/v1/preferences/time_zones/', + data: {country_code: user.attributes.country}, + success: function(data) { + var countryTimeZones = $.map(data, function(timeZoneInfo) { + return [[timeZoneInfo.time_zone, timeZoneInfo.description]]; + }); + view.replaceOrAddGroupOption( + 'Country Time Zones', + countryTimeZones + ); + view.render(); + } + }); + }, + + updateValueInField: function() { + var options; + if (this.modelValue()) { + options = [[this.modelValue(), this.displayValue(this.modelValue())]]; + this.replaceOrAddGroupOption( + 'Currently Selected Time Zone', + options + ); + } + this._super(); // eslint-disable-line no-underscore-dangle + }, + + replaceOrAddGroupOption: function(title, options) { + var groupOption = { + groupTitle: gettext(title), + selectOptions: options + }; + + var index = _.findIndex(this.options.groupOptions, function(group) { + return group.groupTitle === gettext(title); + }); + if (index >= 0) { + this.options.groupOptions[index] = groupOption; + } else { + this.options.groupOptions.unshift(groupOption); + } + } + + }), + PasswordFieldView: FieldViews.LinkFieldView.extend({ + fieldType: 'button', + fieldTemplate: field_link_account_template, + events: { + 'click button': 'linkClicked' + }, + initialize: function(options) { + this.options = _.extend({}, options); + this._super(options); + _.bindAll(this, 'resetPassword'); + }, + linkClicked: function(event) { + event.preventDefault(); + this.toggleDisableButton(true); + this.resetPassword(event); + }, + resetPassword: function() { + var data = {}; + data[this.options.emailAttribute] = this.model.get(this.options.emailAttribute); + + var view = this; + $.ajax({ + type: 'POST', + url: view.options.linkHref, + data: data, + success: function() { + view.showSuccessMessage(); + view.setMessageTimeout(); + }, + error: function(xhr) { + view.showErrorMessage(xhr); + view.setMessageTimeout(); + view.toggleDisableButton(false); + } + }); + }, + toggleDisableButton: function(disabled) { + var button = this.$('#u-field-link-' + this.options.valueAttribute); + if (button) { + button.prop('disabled', disabled); + } + }, + setMessageTimeout: function() { + var view = this; + setTimeout(function() { + view.showHelpMessage(); + }, 6000); + }, + successMessage: function() { + return HtmlUtils.joinHtml( + this.indicators.success, + HtmlUtils.interpolateHtml( + gettext('We\'ve sent a message to {email}. Click the link in the message to reset your password. Didn\'t receive the message? Contact {anchorStart}technical support{anchorEnd}.'), // eslint-disable-line max-len + { + email: this.model.get(this.options.emailAttribute), + anchorStart: HtmlUtils.HTML( + StringUtils.interpolate( + '', { + passwordResetSupportUrl: this.options.passwordResetSupportUrl + } + ) + ), + anchorEnd: HtmlUtils.HTML('') + } + ) + ); + } + }), + LanguageProficienciesFieldView: FieldViews.DropdownFieldView.extend({ + fieldTemplate: field_dropdown_account_template, + modelValue: function() { + var modelValue = this.model.get(this.options.valueAttribute); + if (_.isArray(modelValue) && modelValue.length > 0) { + return modelValue[0].code; + } else { + return null; + } + }, + saveValue: function() { + var attributes = {}, + value = ''; + if (this.persistChanges === true) { + value = this.fieldValue() ? [{code: this.fieldValue()}] : []; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + } + }), + SocialLinkTextFieldView: FieldViews.TextFieldView.extend({ + render: function() { + HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({ + id: this.options.valueAttribute + '_' + this.options.platform, + title: this.options.title, + value: this.modelValue(), + message: this.options.helpMessage, + placeholder: this.options.placeholder || '' + })); + this.delegateEvents(); + return this; + }, + + modelValue: function() { + var socialLinks = this.model.get(this.options.valueAttribute); + for (var i = 0; i < socialLinks.length; i++) { // eslint-disable-line vars-on-top + if (socialLinks[i].platform === this.options.platform) { + return socialLinks[i].social_link; + } + } + return null; + }, + saveValue: function() { + var attributes, value; + if (this.persistChanges === true) { + attributes = {}; + value = this.fieldValue() != null ? [{ + platform: this.options.platform, + social_link: this.fieldValue() + }] : []; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + } + }), + ExtendedFieldTextFieldView: FieldViews.TextFieldView.extend({ + render: function() { + HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({ + id: this.options.valueAttribute + '_' + this.options.fieldName, + title: this.options.title, + value: this.modelValue(), + message: this.options.helpMessage, + placeholder: this.options.placeholder || '' + })); + this.delegateEvents(); + return this; + }, + + modelValue: function() { + var extendedProfileFields = this.model.get(this.options.valueAttribute); + for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top + if (extendedProfileFields[i].field_name === this.options.fieldName) { + return extendedProfileFields[i].field_value; + } + } + return null; + }, + saveValue: function() { + var attributes, value; + if (this.persistChanges === true) { + attributes = {}; + value = this.fieldValue() != null ? [{ + field_name: this.options.fieldName, + field_value: this.fieldValue() + }] : []; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + } + }), + ExtendedFieldListFieldView: FieldViews.DropdownFieldView.extend({ + fieldTemplate: field_dropdown_account_template, + modelValue: function() { + var extendedProfileFields = this.model.get(this.options.valueAttribute); + for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top + if (extendedProfileFields[i].field_name === this.options.fieldName) { + return extendedProfileFields[i].field_value; + } + } + return null; + }, + saveValue: function() { + var attributes = {}, + value; + if (this.persistChanges === true) { + value = this.fieldValue() ? [{ + field_name: this.options.fieldName, + field_value: this.fieldValue() + }] : []; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + } + }), + ExtendedFieldCheckboxFieldView: FieldViews.EditableFieldView.extend({ + + fieldType: 'checkbox', + fieldTemplate: field_checkbox_account_template, + + events: { + 'change input': 'saveValue' + }, + + initialize: function(options) { + this._super(options); + _.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue'); + this.listenTo(this.model, 'change:' + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_checkbox_account_template)({ + id: this.options.valueAttribute + '_' + this.options.fieldName, + title: this.options.title, + value: this.modelValue(), + message: this.options.helpMessage + })); + this.delegateEvents(); + return this; + }, + + modelValue: function () { + var extendedProfileFields = this.model.get(this.options.valueAttribute); + for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top + if (extendedProfileFields[i].field_name === this.options.fieldName) { + return extendedProfileFields[i].field_value; + } + } + return null; + }, + + fieldValue: function() { + return this.$('.u-field-value input').is(':checked'); + }, + + updateValueInField: function() { + const checked = this.modelValue() === true; + this.$('.u-field-value input').prop('checked', checked); + }, + + saveValue: function () { + let attributes = {}, value; + + if (this.persistChanges === true) { + value = [{ + field_name: this.options.fieldName, + field_value: !!this.fieldValue() + }]; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + } + }), + AuthFieldView: FieldViews.LinkFieldView.extend({ + fieldTemplate: field_social_link_template, + className: function() { + return 'u-field u-field-social u-field-' + this.options.valueAttribute; + }, + initialize: function(options) { + this.options = _.extend({}, options); + this._super(options); + _.bindAll(this, 'redirect_to', 'disconnect', 'successMessage', 'inProgressMessage'); + }, + render: function() { + var linkTitle = '', + linkClass = '', + subTitle = '', + screenReaderTitle = StringUtils.interpolate( + gettext('Link your {accountName} account'), + {accountName: this.options.title} + ); + if (this.options.connected) { + linkTitle = gettext('Unlink This Account'); + linkClass = 'social-field-linked'; + subTitle = StringUtils.interpolate( + gettext('You can use your {accountName} account to sign in to your {platformName} account.'), // eslint-disable-line max-len + {accountName: this.options.title, platformName: this.options.platformName} + ); + screenReaderTitle = StringUtils.interpolate( + gettext('Unlink your {accountName} account'), + {accountName: this.options.title} + ); + } else if (this.options.acceptsLogins) { + linkTitle = gettext('Link Your Account'); + linkClass = 'social-field-unlinked'; + subTitle = StringUtils.interpolate( + gettext('Link your {accountName} account to your {platformName} account and use {accountName} to sign in to {platformName}.'), // eslint-disable-line max-len + {accountName: this.options.title, platformName: this.options.platformName} + ); + } + + HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({ + id: this.options.valueAttribute, + title: this.options.title, + screenReaderTitle: screenReaderTitle, + linkTitle: linkTitle, + subTitle: subTitle, + linkClass: linkClass, + linkHref: '#', + message: this.helpMessage + })); + this.delegateEvents(); + return this; + }, + linkClicked: function(event) { + event.preventDefault(); + + this.showInProgressMessage(); + + if (this.options.connected) { + this.disconnect(); + } else { + // Direct the user to the providers site to start the authentication process. + // See python-social-auth docs for more information. + this.redirect_to(this.options.connectUrl); + } + }, + redirect_to: function(url) { + window.location.href = url; + }, + disconnect: function() { + var data = {}; + + // Disconnects the provider from the user's edX account. + // See python-social-auth docs for more information. + var view = this; + $.ajax({ + type: 'POST', + url: this.options.disconnectUrl, + data: data, + dataType: 'html', + success: function() { + view.options.connected = false; + view.render(); + view.showSuccessMessage(); + }, + error: function(xhr) { + view.showErrorMessage(xhr); + } + }); + }, + inProgressMessage: function() { + return HtmlUtils.joinHtml(this.indicators.inProgress, ( + this.options.connected ? gettext('Unlinking') : gettext('Linking') + )); + }, + successMessage: function() { + return HtmlUtils.joinHtml(this.indicators.success, gettext('Successfully unlinked.')); + } + }), + + OrderHistoryFieldView: FieldViews.ReadonlyFieldView.extend({ + fieldType: 'orderHistory', + fieldTemplate: field_order_history_template, + + initialize: function(options) { + this.options = options; + this._super(options); + this.template = HtmlUtils.template(this.fieldTemplate); + }, + + render: function() { + HtmlUtils.setHtml(this.$el, this.template({ + totalPrice: this.options.totalPrice, + orderId: this.options.orderId, + orderDate: this.options.orderDate, + receiptUrl: this.options.receiptUrl, + valueAttribute: this.options.valueAttribute, + lines: this.options.lines + })); + this.delegateEvents(); + return this; + } + }) + }; + + return AccountSettingsFieldViews; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/templates/fields/field_checkbox_account.underscore b/lms/templates/fields/field_checkbox_account.underscore new file mode 100644 index 0000000000..b3ca4a6ec8 --- /dev/null +++ b/lms/templates/fields/field_checkbox_account.underscore @@ -0,0 +1,13 @@ +
+ + checked<% } %> /> +
+ + + <%= HtmlUtils.ensureHtml(message) %> + From 141cbae4f91e12a38c1547622d2b9db03fde21d0 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 13 Dec 2024 11:24:46 -0500 Subject: [PATCH 20/25] style: add styling for checkbox field --- lms/templates/fields/field_checkbox_account.underscore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/templates/fields/field_checkbox_account.underscore b/lms/templates/fields/field_checkbox_account.underscore index b3ca4a6ec8..8792366102 100644 --- a/lms/templates/fields/field_checkbox_account.underscore +++ b/lms/templates/fields/field_checkbox_account.underscore @@ -1,6 +1,6 @@ -
- - + + Date: Thu, 19 Dec 2024 12:36:49 -0500 Subject: [PATCH 21/25] chore: convert indentation to spaces --- .../views/account_settings_fields.js | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js index a6566ea25f..0381a2b447 100644 --- a/lms/static/js/student_account/views/account_settings_fields.js +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -346,57 +346,57 @@ fieldTemplate: field_checkbox_account_template, events: { - 'change input': 'saveValue' - }, - - initialize: function(options) { - this._super(options); - _.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue'); - this.listenTo(this.model, 'change:' + this.options.valueAttribute, this.updateValueInField); - }, - - render: function () { - HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_checkbox_account_template)({ - id: this.options.valueAttribute + '_' + this.options.fieldName, - title: this.options.title, - value: this.modelValue(), - message: this.options.helpMessage - })); - this.delegateEvents(); - return this; - }, - - modelValue: function () { - var extendedProfileFields = this.model.get(this.options.valueAttribute); - for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top - if (extendedProfileFields[i].field_name === this.options.fieldName) { - return extendedProfileFields[i].field_value; - } - } - return null; - }, - - fieldValue: function() { - return this.$('.u-field-value input').is(':checked'); - }, - - updateValueInField: function() { - const checked = this.modelValue() === true; - this.$('.u-field-value input').prop('checked', checked); - }, - - saveValue: function () { - let attributes = {}, value; - - if (this.persistChanges === true) { - value = [{ - field_name: this.options.fieldName, - field_value: !!this.fieldValue() - }]; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } + 'change input': 'saveValue' + }, + + initialize: function(options) { + this._super(options); + _.bindAll(this, 'render', 'fieldValue', 'updateValueInField', 'saveValue'); + this.listenTo(this.model, 'change:' + this.options.valueAttribute, this.updateValueInField); + }, + + render: function () { + HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_checkbox_account_template)({ + id: this.options.valueAttribute + '_' + this.options.fieldName, + title: this.options.title, + value: this.modelValue(), + message: this.options.helpMessage + })); + this.delegateEvents(); + return this; + }, + + modelValue: function () { + var extendedProfileFields = this.model.get(this.options.valueAttribute); + for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top + if (extendedProfileFields[i].field_name === this.options.fieldName) { + return extendedProfileFields[i].field_value; + } + } + return null; + }, + + fieldValue: function() { + return this.$('.u-field-value input').is(':checked'); + }, + + updateValueInField: function() { + const checked = this.modelValue() === true; + this.$('.u-field-value input').prop('checked', checked); + }, + + saveValue: function () { + let attributes = {}, value; + + if (this.persistChanges === true) { + value = [{ + field_name: this.options.fieldName, + field_value: !!this.fieldValue() + }]; + attributes[this.options.valueAttribute] = value; + this.saveAttributes(attributes); + } + } }), AuthFieldView: FieldViews.LinkFieldView.extend({ fieldTemplate: field_social_link_template, From 7ee3ea6d7955f9977a23a29f8d252bf7e84b31cb Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Mon, 13 Mar 2023 22:55:00 +0000 Subject: [PATCH 22/25] feat: add exception message has localizedMessage GN-1088 FAN-41 --- openedx/core/djangoapps/enrollments/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index 8f4c1f7de0..82ca0297f1 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -868,6 +868,14 @@ def post(self, request): log.info("The user [%s] has already been enrolled in course run [%s].", username, course_id) return Response(response) + except InvalidEnrollmentAttribute as error: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "message": str(error), + "localizedMessage": str(error), + } + ) except CourseModeNotFoundError as error: return Response( status=status.HTTP_400_BAD_REQUEST, From 3b7cc80d9a177b732a658c2f7de4d7fc3f8495b5 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Sun, 11 May 2025 20:39:44 -0500 Subject: [PATCH 23/25] feat: add feature toggle for private fields in profile information report --- lms/djangoapps/instructor/tests/test_api.py | 18 ++++++ lms/djangoapps/instructor/views/api.py | 71 +++++++++++---------- lms/envs/common.py | 10 +++ 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index f1e7322abc..fb5a5f865b 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -22,6 +22,7 @@ from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, TestCase from django.test.client import MULTIPART_CONTENT +from django.test.utils import override_settings from django.urls import reverse as django_reverse from django.utils.translation import gettext as _ from edx_when.api import get_dates_for_course, get_overrides_for_user, set_date_for_block @@ -2626,6 +2627,23 @@ def test_get_students_features(self): assert student_json['city'] == student.profile.city assert student_json['country'] == '' + @ddt.data(True, False) + def test_get_students_features_private_fields(self, show_private_fields): + """ + Test that the get_students_features returns the expected private fields + """ + with override_settings(FEATURES={'SHOW_PRIVATE_FIELDS_IN_PROFILE_INFORMATION_REPORT': show_private_fields}): + url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode('utf-8')) + + assert 'students' in res_json + for student in res_json['students']: + if show_private_fields: + assert 'year_of_birth' in student + else: + assert 'year_of_birth' not in student + @ddt.data(True, False) def test_get_students_features_cohorted(self, is_cohorted): """ diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 84058dfeae..c90407d68f 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1485,40 +1485,45 @@ def post(self, request, course_id, csv=False): # pylint: disable=redefined-oute # We need to clone the list because we modify it below query_features = list(configuration_helpers.get_value('student_profile_download_fields', [])) - if not query_features: - query_features = [ - 'id', 'username', 'name', 'email', 'language', 'location', - 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', - 'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key' - ] - keep_field_private(query_features, 'year_of_birth') # protected information - - # Provide human-friendly and translatable names for these features. These names - # will be displayed in the table generated in data_download.js. It is not (yet) - # used as the header row in the CSV, but could be in the future. - query_features_names = { - 'id': _('User ID'), - 'username': _('Username'), - 'name': _('Name'), - 'email': _('Email'), - 'language': _('Language'), - 'location': _('Location'), - # 'year_of_birth': _('Birth Year'), treated as privileged information as of TNL-10683, - # not to go in reports - 'gender': _('Gender'), - 'level_of_education': _('Level of Education'), - 'mailing_address': _('Mailing Address'), - 'goals': _('Goals'), - 'enrollment_mode': _('Enrollment Mode'), - 'last_login': _('Last Login'), - 'date_joined': _('Date Joined'), - 'external_user_key': _('External User Key'), - } + if not query_features: + query_features = [ + 'id', 'username', 'name', 'email', 'language', 'location', + 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', + 'goals', 'enrollment_mode', 'verification_status', + 'last_login', 'date_joined', 'external_user_key', + 'enrollment_date' + ] + + # Provide human-friendly and translatable names for these features. These names + # will be displayed in the table generated in data_download.js. It is not (yet) + # used as the header row in the CSV, but could be in the future. + query_features_names = { + 'id': _('User ID'), + 'username': _('Username'), + 'name': _('Name'), + 'email': _('Email'), + 'language': _('Language'), + 'location': _('Location'), + 'year_of_birth': _('Birth Year'), + 'gender': _('Gender'), + 'level_of_education': _('Level of Education'), + 'mailing_address': _('Mailing Address'), + 'goals': _('Goals'), + 'enrollment_mode': _('Enrollment Mode'), + 'last_login': _('Last Login'), + 'date_joined': _('Date Joined'), + 'external_user_key': _('External User Key'), + 'enrollment_date': _('Enrollment Date'), + } + + if not settings.FEATURES.get('SHOW_PRIVATE_FIELDS_IN_PROFILE_INFORMATION_REPORT', False): + keep_field_private(query_features, 'year_of_birth') + query_features_names.pop('year_of_birth', None) - if is_course_cohorted(course.id): - # Translators: 'Cohort' refers to a group of students within a course. - query_features.append('cohort') - query_features_names['cohort'] = _('Cohort') + if is_course_cohorted(course.id): + # Translators: 'Cohort' refers to a group of students within a course. + query_features.append('cohort') + query_features_names['cohort'] = _('Cohort') if course.teams_enabled: query_features.append('team') diff --git a/lms/envs/common.py b/lms/envs/common.py index 763bd83b9d..db0ba2ef9e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1067,6 +1067,16 @@ # .. toggle_creation_date: 2024-04-02 # .. toggle_target_removal_date: None 'BADGES_ENABLED': False, + + # .. toggle_name: FEATURES['SHOW_PRIVATE_FIELDS_IN_PROFILE_INFORMATION_REPORT'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Adds private fields to the profile information report. + # .. toggle_use_cases: open_edx + # .. toggle_creation_date: 2025-05-12 + # .. toggle_target_removal_date: None + # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/36688 + 'SHOW_PRIVATE_FIELDS_IN_PROFILE_INFORMATION_REPORT': False, } # Specifies extra XBlock fields that should available when requested via the Course Blocks API From ba9707c0a71dcf44a9e41de96e25c4770284ce04 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Wed, 10 Sep 2025 15:32:02 +0100 Subject: [PATCH 24/25] feat: add user certificate to Certificate render start context Add the user certificate to the CertificateRenderStarted Open edX filter context, so it is possible to have complex filtering and applying custom logic to the course certificate presentation. fccn/nau-technical#667 --- lms/djangoapps/certificates/views/webview.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 3b6cc75e48..1c75860204 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -585,6 +585,21 @@ def render_html_view(request, course_id, certificate=None): # pylint: disable=t context.update(get_certificate_header_context(is_secure=request.is_secure())) context.update(get_certificate_footer_context()) + # Append/Override the existing view context values with plugin defined values + run_extension_point( + 'NAU_CERTIFICATE_CONTEXT_EXTENSION', + context=context, + request=request, + course=course, + user=user, + user_certificate=user_certificate, + configuration=configuration, + certificate_language=certificate_language, + ) + + # NAU Customization so the filter know which certificate is being rendered + context['user_certificate'] = user_certificate + # Append/Override the existing view context values with any course-specific static values from Advanced Settings context.update(course.cert_html_view_overrides) From ec7f330685964b49b13aad9ac05fbe965dd5dd97 Mon Sep 17 00:00:00 2001 From: Ivo Branco Date: Thu, 11 May 2023 10:15:36 +0000 Subject: [PATCH 25/25] feat: add enrollment_date and custom fields to profile data csv Add `enrollment_date` column on the csv file of all students enrolled in a course. Allow site operators to include on the export of profile information as CSV custom fields if the platform has an extending User model. This can be used if you have an extended model that include for example an university student number and site operator want to export the student number on the student profile information CSV. GN-914 --- lms/djangoapps/instructor_analytics/basic.py | 91 ++++++++++++++++--- .../instructor_analytics/tests/test_basic.py | 62 ++++++++++++- 2 files changed, 140 insertions(+), 13 deletions(-) diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index c7bc6ca6da..52612b7700 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -10,7 +10,6 @@ import logging from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import ObjectDoesNotExist from django.core.serializers.json import DjangoJSONEncoder from django.db.models import Count # lint-amnesty, pylint: disable=unused-import @@ -38,6 +37,7 @@ 'level_of_education', 'mailing_address', 'goals', 'meta', 'city', 'country') PROGRAM_ENROLLMENT_FEATURES = ('external_user_key', ) +ENROLLMENT_FEATURES = ('enrollment_date', ) ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'status') ORDER_FEATURES = ('purchase_time',) @@ -49,7 +49,7 @@ 'bill_to_street2', 'bill_to_city', 'bill_to_state', 'bill_to_postalcode', 'bill_to_country', 'order_type', 'created') -AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES +AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES + ENROLLMENT_FEATURES COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at', 'is_valid') COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date', 'is_active') CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason') @@ -84,7 +84,62 @@ def issued_certificates(course_key, features): return generated_certificates -def enrolled_students_features(course_key, features): +def get_student_features_with_custom(course_key): + """ + Allow site operators to include on the export custom fields if platform has an extending + User model. This can be used if you have an extended model that include for example + an university student number. + Basic example of adding age: + ```python + def get_age(self): + return datetime.datetime.now().year - self.profile.year_of_birth + setattr(User, 'age', property(get_age)) + ``` + Then you have to add `age` to both site configurations: + - `student_profile_download_fields_custom_student_attributes` + - `student_profile_download_fields` site configurations` + ```json + "student_profile_download_fields_custom_student_attributes": ["age"], + "student_profile_download_fields": [ + "id", "username", "name", "email", "language", "location", + "year_of_birth", "gender", "level_of_education", "mailing_address", + "goals", "enrollment_mode", "last_login", "date_joined", "external_user_key", + "enrollment_date", "age" + ] + ``` + Example if the platform has a custom user extended model like a One-To-One Link + with the User Model: + ```python + def get_user_extended_model_custom_field(self): + if hasattr(self, "userextendedmodel"): + return self.userextendedmodel.custom_field + return None + setattr(User, 'user_extended_model_custom_field', property(get_user_extended_model_custom_field)) + ``` + ```json + "student_profile_download_fields_custom_student_attributes": ["user_extended_model_custom_field"], + "student_profile_download_fields": [ + "id", "username", "name", "email", "language", "location", + "year_of_birth", "gender", "level_of_education", "mailing_address", + "goals", "enrollment_mode", "last_login", "date_joined", "external_user_key", + "enrollment_date", "user_extended_model_custom_field" + ] + ``` + """ + return STUDENT_FEATURES + tuple( + configuration_helpers.get_value_for_org( + course_key.org, + "student_profile_download_fields_custom_student_attributes", + getattr( + settings, + "STUDENT_PROFILE_DOWNLOAD_FIELDS_CUSTOM_STUDENT_ATTRIBUTES", + (), + ), + ) + ) + + +def enrolled_students_features(course_key, features): # lint-amnesty, pylint: disable=too-many-statements """ Return list of student features as dictionaries. @@ -101,18 +156,25 @@ def enrolled_students_features(course_key, features): include_enrollment_mode = 'enrollment_mode' in features include_verification_status = 'verification_status' in features include_program_enrollments = 'external_user_key' in features + include_enrollment_date = 'enrollment_date' in features external_user_key_dict = {} - students = User.objects.filter( - courseenrollment__course_id=course_key, - courseenrollment__is_active=1, - ).order_by('username').select_related('profile') + enrollments = CourseEnrollment.objects.filter( + course_id=course_key, + is_active=1, + ).select_related('user').order_by('user__username').select_related('user__profile') if include_cohort_column: - students = students.prefetch_related('course_groups') + enrollments = enrollments.prefetch_related('user__course_groups') if include_team_column: - students = students.prefetch_related('teams') + enrollments = enrollments.prefetch_related('user__teams') + + + students = [enrollment.user for enrollment in enrollments] + + # student_features = [x for x in get_student_features_with_custom(course_key) if x in features] + # profile_features = [x for x in PROFILE_FEATURES if x in features] if include_program_enrollments and len(students) > 0: program_enrollments = fetch_program_enrollments_by_students(users=students, realized_only=True) @@ -128,11 +190,15 @@ def extract_attr(student, feature): except TypeError: return str(attr) - def extract_student(student, features): + def extract_enrollment_student(enrollment, features): """ convert student to dictionary """ + student_features = [x for x in STUDENT_FEATURES if x in features] profile_features = [x for x in PROFILE_FEATURES if x in features] + student = enrollment.user + + # For data extractions on the 'meta' field # the feature name should be in the format of 'meta.foo' where # 'foo' is the keyname in the meta dictionary @@ -189,9 +255,12 @@ def extract_student(student, features): # extra external_user_key student_dict['external_user_key'] = external_user_key_dict.get(student.id, '') + if include_enrollment_date: + student_dict['enrollment_date'] = enrollment.created + return student_dict - return [extract_student(student, features) for student in students] + return [extract_enrollment_student(enrollment, features) for enrollment in enrollments] def list_may_enroll(course_key, features): diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 83f23246ea..4261cf31d7 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -5,8 +5,11 @@ from unittest.mock import MagicMock, Mock, patch +import random +import datetime import ddt import json # lint-amnesty, pylint: disable=wrong-import-order +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from edx_proctoring.api import create_exam from edx_proctoring.models import ProctoredExamStudentAttempt from opaque_keys.edx.locator import UsageKey @@ -15,6 +18,7 @@ PROFILE_FEATURES, PROGRAM_ENROLLMENT_FEATURES, STUDENT_FEATURES, + ENROLLMENT_FEATURES, StudentModule, enrolled_students_features, get_proctored_exam_results, @@ -24,6 +28,7 @@ ) from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed from common.djangoapps.student.tests.factories import InstructorFactory from common.djangoapps.student.tests.factories import UserFactory @@ -250,8 +255,61 @@ def test_enrolled_student_features_external_user_keys(self): assert '' == report['external_user_key'] def test_available_features(self): - assert len(AVAILABLE_FEATURES) == len(STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES) - assert set(AVAILABLE_FEATURES) == set(STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES) + assert len(AVAILABLE_FEATURES) == len( + STUDENT_FEATURES + + PROFILE_FEATURES + + PROGRAM_ENROLLMENT_FEATURES + + ENROLLMENT_FEATURES + ) + assert set(AVAILABLE_FEATURES) == set( + STUDENT_FEATURES + + PROFILE_FEATURES + + PROGRAM_ENROLLMENT_FEATURES + + ENROLLMENT_FEATURES + ) + + def test_enrolled_students_enrollment_date(self): + query_features = ('username', 'enrollment_date',) + for feature in query_features: + assert feature in AVAILABLE_FEATURES + with self.assertNumQueries(2): + userreports = enrolled_students_features(self.course_key, query_features) + assert len(userreports) == len(self.users) + + userreports = sorted(userreports, key=lambda u: u["username"]) + users = sorted(self.users, key=lambda u: u.username) + for userreport, user in zip(userreports, users): + assert set(userreport.keys()) == set(query_features) + assert userreport['enrollment_date'] == CourseEnrollment.enrollments_for_user(user)[0].created + + def test_enrolled_students_extended_model_age(self): + SiteConfigurationFactory.create( + site_values={ + 'course_org_filter': ['robot'], + 'student_profile_download_fields_custom_student_attributes': ['age'], + } + ) + + def get_age(self): + return datetime.datetime.now().year - self.profile.year_of_birth + setattr(User, "age", property(get_age)) # lint-amnesty, pylint: disable=literal-used-as-attribute + + for user in self.users: + user.profile.year_of_birth = random.randint(1900, 2000) + user.profile.save() + + query_features = ('username', 'age',) + with self.assertNumQueries(3): + userreports = enrolled_students_features(self.course_key, query_features) + assert len(userreports) == len(self.users) + + userreports = sorted(userreports, key=lambda u: u["username"]) + users = sorted(self.users, key=lambda u: u.username) + for userreport, user in zip(userreports, users): + assert set(userreport.keys()) == set(query_features) + assert userreport['age'] == str(user.age) + + delattr(User, "age") # lint-amnesty, pylint: disable=literal-used-as-attribute def test_list_may_enroll(self): may_enroll = list_may_enroll(self.course_key, ['email'])