diff --git a/cms/envs/test.py b/cms/envs/test.py index 4237d884e82c..e7be26b73c0e 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -26,8 +26,7 @@ from .common import * # import settings from LMS for consistent behavior with CMS -from lms.envs.test import ( # pylint: disable=wrong-import-order, disable=unused-import - ACCOUNT_MICROFRONTEND_URL, +from lms.envs.test import ( # pylint: disable=wrong-import-order BLOCKSTORE_USE_BLOCKSTORE_APP_API, BLOCKSTORE_API_URL, COMPREHENSIVE_THEME_DIRS, # unimport:skip @@ -38,10 +37,8 @@ LOGIN_ISSUE_SUPPORT_LINK, MEDIA_ROOT, MEDIA_URL, - ORDER_HISTORY_MICROFRONTEND_URL, PLATFORM_DESCRIPTION, PLATFORM_NAME, - PROFILE_MICROFRONTEND_URL, REGISTRATION_EXTRA_FIELDS, GRADES_DOWNLOAD, SITE_NAME, diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index bf79ed7ae402..376595a8507b 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -1,7 +1,6 @@ """ Test that various filters are fired for models/views in the student app. """ -from django.conf import settings from django.http import HttpResponse from django.test import override_settings from django.urls import reverse @@ -422,7 +421,7 @@ def test_dashboard_redirect_account_settings(self): response = self.client.get(self.dashboard_url) self.assertEqual(status.HTTP_302_FOUND, response.status_code) - self.assertEqual(settings.ACCOUNT_MICROFRONTEND_URL, response.url) + self.assertEqual(reverse("account_settings"), response.url) @override_settings( OPEN_EDX_FILTERS_CONFIG={ diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index 8f35afdf4299..1230f8320a75 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -233,7 +233,7 @@ def test_redirect_account_settings(self): """ UserProfile.objects.get(user=self.user).delete() response = self.client.get(self.path) - self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, target_status_code=302) + self.assertRedirects(response, reverse('account_settings')) @patch('common.djangoapps.student.views.dashboard.should_redirect_to_learner_home_mfe') def test_redirect_to_learner_home(self, mock_should_redirect_to_learner_home_mfe): diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index 693e64ee933d..0e73c3c25700 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -519,7 +519,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem """ user = request.user if not UserProfile.objects.filter(user=user).exists(): - return redirect(settings.ACCOUNT_MICROFRONTEND_URL) + return redirect(reverse('account_settings')) if should_redirect_to_learner_home_mfe(user): return redirect(settings.LEARNER_HOME_MICROFRONTEND_URL) @@ -624,7 +624,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem "Go to {link_start}your Account Settings{link_end}.") ).format( link_start=HTML("").format( - account_setting_page=settings.ACCOUNT_MICROFRONTEND_URL, + account_setting_page=reverse('account_settings'), ), link_end=HTML("") ) @@ -897,7 +897,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem except DashboardRenderStarted.RenderInvalidDashboard as exc: response = render_to_response(exc.dashboard_template, exc.template_context) except DashboardRenderStarted.RedirectToPage as exc: - response = HttpResponseRedirect(exc.redirect_to or settings.ACCOUNT_MICROFRONTEND_URL) + response = HttpResponseRedirect(exc.redirect_to or reverse('account_settings')) except DashboardRenderStarted.RenderCustomResponse as exc: response = exc.response else: diff --git a/common/djangoapps/third_party_auth/api/tests/test_views.py b/common/djangoapps/third_party_auth/api/tests/test_views.py index 99e9e2744d58..aea4c18367e6 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_views.py +++ b/common/djangoapps/third_party_auth/api/tests/test_views.py @@ -2,11 +2,10 @@ Tests for the Third Party Auth REST API """ -import urllib from unittest.mock import patch import ddt -from django.conf import settings +import six from django.http import QueryDict from django.test.utils import override_settings from django.urls import reverse @@ -220,7 +219,7 @@ def make_url(self, identifier): """ return '?'.join([ reverse('third_party_auth_users_api_v2'), - urllib.parse.urlencode(identifier) + six.moves.urllib.parse.urlencode(identifier) ]) @@ -378,12 +377,11 @@ def test_get(self): """ self.client.login(username=self.user.username, password=PASSWORD) response = self.client.get(self.url, content_type="application/json") - next_url = urllib.parse.quote(settings.ACCOUNT_MICROFRONTEND_URL, safe="") assert response.status_code == 200 assert (response.data == [{ 'accepts_logins': True, 'name': 'Google', 'disconnect_url': '/auth/disconnect/google-oauth2/?', - 'connect_url': f'/auth/login/google-oauth2/?auth_entry=account_settings&next={next_url}', + 'connect_url': '/auth/login/google-oauth2/?auth_entry=account_settings&next=%2Faccount%2Fsettings', 'connected': False, 'id': 'oa2-google-oauth2' }]) diff --git a/common/djangoapps/third_party_auth/api/views.py b/common/djangoapps/third_party_auth/api/views.py index c1127f8e335d..97d1a7d6dba4 100644 --- a/common/djangoapps/third_party_auth/api/views.py +++ b/common/djangoapps/third_party_auth/api/views.py @@ -9,6 +9,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db.models import Q from django.http import Http404 +from django.urls import reverse from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from rest_framework import exceptions, permissions, status, throttling @@ -424,7 +425,7 @@ def get(self, request): 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=settings.ACCOUNT_MICROFRONTEND_URL, + 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 diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 0783c5557324..8f96235017da 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -11,7 +11,7 @@ import pytest from django import test from django.conf import settings -from django.contrib import auth, messages +from django.contrib import auth from django.contrib.auth import models as auth_models from django.contrib.messages.storage import fallback from django.contrib.sessions.backends import cache @@ -28,6 +28,7 @@ from openedx.core.djangoapps.user_authn.views.register import RegistrationView from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from common.djangoapps.student import models as student_models from common.djangoapps.student.tests.factories import UserFactory @@ -98,43 +99,6 @@ def assert_register_response_in_pipeline_looks_correct(self, response, pipeline_ if prepopulated_form_data in required_fields: self.assertContains(response, form_field_data[prepopulated_form_data]) - def _get_user_providers_state(self, request): - """ - Return provider user states and duplicated providers. - """ - data = { - 'auth': {}, - } - data['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) - auth_states = pipeline.get_provider_user_states(request.user) - data['auth']['providers'] = [{ - 'name': state.provider.name, - 'connected': state.has_account, - } for state in auth_states if state.provider.display_for_login or state.has_account] - return data - - def assert_third_party_accounts_state(self, request, duplicate=False, linked=None): - """ - Asserts the user's third party account in the expected state. - - If duplicate is True, we expect data['duplicate_provider'] to contain - the duplicate provider backend name. If linked is passed, we conditionally - check that the provider is included in data['auth']['providers'] and - its connected state is correct. - """ - data = self._get_user_providers_state(request) - if duplicate: - assert data['duplicate_provider'] == self.provider.backend_name - else: - assert data['duplicate_provider'] is None - - if linked is not None: - expected_provider = [ - provider for provider in data['auth']['providers'] if provider['name'] == self.provider.name - ][0] - assert expected_provider is not None - assert expected_provider['connected'] == linked - def assert_register_form_populates_unicode_username_correctly(self, request): # lint-amnesty, pylint: disable=invalid-name """ Check the registration form username field behaviour with unicode values. @@ -154,6 +118,27 @@ def assert_register_form_populates_unicode_username_correctly(self, request): # with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_UNICODE_USERNAME': True}): self._check_registration_form_username(pipeline_kwargs, unicode_username, unicode_username) + # pylint: disable=invalid-name + def assert_account_settings_context_looks_correct(self, context, duplicate=False, linked=None): + """Asserts the user's account settings page context is in the expected state. + + If duplicate is True, we expect context['duplicate_provider'] to contain + the duplicate provider backend name. If linked is passed, we conditionally + check that the provider is included in context['auth']['providers'] and + its connected state is correct. + """ + if duplicate: + assert context['duplicate_provider'] == self.provider.backend_name + else: + assert context['duplicate_provider'] is None + + if linked is not None: + expected_provider = [ + provider for provider in context['auth']['providers'] if provider['name'] == self.provider.name + ][0] + assert expected_provider is not None + assert expected_provider['connected'] == linked + def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None): """Tests middleware conditional redirection. @@ -626,7 +611,7 @@ def test_full_pipeline_succeeds_for_linking_account(self, _mock_segment_track): # First we expect that we're in the unlinked state, and that there # really is no association in the backend. - self.assert_third_party_accounts_state(get_request, linked=False) + self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False) self.assert_social_auth_does_not_exist_for_user(get_request.user, strategy) # We should be redirected back to the complete page, setting @@ -645,7 +630,7 @@ def test_full_pipeline_succeeds_for_linking_account(self, _mock_segment_track): # Now we expect to be in the linked state, with a backend entry. self.assert_social_auth_exists_for_user(get_request.user, strategy) - self.assert_third_party_accounts_state(get_request, linked=True) + self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=True) def test_full_pipeline_succeeds_for_unlinking_account(self): # First, create, the GET request and strategy that store pipeline state, @@ -677,7 +662,7 @@ def test_full_pipeline_succeeds_for_unlinking_account(self): get_request.user = post_request.user # First we expect that we're in the linked state, with a backend entry. - self.assert_third_party_accounts_state(get_request, linked=True) + self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=True) self.assert_social_auth_exists_for_user(get_request.user, strategy) # Fire off the disconnect pipeline to unlink. @@ -691,7 +676,7 @@ def test_full_pipeline_succeeds_for_unlinking_account(self): ) # Now we expect to be in the unlinked state, with no backend entry. - self.assert_third_party_accounts_state(get_request, linked=False) + self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) def test_linking_already_associated_account_raises_auth_already_associated(self): @@ -749,8 +734,8 @@ def test_already_associated_exception_populates_dashboard_with_error(self): post_request, exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.')) - self.assert_third_party_accounts_state( - post_request, duplicate=True, linked=True) + self.assert_account_settings_context_looks_correct( + account_settings_context(post_request), duplicate=True, linked=True) @mock.patch('common.djangoapps.third_party_auth.pipeline.segment.track') def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self, _mock_segment_track): @@ -810,7 +795,7 @@ def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self, self.assert_redirect_after_pipeline_completes( self.do_complete(strategy, get_request, partial_pipeline_token, partial_data, user) ) - self.assert_third_party_accounts_state(get_request) + self.assert_account_settings_context_looks_correct(account_settings_context(get_request)) def test_signin_fails_if_account_not_active(self): _, strategy = self.get_request_and_strategy( @@ -952,7 +937,7 @@ def test_full_pipeline_succeeds_registering_new_account(self): ) # Now the user has been redirected to the dashboard. Their third party account should now be linked. self.assert_social_auth_exists_for_user(created_user, strategy) - self.assert_third_party_accounts_state(request, linked=True) + self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True) def test_new_account_registration_assigns_distinct_username_on_collision(self): original_username = self.get_username() diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index 8d0bd5a5ae8d..ec3efd8286e7 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -27,6 +27,7 @@ from common.djangoapps.third_party_auth.saml import log as saml_log from common.djangoapps.third_party_auth.tasks import fetch_saml_metadata from common.djangoapps.third_party_auth.tests import testutil, utils +from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from openedx.core.djangoapps.user_authn.views.login import login_user from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerFactory @@ -238,10 +239,12 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin } @patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') + @patch('openedx.core.djangoapps.user_api.accounts.settings_views.enterprise_customer_for_request') @patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') def test_full_pipeline_succeeds_for_unlinking_testshib_account( self, mock_auth_provider, + mock_enterprise_customer_for_request_settings_view, mock_enterprise_customer_for_request, ): @@ -281,6 +284,7 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account( } mock_auth_provider.return_value.backend_name = 'tpa-saml' mock_enterprise_customer_for_request.return_value = enterprise_customer_data + mock_enterprise_customer_for_request_settings_view.return_value = enterprise_customer_data # Instrument the pipeline to get to the dashboard with the full expected state. self.client.get( @@ -295,7 +299,7 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account( request=request) # First we expect that we're in the linked state, with a backend entry. - self.assert_third_party_accounts_state(request, linked=True) + self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True) self.assert_social_auth_exists_for_user(request.user, strategy) FEATURES_WITH_ENTERPRISE_ENABLED = settings.FEATURES.copy() @@ -323,7 +327,7 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account( ) ) # Now we expect to be in the unlinked state, with no backend entry. - self.assert_third_party_accounts_state(request, linked=False) + self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) assert EnterpriseCustomerUser.objects\ .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 0 diff --git a/conf/locale/config.yaml b/conf/locale/config.yaml index fc2fd4584800..23cb9e0d8537 100644 --- a/conf/locale/config.yaml +++ b/conf/locale/config.yaml @@ -157,6 +157,10 @@ segment: djangojs-partial.po: djangojs-studio.po: - cms/* + djangojs-account-settings-view.po: + - lms/static/js/student_account/views/account_settings_view.js + # Segregating student account settings view strings, so that beta language message + # can be translated for wide set of partially supported languages. mako.po: mako-studio.po: - cms/* diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index a489750ec9cb..afad888fe0c5 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -110,7 +110,7 @@ def _get_course_email_context(course): 'course_url': course_url, 'course_image_url': image_url, 'course_end_date': course_end_date, - 'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL, + 'account_settings_url': '{}{}'.format(lms_root_url, reverse('account_settings')), 'email_settings_url': '{}{}'.format(lms_root_url, reverse('dashboard')), 'logo_url': get_logo_url_for_email(), 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index f68fd9b2499f..ba95bd26ddda 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -743,7 +743,7 @@ def verify_email_context(self, email_context, scheme): assert email_context['course_image_url'] == \ f'{scheme}://edx.org/asset-v1:{course_id_fragment}+type@asset+block@images_course_image.jpg' assert email_context['email_settings_url'] == f'{scheme}://edx.org/dashboard' - assert email_context['account_settings_url'] == settings.ACCOUNT_MICROFRONTEND_URL + assert email_context['account_settings_url'] == f'{scheme}://edx.org/account/settings' @override_settings(LMS_ROOT_URL="http://edx.org") def test_insecure_email_context(self): diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index e39294517f36..fdc90a46a513 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -2143,7 +2143,7 @@ def financial_assistance_form(request, course_id=None): 'header_text': _get_fa_header(FINANCIAL_ASSISTANCE_HEADER), 'student_faq_url': marketing_link('FAQ'), 'dashboard_url': reverse('dashboard'), - 'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL, + 'account_settings_url': reverse('account_settings'), 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'user_details': { 'email': user.email, diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py index bfb594a24b6c..bfa511a575bd 100644 --- a/lms/djangoapps/discussion/views.py +++ b/lms/djangoapps/discussion/views.py @@ -4,7 +4,6 @@ import logging from functools import wraps -from urllib.parse import urljoin from django.conf import settings from django.contrib.auth import get_user_model @@ -630,7 +629,7 @@ def create_user_profile_context(request, course_key, user_id): 'page': query_params['page'], 'num_pages': query_params['num_pages'], 'sort_preference': user.default_sort_key, - 'learner_profile_page_url': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{django_user.username}'), + 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}), }) return context diff --git a/lms/envs/common.py b/lms/envs/common.py index 57e586c63142..0bad10728565 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3249,6 +3249,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx.features.course_bookmarks', 'openedx.features.course_experience', 'openedx.features.enterprise_support.apps.EnterpriseSupportConfig', + 'openedx.features.learner_profile', 'openedx.features.course_duration_limits', 'openedx.features.content_type_gating', 'openedx.features.discounts', diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 1ac54b95a2f6..4eae239aabbe 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -385,7 +385,6 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ############## Settings for Microfrontends ######################### LEARNING_MICROFRONTEND_URL = 'http://localhost:2000' ACCOUNT_MICROFRONTEND_URL = 'http://localhost:1997' -PROFILE_MICROFRONTEND_URL = 'http://localhost:1995' COMMUNICATIONS_MICROFRONTEND_URL = 'http://localhost:1984' AUTHN_MICROFRONTEND_URL = 'http://localhost:1999' AUTHN_MICROFRONTEND_DOMAIN = 'localhost:1999' diff --git a/lms/envs/test.py b/lms/envs/test.py index 2ae4b876a0c2..7d50c943a3d0 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -596,7 +596,7 @@ PDF_RECEIPT_TERMS_AND_CONDITIONS = 'add your own terms and conditions' PDF_RECEIPT_TAX_ID_LABEL = 'Tax ID' -PROFILE_MICROFRONTEND_URL = "http://profile-mfe" +PROFILE_MICROFRONTEND_URL = "http://profile-mfe/abc/" ORDER_HISTORY_MICROFRONTEND_URL = "http://order-history-mfe/" ACCOUNT_MICROFRONTEND_URL = "http://account-mfe" AUTHN_MICROFRONTEND_URL = "http://authn-mfe" diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js new file mode 100644 index 000000000000..075142c84ac5 --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_factory_spec.js @@ -0,0 +1,334 @@ +define(['backbone', + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/spec/views/fields_helpers', + 'js/spec/student_account/helpers', + 'js/spec/student_account/account_settings_fields_helpers', + 'js/student_account/views/account_settings_factory', + 'js/student_account/views/account_settings_view' +], +function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers, Helpers, + AccountSettingsFieldViewSpecHelpers, AccountSettingsPage) { + 'use strict'; + + describe('edx.user.AccountSettingsFactory', function() { + var createAccountSettingsPage = function() { + var context = AccountSettingsPage( + Helpers.FIELDS_DATA, + false, + [], + Helpers.AUTH_DATA, + Helpers.PASSWORD_RESET_SUPPORT_LINK, + Helpers.USER_ACCOUNTS_API_URL, + Helpers.USER_PREFERENCES_API_URL, + 1, + Helpers.PLATFORM_NAME, + Helpers.CONTACT_EMAIL, + true, + Helpers.ENABLE_COPPA_COMPLIANCE + ); + return context.accountSettingsView; + }; + + var requests; + + beforeEach(function() { + setFixtures('
'); + }); + + it('shows loading error when UserAccountModel fails to load', function() { + requests = AjaxHelpers.requests(this); + + var accountSettingsView = createAccountSettingsPage(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + var request = requests[0]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + }); + + it('shows loading error when UserPreferencesModel fails to load', function() { + requests = AjaxHelpers.requests(this); + + var accountSettingsView = createAccountSettingsPage(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + var request = requests[0]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + request = requests[1]; + expect(request.method).toBe('GET'); + expect(request.url).toBe('/api/user/v1/preferences/time_zones/?country_code=1'); + AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); + + request = requests[2]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + }); + + it('renders fields after the models are successfully fetched', function() { + requests = AjaxHelpers.requests(this); + + var accountSettingsView = createAccountSettingsPage(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + + accountSettingsView.render(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); + }); + + it('expects all fields to behave correctly', function() { + var i, view; + + requests = AjaxHelpers.requests(this); + + var accountSettingsView = createAccountSettingsPage(); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + AjaxHelpers.respondWithJson(requests, {}); // Page viewed analytics event + + var sectionsData = accountSettingsView.options.tabSections.aboutTabSections; + + expect(sectionsData[0].fields.length).toBe(7); + + var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]]; + for (i = 0; i < textFields.length; i++) { + view = textFields[i].view; + FieldViewsSpecHelpers.verifyTextField(view, { + title: view.options.title, + valueAttribute: view.options.valueAttribute, + helpMessage: view.options.helpMessage, + validValue: 'My Name', + invalidValue1: '', + invalidValue2: '@', + validationError: 'Think again!', + defaultValue: '' + }, requests); + } + + expect(sectionsData[1].fields.length).toBe(4); + var dropdownFields = [ + sectionsData[1].fields[0], + sectionsData[1].fields[1], + sectionsData[1].fields[2] + ]; + _.each(dropdownFields, function(field) { + // eslint-disable-next-line no-shadow + var view = field.view; + FieldViewsSpecHelpers.verifyDropDownField(view, { + title: view.options.title, + valueAttribute: view.options.valueAttribute, + helpMessage: '', + validValue: Helpers.FIELD_OPTIONS[1][0], + invalidValue1: Helpers.FIELD_OPTIONS[2][0], + invalidValue2: Helpers.FIELD_OPTIONS[3][0], + validationError: 'Nope, this will not do!', + defaultValue: null + }, requests); + }); + }); + }); + + describe('edx.user.AccountSettingsFactory', function() { + var createEnterpriseLearnerAccountSettingsPage = function() { + var context = AccountSettingsPage( + Helpers.FIELDS_DATA, + false, + [], + Helpers.AUTH_DATA, + Helpers.PASSWORD_RESET_SUPPORT_LINK, + Helpers.USER_ACCOUNTS_API_URL, + Helpers.USER_PREFERENCES_API_URL, + 1, + Helpers.PLATFORM_NAME, + Helpers.CONTACT_EMAIL, + true, + Helpers.ENABLE_COPPA_COMPLIANCE, + '', + + Helpers.SYNC_LEARNER_PROFILE_DATA, + Helpers.ENTERPRISE_NAME, + Helpers.ENTERPRISE_READ_ONLY_ACCOUNT_FIELDS, + Helpers.EDX_SUPPORT_URL + ); + return context.accountSettingsView; + }; + + var requests; + var accountInfoTab = { + BASIC_ACCOUNT_INFORMATION: 0, + ADDITIONAL_INFORMATION: 1 + }; + var basicAccountInfoFields = { + USERNAME: 0, + FULL_NAME: 1, + EMAIL_ADDRESS: 2, + PASSWORD: 3, + LANGUAGE: 4, + COUNTRY: 5, + TIMEZONE: 6 + }; + var additionalInfoFields = { + EDUCATION: 0, + GENDER: 1, + YEAR_OF_BIRTH: 2, + PREFERRED_LANGUAGE: 3 + }; + + beforeEach(function() { + setFixtures('
'); + }); + + it('shows loading error when UserAccountModel fails to load for enterprise learners', function() { + var accountSettingsView, request; + requests = AjaxHelpers.requests(this); + + accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + request = requests[0]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + }); + + it('shows loading error when UserPreferencesModel fails to load for enterprise learners', function() { + var accountSettingsView, request; + requests = AjaxHelpers.requests(this); + + accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + request = requests[0]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + request = requests[1]; + expect(request.method).toBe('GET'); + expect(request.url).toBe('/api/user/v1/preferences/time_zones/?country_code=1'); + AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); + + request = requests[2]; + expect(request.method).toBe('GET'); + expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL); + + AjaxHelpers.respondWithError(requests, 500); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + }); + + it('renders fields after the models are successfully fetched for enterprise learners', function() { + var accountSettingsView; + requests = AjaxHelpers.requests(this); + + accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + + accountSettingsView.render(); + + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsAndFieldsToBeRenderedWithMessage(accountSettingsView); + }); + + it('expects all fields to behave correctly for enterprise learners', function() { + var accountSettingsView, i, view, sectionsData, textFields, dropdownFields; + requests = AjaxHelpers.requests(this); + + accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); + + AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); + AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); + AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); + AjaxHelpers.respondWithJson(requests, {}); // Page viewed analytics event + + sectionsData = accountSettingsView.options.tabSections.aboutTabSections; + + expect(sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields.length).toBe(7); + + // Verify that username, name and email fields are readonly + textFields = [ + sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.USERNAME], + sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.FULL_NAME], + sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.EMAIL_ADDRESS] + ]; + for (i = 0; i < textFields.length; i++) { + view = textFields[i].view; + + FieldViewsSpecHelpers.verifyReadonlyTextField(view, { + title: view.options.title, + valueAttribute: view.options.valueAttribute, + helpMessage: view.options.helpMessage, + validValue: 'My Name', + defaultValue: '' + }, requests); + } + + // Verify un-editable country dropdown field + view = sectionsData[ + accountInfoTab.BASIC_ACCOUNT_INFORMATION + ].fields[basicAccountInfoFields.COUNTRY].view; + + FieldViewsSpecHelpers.verifyReadonlyDropDownField(view, { + title: view.options.title, + valueAttribute: view.options.valueAttribute, + helpMessage: '', + validValue: Helpers.FIELD_OPTIONS[1][0], + editable: 'never', + defaultValue: null + }); + + expect(sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields.length).toBe(4); + dropdownFields = [ + sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.EDUCATION], + sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.GENDER], + sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.YEAR_OF_BIRTH] + ]; + _.each(dropdownFields, function(field) { + view = field.view; + FieldViewsSpecHelpers.verifyDropDownField(view, { + title: view.options.title, + valueAttribute: view.options.valueAttribute, + helpMessage: '', + validValue: Helpers.FIELD_OPTIONS[1][0], // dummy option for dropdown field + invalidValue1: Helpers.FIELD_OPTIONS[2][0], // dummy option for dropdown field + invalidValue2: Helpers.FIELD_OPTIONS[3][0], // dummy option for dropdown field + validationError: 'Nope, this will not do!', + defaultValue: null + }, requests); + }); + }); + }); +}); diff --git a/lms/static/js/spec/student_account/account_settings_fields_helpers.js b/lms/static/js/spec/student_account/account_settings_fields_helpers.js new file mode 100644 index 000000000000..4aea86b235a3 --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_fields_helpers.js @@ -0,0 +1,34 @@ +define(['backbone', + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/spec/views/fields_helpers', + 'string_utils'], +function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers) { + 'use strict'; + + var verifyAuthField = function(view, data, requests) { + var selector = '.u-field-value .u-field-link-title-' + view.options.valueAttribute; + + spyOn(view, 'redirect_to'); + + FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, data.title, data.helpMessage); + expect(view.$(selector).text().trim()).toBe('Unlink This Account'); + view.$(selector).click(); + FieldViewsSpecHelpers.expectMessageContains(view, 'Unlinking'); + AjaxHelpers.expectRequest(requests, 'POST', data.disconnectUrl); + AjaxHelpers.respondWithNoContent(requests); + + expect(view.$(selector).text().trim()).toBe('Link Your Account'); + FieldViewsSpecHelpers.expectMessageContains(view, 'Successfully unlinked.'); + + view.$(selector).click(); + FieldViewsSpecHelpers.expectMessageContains(view, 'Linking'); + expect(view.redirect_to).toHaveBeenCalledWith(data.connectUrl); + }; + + return { + verifyAuthField: verifyAuthField + }; +}); diff --git a/lms/static/js/spec/student_account/account_settings_fields_spec.js b/lms/static/js/spec/student_account/account_settings_fields_spec.js new file mode 100644 index 000000000000..76ea7c512b7f --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_fields_spec.js @@ -0,0 +1,216 @@ +define(['backbone', + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/student_account/models/user_account_model', + 'js/views/fields', + 'js/spec/views/fields_helpers', + 'js/spec/student_account/account_settings_fields_helpers', + 'js/student_account/views/account_settings_fields', + 'js/student_account/models/user_account_model', + 'string_utils'], +function(Backbone, $, _, AjaxHelpers, TemplateHelpers, UserAccountModel, FieldViews, FieldViewsSpecHelpers, + AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews) { + 'use strict'; + + describe('edx.AccountSettingsFieldViews', function() { + var requests, + timerCallback, // eslint-disable-line no-unused-vars + data; + + beforeEach(function() { + timerCallback = jasmine.createSpy('timerCallback'); + jasmine.clock().install(); + }); + + afterEach(function() { + jasmine.clock().uninstall(); + }); + + it('sends request to reset password on clicking link in PasswordFieldView', function() { + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.PasswordFieldView, { + linkHref: '/password_reset', + emailAttribute: 'email', + valueAttribute: 'password' + }); + + var view = new AccountSettingsFieldViews.PasswordFieldView(fieldData).render(); + expect(view.$('.u-field-value > button').is(':disabled')).toBe(false); + view.$('.u-field-value > button').click(); + expect(view.$('.u-field-value > button').is(':disabled')).toBe(true); + AjaxHelpers.expectRequest(requests, 'POST', '/password_reset', 'email=legolas%40woodland.middlearth'); + AjaxHelpers.respondWithJson(requests, {success: 'true'}); + FieldViewsSpecHelpers.expectMessageContains( + view, + "We've sent a message to legolas@woodland.middlearth. " + + 'Click the link in the message to reset your password.' + ); + }); + + it('update time zone dropdown after country dropdown changes', function() { + var baseSelector = '.u-field-value > select'; + var groupsSelector = baseSelector + '> optgroup'; + var groupOptionsSelector = groupsSelector + '> option'; + + var timeZoneData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.TimeZoneFieldView, { + valueAttribute: 'time_zone', + groupOptions: [{ + groupTitle: gettext('All Time Zones'), + selectOptions: FieldViewsSpecHelpers.SELECT_OPTIONS, + nullValueOptionLabel: 'Default (Local Time Zone)' + }], + persistChanges: true, + required: true + }); + var countryData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { + valueAttribute: 'country', + options: [['KY', 'Cayman Islands'], ['CA', 'Canada'], ['GY', 'Guyana']], + persistChanges: true + }); + + var countryChange = {country: 'GY'}; + var timeZoneChange = {time_zone: 'Pacific/Kosrae'}; + + var timeZoneView = new AccountSettingsFieldViews.TimeZoneFieldView(timeZoneData).render(); + var countryView = new AccountSettingsFieldViews.DropdownFieldView(countryData).render(); + + requests = AjaxHelpers.requests(this); + + timeZoneView.listenToCountryView(countryView); + + // expect time zone dropdown to have single subheader ('All Time Zones') + expect(timeZoneView.$(groupsSelector).length).toBe(1); + expect(timeZoneView.$(groupOptionsSelector).length).toBe(3); + expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); + + // change country + countryView.$(baseSelector).val(countryChange[countryData.valueAttribute]).change(); + countryView.$(baseSelector).focusout(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, countryChange); + AjaxHelpers.respondWithJson(requests, {success: 'true'}); + + AjaxHelpers.expectRequest( + requests, + 'GET', + '/api/user/v1/preferences/time_zones/?country_code=GY' + ); + AjaxHelpers.respondWithJson(requests, [ + {time_zone: 'America/Guyana', description: 'America/Guyana (ECT, UTC-0500)'}, + {time_zone: 'Pacific/Kosrae', description: 'Pacific/Kosrae (KOST, UTC+1100)'} + ]); + + // expect time zone dropdown to have two subheaders (country/all time zone sub-headers) with new values + expect(timeZoneView.$(groupsSelector).length).toBe(2); + expect(timeZoneView.$(groupOptionsSelector).length).toBe(6); + expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe('America/Guyana'); + + // select time zone option from option + timeZoneView.$(baseSelector).val(timeZoneChange[timeZoneData.valueAttribute]).change(); + timeZoneView.$(baseSelector).focusout(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, timeZoneChange); + AjaxHelpers.respondWithJson(requests, {success: 'true'}); + timeZoneView.render(); + + // expect time zone dropdown to have three subheaders (currently selected/country/all time zones) + expect(timeZoneView.$(groupsSelector).length).toBe(3); + expect(timeZoneView.$(groupOptionsSelector).length).toBe(6); + expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe('Pacific/Kosrae'); + }); + + it('sends request to /i18n/setlang/ after changing language in LanguagePreferenceFieldView', function() { + requests = AjaxHelpers.requests(this); + + var selector = '.u-field-value > select'; + var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { + valueAttribute: 'language', + options: FieldViewsSpecHelpers.SELECT_OPTIONS, + persistChanges: true + }); + + var view = new AccountSettingsFieldViews.LanguagePreferenceFieldView(fieldData).render(); + + data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]}; + view.$(selector).val(data[fieldData.valueAttribute]).change(); + view.$(selector).focusout(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); + AjaxHelpers.respondWithNoContent(requests); + + AjaxHelpers.expectRequest( + requests, + 'POST', + '/i18n/setlang/', + $.param({ + language: data[fieldData.valueAttribute], + next: window.location.href + }) + ); + // Django will actually respond with a 302 redirect, but that would cause a page load during these + // unittests. 204 should work fine for testing. + AjaxHelpers.respondWithNoContent(requests); + FieldViewsSpecHelpers.expectMessageContains(view, 'Your changes have been saved.'); + + data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}; + view.$(selector).val(data[fieldData.valueAttribute]).change(); + view.$(selector).focusout(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); + AjaxHelpers.respondWithNoContent(requests); + + AjaxHelpers.expectRequest( + requests, + 'POST', + '/i18n/setlang/', + $.param({ + language: data[fieldData.valueAttribute], + next: window.location.href + }) + ); + AjaxHelpers.respondWithError(requests, 500); + FieldViewsSpecHelpers.expectMessageContains( + view, + 'You must sign out and sign back in before your language changes take effect.' + ); + }); + + it('reads and saves the value correctly for LanguageProficienciesFieldView', function() { + requests = AjaxHelpers.requests(this); + + var selector = '.u-field-value > select'; + var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { + valueAttribute: 'language_proficiencies', + options: FieldViewsSpecHelpers.SELECT_OPTIONS, + persistChanges: true + }); + fieldData.model.set({language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]}); + + var view = new AccountSettingsFieldViews.LanguageProficienciesFieldView(fieldData).render(); + + expect(view.modelValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); + + data = {language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]}; + view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change(); + view.$(selector).focusout(); + FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); + AjaxHelpers.respondWithNoContent(requests); + }); + + it('correctly links and unlinks from AuthFieldView', function() { + requests = AjaxHelpers.requests(this); + + var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, { + title: 'Yet another social network', + helpMessage: '', + valueAttribute: 'auth-yet-another', + connected: true, + acceptsLogins: 'true', + connectUrl: 'yetanother.com/auth/connect', + disconnectUrl: 'yetanother.com/auth/disconnect' + }); + var view = new AccountSettingsFieldViews.AuthFieldView(fieldData).render(); + + AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, fieldData, requests); + }); + }); +}); diff --git a/lms/static/js/spec/student_account/account_settings_view_spec.js b/lms/static/js/spec/student_account/account_settings_view_spec.js new file mode 100644 index 000000000000..c0c213cf3c5c --- /dev/null +++ b/lms/static/js/spec/student_account/account_settings_view_spec.js @@ -0,0 +1,91 @@ +define(['backbone', + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/views/account_settings_view' +], +function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, FieldViews, UserAccountModel, + AccountSettingsView) { + 'use strict'; + + describe('edx.user.AccountSettingsView', function() { + var createAccountSettingsView = function() { + var model = new UserAccountModel(); + model.set(Helpers.createAccountSettingsData()); + + var aboutSectionsData = [ + { + title: 'Basic Account Information', + messageType: 'info', + message: 'Your profile settings are managed by Test Enterprise. ' + + 'Contact your administrator or edX Support for help.', + fields: [ + { + view: new FieldViews.ReadonlyFieldView({ + model: model, + title: 'Username', + valueAttribute: 'username' + }) + }, + { + view: new FieldViews.TextFieldView({ + model: model, + title: 'Full Name', + valueAttribute: 'name' + }) + } + ] + }, + { + title: 'Additional Information', + fields: [ + { + view: new FieldViews.DropdownFieldView({ + model: model, + title: 'Education Completed', + valueAttribute: 'level_of_education', + options: Helpers.FIELD_OPTIONS + }) + } + ] + } + ]; + + var accountSettingsView = new AccountSettingsView({ + el: $('.wrapper-account-settings'), + model: model, + tabSections: { + aboutTabSections: aboutSectionsData + } + }); + + return accountSettingsView; + }; + + beforeEach(function() { + setFixtures('
'); + }); + + it('shows loading error correctly', function() { + var accountSettingsView = createAccountSettingsView(); + + accountSettingsView.render(); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + + accountSettingsView.showLoadingError(); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); + }); + + it('renders all fields as expected', function() { + var accountSettingsView = createAccountSettingsView(); + + accountSettingsView.render(); + Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); + Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); + }); + }); +}); diff --git a/lms/static/js/student_account/views/account_section_view.js b/lms/static/js/student_account/views/account_section_view.js new file mode 100644 index 000000000000..70cd217477a4 --- /dev/null +++ b/lms/static/js/student_account/views/account_section_view.js @@ -0,0 +1,48 @@ +// eslint-disable-next-line no-shadow-restricted-names +(function(define, undefined) { + 'use strict'; + + define([ + 'gettext', + 'jquery', + 'underscore', + 'backbone', + 'edx-ui-toolkit/js/utils/html-utils', + 'text!templates/student_account/account_settings_section.underscore' + ], function(gettext, $, _, Backbone, HtmlUtils, sectionTemplate) { + var AccountSectionView = Backbone.View.extend({ + + initialize: function(options) { + this.options = options; + _.bindAll(this, 'render', 'renderFields'); + }, + + render: function() { + HtmlUtils.setHtml( + this.$el, + HtmlUtils.template(sectionTemplate)({ + HtmlUtils: HtmlUtils, + sections: this.options.sections, + tabName: this.options.tabName, + tabLabel: this.options.tabLabel + }) + ); + + this.renderFields(); + }, + + renderFields: function() { + var view = this; + + _.each(view.$('.' + view.options.tabName + '-section-body'), function(sectionEl, index) { + _.each(view.options.sections[index].fields, function(field) { + $(sectionEl).append(field.view.render().el); + }); + }); + return this; + } + }); + + return AccountSectionView; + }); +}).call(this, define || RequireJS.define); 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 000000000000..70d3ad205c10 --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -0,0 +1,495 @@ +// 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 + }) + }); + } + } + } + + // 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 000000000000..1fc174f93588 --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_fields.js @@ -0,0 +1,466 @@ +// 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_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_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.field_name, + 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); + } + } + }), + 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/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js new file mode 100644 index 000000000000..6ee9c9101d6c --- /dev/null +++ b/lms/static/js/student_account/views/account_settings_view.js @@ -0,0 +1,157 @@ +// eslint-disable-next-line no-shadow-restricted-names +(function(define, undefined) { + 'use strict'; + + define([ + 'gettext', + 'jquery', + 'underscore', + 'common/js/components/views/tabbed_view', + 'edx-ui-toolkit/js/utils/html-utils', + 'js/student_account/views/account_section_view', + 'text!templates/student_account/account_settings.underscore' + ], function(gettext, $, _, TabbedView, HtmlUtils, AccountSectionView, accountSettingsTemplate) { + var AccountSettingsView = TabbedView.extend({ + + navLink: '.account-nav-link', + activeTab: 'aboutTabSections', + events: { + 'click .account-nav-link': 'switchTab', + 'keydown .account-nav-link': 'keydownHandler', + 'click .btn-alert-primary': 'revertValue' + }, + + initialize: function(options) { + this.options = options; + _.bindAll(this, 'render', 'switchTab', 'setActiveTab', 'showLoadingError'); + }, + + render: function() { + var tabName, betaLangMessage, helpTranslateText, helpTranslateLink, betaLangCode, oldLangCode, + view = this; + var accountSettingsTabs = [ + { + name: 'aboutTabSections', + id: 'about-tab', + label: gettext('Account Information'), + class: 'active', + tabindex: 0, + selected: true, + expanded: true + }, + { + name: 'accountsTabSections', + id: 'accounts-tab', + label: gettext('Linked Accounts'), + tabindex: -1, + selected: false, + expanded: false + } + ]; + if (!view.options.disableOrderHistoryTab) { + accountSettingsTabs.push({ + name: 'ordersTabSections', + id: 'orders-tab', + label: gettext('Order History'), + tabindex: -1, + selected: false, + expanded: false + }); + } + + if (!_.isEmpty(view.options.betaLanguage) && $.cookie('old-pref-lang')) { + betaLangMessage = HtmlUtils.interpolateHtml( + gettext('You have set your language to {beta_language}, which is currently not fully translated. You can help us translate this language fully by joining the Transifex community and adding translations from English for learners that speak {beta_language}.'), // eslint-disable-line max-len + { + beta_language: view.options.betaLanguage.name + } + ); + helpTranslateText = HtmlUtils.interpolateHtml( + gettext('Help Translate into {beta_language}'), + { + beta_language: view.options.betaLanguage.name + } + ); + betaLangCode = this.options.betaLanguage.code.split('-'); + if (betaLangCode.length > 1) { + betaLangCode = betaLangCode[0] + '_' + betaLangCode[1].toUpperCase(); + } else { + betaLangCode = betaLangCode[0]; + } + helpTranslateLink = 'https://www.transifex.com/open-edx/edx-platform/translate/#' + betaLangCode; + oldLangCode = $.cookie('old-pref-lang'); + // Deleting the cookie + document.cookie = 'old-pref-lang=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/account;'; + + $.cookie('focus_id', '#beta-language-message'); + } + HtmlUtils.setHtml(this.$el, HtmlUtils.template(accountSettingsTemplate)({ + accountSettingsTabs: accountSettingsTabs, + HtmlUtils: HtmlUtils, + message: betaLangMessage, + helpTranslateText: helpTranslateText, + helpTranslateLink: helpTranslateLink, + oldLangCode: oldLangCode + })); + _.each(accountSettingsTabs, function(tab) { + tabName = tab.name; + view.renderSection(view.options.tabSections[tabName], tabName, tab.label); + }); + return this; + }, + + switchTab: function(e) { + var $currentTab, + $accountNavLink = $('.account-nav-link'); + + if (e) { + e.preventDefault(); + $currentTab = $(e.target); + this.activeTab = $currentTab.data('name'); + + _.each(this.$('.account-settings-tabpanels'), function(tabPanel) { + $(tabPanel).addClass('hidden'); + }); + + $('#' + this.activeTab + '-tabpanel').removeClass('hidden'); + + $accountNavLink.attr('tabindex', -1); + $accountNavLink.attr('aria-selected', false); + $accountNavLink.attr('aria-expanded', false); + + $currentTab.attr('tabindex', 0); + $currentTab.attr('aria-selected', true); + $currentTab.attr('aria-expanded', true); + + $(this.navLink).removeClass('active'); + $currentTab.addClass('active'); + } + }, + + setActiveTab: function() { + this.switchTab(); + }, + + renderSection: function(tabSections, tabName, tabLabel) { + var accountSectionView = new AccountSectionView({ + tabName: tabName, + tabLabel: tabLabel, + sections: tabSections, + el: '#' + tabName + '-tabpanel' + }); + + accountSectionView.render(); + }, + + showLoadingError: function() { + this.$('.ui-loading-error').removeClass('is-hidden'); + }, + + revertValue: function(event) { + this.options.userPreferencesModel.trigger('revertValue', event); + } + }); + + return AccountSettingsView; + }); +}).call(this, define || RequireJS.define); diff --git a/lms/static/learner_profile b/lms/static/learner_profile new file mode 120000 index 000000000000..ca7ce1f79785 --- /dev/null +++ b/lms/static/learner_profile @@ -0,0 +1 @@ +../../openedx/features/learner_profile/static/learner_profile \ No newline at end of file diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index 1d5e1a983be0..c22f366c5d2b 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -33,8 +33,10 @@ 'js/discussions_management/views/discussions_dashboard_factory', 'js/header_factory', 'js/student_account/logistration_factory', + 'js/student_account/views/account_settings_factory', 'js/student_account/views/finish_auth_factory', 'js/views/message_banner', + 'learner_profile/js/learner_profile_factory', 'lms/js/preview/preview_factory', 'support/js/certificates_factory', 'support/js/enrollment_factory', diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 4b6b0d9ec64a..795782948f0b 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -761,6 +761,9 @@ 'js/spec/shoppingcart/shoppingcart_spec.js', 'js/spec/staff_debug_actions_spec.js', 'js/spec/student_account/access_spec.js', + 'js/spec/student_account/account_settings_factory_spec.js', + 'js/spec/student_account/account_settings_fields_spec.js', + 'js/spec/student_account/account_settings_view_spec.js', 'js/spec/student_account/emailoptin_spec.js', 'js/spec/student_account/enrollment_spec.js', 'js/spec/student_account/finish_auth_spec.js', @@ -784,6 +787,14 @@ 'js/spec/views/file_uploader_spec.js', 'js/spec/views/message_banner_spec.js', 'js/spec/views/notification_spec.js', + 'learner_profile/js/spec/learner_profile_factory_spec.js', + 'learner_profile/js/spec/views/badge_list_container_spec.js', + 'learner_profile/js/spec/views/badge_list_view_spec.js', + 'learner_profile/js/spec/views/badge_view_spec.js', + 'learner_profile/js/spec/views/learner_profile_fields_spec.js', + 'learner_profile/js/spec/views/learner_profile_view_spec.js', + 'learner_profile/js/spec/views/section_two_tab_spec.js', + 'learner_profile/js/spec/views/share_modal_view_spec.js', 'support/js/spec/collections/enrollment_spec.js', 'support/js/spec/models/enrollment_spec.js', 'support/js/spec/views/certificates_spec.js', diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 4d64e6768515..1171e3d14cdf 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -52,6 +52,7 @@ @import 'multicourse/survey-page'; // base - specific views +@import 'views/account-settings'; @import 'views/course-entitlements'; @import 'views/login-register'; @import 'views/verification'; @@ -68,6 +69,7 @@ // features @import 'features/bookmarks-v1'; @import "features/announcements"; +@import 'features/learner-profile'; @import 'features/_unsupported-browser-alert'; @import 'features/content-type-gating'; @import 'features/course-duration-limits'; diff --git a/lms/static/sass/features/_learner-profile.scss b/lms/static/sass/features/_learner-profile.scss new file mode 100644 index 000000000000..8d35a7eccc67 --- /dev/null +++ b/lms/static/sass/features/_learner-profile.scss @@ -0,0 +1,875 @@ +// lms - application - learner profile +// ==================== + +.learner-achievements { + .learner-message { + @extend %no-content; + + margin: $baseline*0.75 0; + + .message-header, + .message-actions { + text-align: center; + } + + .message-actions { + margin-top: $baseline/2; + + .btn-brand { + color: $white; + } + } + } +} + +.certificate-card { + display: flex; + flex-direction: row; + margin-bottom: $baseline; + padding: $baseline/2; + border: 1px; + border-style: solid; + background-color: $white; + cursor: pointer; + + &:hover { + box-shadow: 0 0 1px 1px $gray-l2; + } + + .card-logo { + @include margin-right($baseline); + + width: 100px; + height: 100px; + + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + display: none; + } + } + + .card-content { + color: $body-color; + margin-top: $baseline/2; + } + + .card-supertitle { + @extend %t-title6; + + color: $lightest-base-font-color; + } + + .card-title { + @extend %t-title5; + @extend %t-strong; + + margin-bottom: $baseline/2; + } + + .card-text { + @extend %t-title8; + + color: $lightest-base-font-color; + } + + &.mode-audit { + border-color: $audit-mode-color; + + .card-logo { + background-image: url('#{$static-path}/images/certificates/audit.png'); + } + } + + &.mode-honor { + border-color: $honor-mode-color; + + .card-logo { + background-image: url('#{$static-path}/images/certificates/honor.png'); + } + } + + &.mode-verified { + border-color: $verified-mode-color; + + .card-logo { + background-image: url('#{$static-path}/images/certificates/verified.png'); + } + } + + &.mode-professional { + border-color: $professional-certificate-color; + + .card-logo { + background-image: url('#{$static-path}/images/certificates/professional.png'); + } + } +} + +.view-profile { + $profile-image-dimension: 120px; + + .window-wrap, + .content-wrapper { + background-color: $body-bg; + padding: 0; + margin-top: 0; + } + + .page-banner { + background-color: $gray-l4; + max-width: none; + + .user-messages { + max-width: map-get($container-max-widths, xl); + margin: auto; + padding: $baseline/2; + } + } + + .ui-loading-indicator { + @extend .ui-loading-base; + + padding-bottom: $baseline; + + // center horizontally + @include margin-left(auto); + @include margin-right(auto); + + width: ($baseline*5); + } + + .profile-image-field { + button { + background: transparent !important; + border: none !important; + padding: 0; + } + + .u-field-image { + padding-top: 0; + padding-bottom: ($baseline/4); + } + + .image-wrapper { + width: $profile-image-dimension; + position: relative; + margin: auto; + + .image-frame { + display: block; + position: relative; + width: $profile-image-dimension; + height: $profile-image-dimension; + border-radius: ($profile-image-dimension/2); + overflow: hidden; + border: 3px solid $gray-l6; + margin-top: $baseline*-0.75; + background: $white; + } + + .u-field-upload-button { + position: absolute; + top: 0; + opacity: 0; + width: $profile-image-dimension; + height: $profile-image-dimension; + border-radius: ($profile-image-dimension/2); + border: 2px dashed transparent; + background: rgba(229, 241, 247, 0.8); + color: $link-color; + text-shadow: none; + + @include transition(all $tmg-f1 ease-in-out 0s); + + z-index: 6; + + i { + color: $link-color; + } + + &:focus, + &:hover { + @include show-hover-state(); + + border-color: $link-color; + } + + &.in-progress { + opacity: 1; + } + } + + .button-visible { + @include show-hover-state(); + } + + .upload-button-icon, + .upload-button-title { + display: block; + margin-bottom: ($baseline/4); + + @include transform(translateY(35px)); + + line-height: 1.3em; + text-align: center; + z-index: 7; + color: $body-color; + } + + .upload-button-input { + position: absolute; + top: 0; + + @include left(0); + + width: $profile-image-dimension; + border-radius: ($profile-image-dimension/2); + height: 100%; + cursor: pointer; + z-index: 5; + outline: 0; + opacity: 0; + } + + .u-field-remove-button { + position: relative; + display: block; + width: $profile-image-dimension; + margin-top: ($baseline / 4); + padding: ($baseline / 5) 0 0; + text-align: center; + opacity: 0; + transition: opacity 0.5s; + } + + &:hover, + &:active { + .u-field-remove-button { + opacity: 1; + } + } + } + } + + .wrapper-profile { + min-height: 200px; + background-color: $gray-l6; + + .ui-loading-indicator { + margin-top: 100px; + } + } + + .profile-self { + .wrapper-profile-field-account-privacy { + @include clearfix(); + + box-sizing: border-box; + width: 100%; + margin: 0 auto; + border-bottom: 1px solid $gray-l3; + background-color: $gray-l4; + padding: ($baseline*0.75) 5%; + display: table; + + .wrapper-profile-records { + display: table-row; + + button { + @extend %btn-secondary-blue-outline; + + margin-top: 1em; + background: $blue; + color: #fff; + } + } + + @include media-breakpoint-up(sm) { + .wrapper-profile-records { + display: table-cell; + vertical-align: middle; + white-space: nowrap; + + button { + margin-top: 0; + } + } + } + + .u-field-account_privacy { + @extend .container; + + display: table-cell; + border: none; + box-shadow: none; + padding: 0; + margin: 0; + vertical-align: middle; + + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + max-width: calc(100% - 40px); + min-width: auto; + } + + .btn-change-privacy { + @extend %btn-primary-blue; + + padding-top: 4px; + padding-bottom: 5px; + background-image: none; + box-shadow: none; + } + } + + .u-field-title { + @extend %t-strong; + + width: auto; + color: $body-color; + cursor: text; + text-shadow: none; // override bad lms styles on labels + } + + .u-field-value { + width: auto; + + @include margin-left($baseline/2); + } + + .u-field-message { + @include float(left); + + width: 100%; + padding: 0; + color: $body-color; + + .u-field-message-notification { + color: $gray-d2; + } + } + } + } + + .wrapper-profile-sections { + @extend .container; + + @include padding($baseline*1.5, 5%, $baseline*1.5, 5%); + + display: flex; + min-width: 0; + max-width: 100%; + + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + @include margin-left(0); + + flex-wrap: wrap; + } + } + + .profile-header { + max-width: map-get($container-max-widths, xl); + margin: auto; + padding: $baseline 5% 0; + + .header { + @extend %t-title4; + @extend %t-ultrastrong; + + display: inline-block; + color: #222; + } + + .subheader { + @extend %t-title6; + } + } + + .wrapper-profile-section-container-one { + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + width: 100%; + } + + .wrapper-profile-section-one { + width: 300px; + background-color: $white; + border-top: 5px solid $blue; + padding-bottom: $baseline; + + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + @include margin-left(0); + + width: 100%; + } + + .profile-section-one-fields { + margin: 0 $baseline/2; + + .social-links { + @include padding($baseline/4, 0, 0, $baseline/4); + + font-size: 2rem; + + & > span { + color: $gray-l4; + } + + a { + .fa-facebook-square { + color: $facebook-blue; + } + + .fa-twitter-square { + color: $twitter-blue; + } + + .fa-linkedin-square { + color: $linkedin-blue; + } + } + } + + .u-field { + font-weight: $font-semibold; + + @include padding(0, 0, 0, 3px); + + color: $body-color; + margin-top: $baseline/5; + + .u-field-value, + .u-field-title { + font-weight: 500; + width: calc(100% - 40px); + color: $lightest-base-font-color; + } + + .u-field-value-readonly { + font-family: $font-family-sans-serif; + color: $darkest-base-font-color; + } + + &.u-field-dropdown { + position: relative; + + &:not(.editable-never) { + cursor: pointer; + } + } + + &:not(.u-field-readonly) { + &.u-field-value { + @extend %t-weight3; + } + + &:not(:last-child) { + padding-bottom: $baseline/4; + border-bottom: 1px solid $border-color; + + &:hover.mode-placeholder { + padding-bottom: $baseline/5; + border-bottom: 2px dashed $link-color; + } + } + } + } + + & > .u-field { + &:not(:first-child) { + font-size: $body-font-size; + color: $body-color; + font-weight: $font-light; + margin-bottom: 0; + } + + &:first-child { + @extend %t-title4; + @extend %t-weight4; + + font-size: em(24); + } + } + + select { + width: 85%; + } + + .u-field-message { + @include right(0); + + position: absolute; + top: 0; + width: 20px; + + .icon { + vertical-align: baseline; + } + } + } + } + } + + + .wrapper-profile-section-container-two { + @include float(left); + @include padding-left($baseline); + + font-family: $font-family-sans-serif; + flex-grow: 1; + + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + width: 90%; + margin-top: $baseline; + padding: 0; + } + + .u-field-textarea { + @include padding(0, ($baseline*0.75), ($baseline*0.75), 0); + + margin-bottom: ($baseline/2); + + @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap + @include padding-left($baseline/4); + } + + .u-field-header { + position: relative; + + .u-field-message { + @include right(0); + + top: $baseline/4; + position: absolute; + } + } + + &.editable-toggle { + cursor: pointer; + } + } + + .u-field-title { + @extend %t-title6; + + display: inline-block; + margin-top: 0; + margin-bottom: ($baseline/4); + color: $gray-d3; + width: 100%; + font: $font-semibold 1.4em/1.4em $font-family-sans-serif; + } + + .u-field-value { + @extend %t-copy-base; + + width: 100%; + overflow: auto; + + textarea { + width: 100%; + background-color: transparent; + border-radius: 5px; + border-color: $gray-d1; + resize: none; + white-space: pre-line; + outline: 0; + box-shadow: none; + -webkit-appearance: none; + } + + a { + color: inherit; + } + } + + .u-field-message { + @include float(right); + + width: auto; + + .message-can-edit { + position: absolute; + } + } + + .u-field.mode-placeholder { + padding: $baseline; + margin: $baseline*0.75 0; + border: 2px dashed $gray-l3; + + i { + font-size: 12px; + + @include padding-right(5px); + + vertical-align: middle; + color: $body-color; + } + + .u-field-title { + width: 100%; + text-align: center; + } + + .u-field-value { + text-align: center; + line-height: 1.5em; + + @extend %t-copy-sub1; + + color: $body-color; + } + + &:hover { + border: 2px dashed $link-color; + + .u-field-title, + i { + color: $link-color; + } + } + } + + .wrapper-u-field { + font-size: $body-font-size; + color: $body-color; + + .u-field-header .u-field-title { + color: $body-color; + } + + .u-field-footer { + .field-textarea-character-count { + @extend %t-weight1; + + @include float(right); + + margin-top: $baseline/4; + } + } + } + + .profile-private-message { + @include padding-left($baseline*0.75); + + line-height: 3em; + } + } + + .badge-paging-header { + padding-top: $baseline; + } + + .page-content-nav { + @extend %page-content-nav; + } + + .badge-set-display { + @extend .container; + + padding: 0; + + .badge-list { + // We're using a div instead of ul for accessibility, so we have to match the style + // used by ul. + margin: 1em 0; + padding: 0 0 0 40px; + } + + .badge-display { + width: 50%; + display: inline-block; + vertical-align: top; + padding: 2em 0; + + .badge-image-container { + padding-right: $baseline; + margin-left: 1em; + width: 20%; + vertical-align: top; + display: inline-block; + + img.badge { + width: 100%; + } + + .accomplishment-placeholder { + border: 4px dotted $gray-l4; + border-radius: 50%; + display: block; + width: 100%; + padding-bottom: 100%; + } + } + + .badge-details { + @extend %t-copy-sub1; + @extend %t-regular; + + max-width: 70%; + display: inline-block; + color: $gray-d1; + + .badge-name { + @extend %t-strong; + @extend %t-copy-base; + + color: $gray-d3; + } + + .badge-description { + padding-bottom: $baseline; + line-height: 1.5em; + } + + .badge-date-stamp { + @extend %t-copy-sub1; + } + + .find-button-container { + border: 1px solid $blue-l1; + padding: ($baseline / 2) $baseline ($baseline / 2) $baseline; + display: inline-block; + border-radius: 5px; + font-weight: bold; + color: $blue-s3; + } + + .share-button { + @extend %t-action3; + @extend %button-reset; + + background: $gray-l6; + color: $gray-d1; + padding: ($baseline / 4) ($baseline / 2); + margin-bottom: ($baseline / 2); + display: inline-block; + border-radius: 5px; + border: 2px solid $gray-d1; + cursor: pointer; + transition: background 0.5s; + + .share-prefix { + display: inline-block; + vertical-align: middle; + } + + .share-icon-container { + display: inline-block; + + img.icon-mozillaopenbadges { + max-width: 1.5em; + margin-right: 0.25em; + } + } + + &:hover { + background: $gray-l4; + } + + &:active { + box-shadow: inset 0 4px 15px 0 $black-t2; + transition: none; + } + } + } + } + + .badge-placeholder { + background-color: $gray-l7; + box-shadow: inset 0 0 4px 0 $gray-l4; + } + } + + // ------------------------------ + // #BADGES MODAL + // ------------------------------ + .badges-overlay { + @extend %ui-depth1; + + position: fixed; + top: 0; + left: 0; + background-color: $dark-trans-bg; /* dim the background */ + width: 100%; + height: 100%; + vertical-align: middle; + + .badges-modal { + @extend %t-copy-lead1; + @extend %ui-depth2; + + color: $lighter-base-font-color; + box-sizing: content-box; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 80%; + max-width: 700px; + max-height: calc(100% - 100px); + margin-right: auto; + margin-left: auto; + border-top: rem(10) solid $blue-l2; + background: $light-gray3; + padding-right: ($baseline * 2); + padding-left: ($baseline * 2); + padding-bottom: ($baseline); + overflow-x: hidden; + + .modal-header { + margin-top: ($baseline / 2); + margin-bottom: ($baseline / 2); + } + + .close { + @extend %button-reset; + @extend %t-strong; + + color: $lighter-base-font-color; + position: absolute; + right: ($baseline); + top: $baseline; + cursor: pointer; + padding: ($baseline / 4) ($baseline / 2); + + @include transition(all $tmg-f2 ease-in-out 0s); + + &:focus, + &:hover { + background-color: $blue-d2; + border-radius: 3px; + color: $white; + } + } + + .badges-steps { + display: table; + } + + .image-container { + // Lines the image up with the content of the above list. + @include ltr { + @include padding-left(2em); + } + + @include rtl { + @include padding-right(1em); + + float: right; + } + } + + .backpack-logo { + @include float(right); + @include margin-left($baseline); + } + } + } + + .modal-hr { + display: block; + border: none; + background-color: $light-gray; + height: rem(2); + width: 100%; + } +} diff --git a/lms/static/sass/partials/lms/theme/_variables-v1.scss b/lms/static/sass/partials/lms/theme/_variables-v1.scss index 5dca9b849534..1cff0168aced 100644 --- a/lms/static/sass/partials/lms/theme/_variables-v1.scss +++ b/lms/static/sass/partials/lms/theme/_variables-v1.scss @@ -527,6 +527,9 @@ $palette-success-border: #b9edb9; $palette-success-back: #ecfaec; $palette-success-text: #008100; +// learner profile elements +$learner-profile-container-flex: 768px; + // course elements $course-bg-color: $uxpl-grayscale-x-back !default; $account-content-wrapper-bg: shade($body-bg, 2%) !default; diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss new file mode 100644 index 000000000000..a4e5ff76eab6 --- /dev/null +++ b/lms/static/sass/views/_account-settings.scss @@ -0,0 +1,683 @@ +// lms - application - account settings +// ==================== + +// Table of Contents +// * +Container - Account Settings +// * +Main - Header +// * +Settings Section +// * +Alert Messages + + +// +Container - Account Settings +.wrapper-account-settings { + background: $white; + width: 100%; + + .account-settings-container { + max-width: grid-width(12); + padding: 10px; + margin: 0 auto; + } + + .ui-loading-indicator, + .ui-loading-error { + @extend .ui-loading-base; + // center horizontally + @include margin-left(auto); + @include margin-right(auto); + + padding: ($baseline*3); + text-align: center; + + .message-error { + color: $alert-color; + } + } +} + +// +Main - Header +.wrapper-account-settings { + .wrapper-header { + max-width: grid-width(12); + height: 139px; + border-bottom: 4px solid $m-gray-l4; + + .header-title { + @extend %t-title4; + + margin-bottom: ($baseline/2); + padding-top: ($baseline*2); + } + + .header-subtitle { + color: $gray-l2; + } + + .account-nav { + @include float(left); + + margin: ($baseline/2) 0; + padding: 0; + list-style: none; + + .account-nav-link { + @include float(left); + + font-size: em(14); + color: $gray; + padding: $baseline/4 $baseline*1.25 $baseline; + display: inline-block; + box-shadow: none; + border-bottom: 4px solid transparent; + border-radius: 0; + background: transparent none; + } + + button { + @extend %ui-clear-button; + @extend %btn-no-style; + + @include appearance(none); + + display: block; + padding: ($baseline/4); + + &:hover, + &:focus { + text-decoration: none; + border-bottom-color: $courseware-border-bottom-color; + } + + &.active { + border-bottom-color: theme-color("dark"); + } + } + } + + @include media-breakpoint-down(md) { + border-bottom-color: transparent; + + .account-nav { + display: flex; + border-bottom: none; + + .account-nav-link { + border-bottom: 4px solid theme-color("light"); + } + } + } + } +} + +// +Settings Section +.account-settings-sections { + .section-header { + @extend %t-title5; + @extend %t-strong; + + padding-top: ($baseline/2)*3; + color: $dark-gray1; + } + + .section { + background-color: $white; + margin: $baseline 5% 0; + border-bottom: 4px solid $m-gray-l4; + + .account-settings-header-subtitle { + font-size: em(14); + line-height: normal; + color: $dark-gray; + padding-bottom: 10px; + } + + .account-settings-header-subtitle-warning { + @extend .account-settings-header-subtitle; + + color: $alert-color; + } + + .account-settings-section-body { + .u-field { + border-bottom: 2px solid $m-gray-l4; + padding: $baseline*0.75 0; + + .field { + width: 30%; + vertical-align: top; + display: inline-block; + position: relative; + + select { + @include appearance(none); + + padding: 14px 30px 14px 15px; + border: 1px solid $gray58-border; + background-color: transparent; + border-radius: 2px; + position: relative; + z-index: 10; + + &::-ms-expand { + display: none; + } + + ~ .icon-caret-down { + &::after { + content: ""; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 7px solid $blue; + position: absolute; + right: 10px; + bottom: 20px; + z-index: 0; + } + } + } + + .field-label { + display: block; + width: auto; + margin-bottom: 0.625rem; + font-size: 1rem; + line-height: 1; + color: $dark-gray; + white-space: nowrap; + } + + .field-input { + @include transition(all 0.125s ease-in-out 0s); + + display: inline-block; + padding: 0.625rem; + border: 1px solid $gray58-border; + border-radius: 2px; + background: $white; + font-size: $body-font-size; + color: $dark-gray; + width: 100%; + height: 48px; + box-shadow: none; + } + + .u-field-link { + @extend %ui-clear-button; + + // set styles + @extend %btn-pl-default-base; + + @include font-size(18); + + width: 100%; + border: 1px solid $blue; + color: $blue; + padding: 11px 14px; + line-height: normal; + } + } + + .u-field-order { + display: flex; + align-items: center; + font-size: em(16); + font-weight: 600; + color: $dark-gray; + width: 100%; + padding-top: $baseline; + padding-bottom: $baseline; + line-height: normal; + flex-flow: row wrap; + + span { + padding: $baseline; + } + + .u-field-order-number { + @include float(left); + + width: 30%; + } + + .u-field-order-date { + @include float(left); + + padding-left: 30px; + width: 20%; + } + + .u-field-order-price { + @include float(left); + + width: 15%; + } + + .u-field-order-link { + width: 10%; + padding: 0; + + .u-field-link { + @extend %ui-clear-button; + @extend %btn-pl-default-base; + + @include font-size(14); + + border: 1px solid $blue; + color: $blue; + line-height: normal; + padding: 10px; + width: 110px; + } + } + } + + .u-field-order-lines { + @extend .u-field-order; + + padding: 5px 0 0; + font-weight: 100; + + .u-field-order-number { + padding: 20px 10px 20px 30px; + } + } + + .social-field-linked { + background: $m-gray-l4; + box-shadow: 0 1px 2px 1px $shadow-l2; + padding: 1.25rem; + box-sizing: border-box; + margin: 10px; + width: 100%; + + .field-label { + @include font-size(24); + } + + .u-field-social-help { + display: inline-block; + padding: 20px 0 6px; + } + + .u-field-link { + @include font-size(14); + @include text-align(left); + + border: none; + margin-top: $baseline; + font-weight: $font-semibold; + padding: 0; + + &:focus, + &:hover, + &:active { + background-color: transparent; + color: $m-blue-d3; + border: none; + } + } + } + + .social-field-unlinked { + background: $m-gray-l4; + box-shadow: 0 1px 2px 1px $shadow-l2; + padding: 1.25rem; + box-sizing: border-box; + text-align: center; + margin: 10px; + width: 100%; + + .field-label { + @include font-size(24); + + text-align: center; + } + + .u-field-link { + @include font-size(14); + + margin-top: $baseline; + font-weight: $font-semibold; + } + } + + .u-field-message { + position: relative; + padding: $baseline*0.75 0 0 ($baseline*4); + width: 60%; + + .u-field-message-notification { + position: absolute; + left: 0; + top: 0; + bottom: 0; + margin: auto; + padding: 38px 0 0 ($baseline*5); + } + } + + &:last-child { + border-bottom: none; + margin-bottom: ($baseline*2); + } + + // Responsive behavior + @include media-breakpoint-down(md) { + .u-field-value { + width: 100%; + } + + .u-field-message { + width: 100%; + padding: $baseline/2 0; + + .u-field-message-notification { + position: relative; + padding: 0; + } + } + + .u-field-order { + display: flex; + flex-wrap: nowrap; + + .u-field-order-number, + .u-field-order-date, + .u-field-order-price, + .u-field-order-link { + width: auto; + float: none; + flex-grow: 1; + + &:first-of-type { + flex-grow: 2; + } + } + } + } + } + + .u-field { + &.u-field-dropdown, + &.editable-never &.mode-display { + .u-field-value { + margin-bottom: ($baseline); + + .u-field-title { + font-size: 16px; + line-height: 22px; + margin-bottom: 18px; + } + + .u-field-value-readonly { + font-size: 22px; + color: #636c72; + line-height: 30px; + white-space: nowrap; + } + } + } + } + + .u-field-readonly .u-field-title { + font-size: 16px; + color: #636c72; + line-height: 22px; + padding-top: ($baseline/2); + padding-bottom: 0; + margin-bottom: 8px !important; + } + + .u-field-readonly .u-field-value { + font-size: 22px; + color: #636c72; + line-height: 30px; + padding-top: 8px; + padding-bottom: ($baseline); + white-space: nowrap; + } + + .u-field-orderHistory { + border-bottom: none; + border: 1px solid $m-gray-l4; + margin-bottom: $baseline; + padding: 0; + + &:last-child { + border-bottom: 1px solid $m-gray-l4; + } + + &:hover, + &:focus { + background-color: $light-gray4; + } + } + + .u-field-order-orderId { + border: none; + margin-top: $baseline; + margin-bottom: 0; + padding-bottom: 0; + + &:hover, + &:focus { + background-color: transparent; + } + + .u-field-order { + font-weight: $font-semibold; + padding-top: 0; + padding-bottom: 0; + + .u-field-order-title { + font-size: em(16); + } + } + } + + .u-field-social { + border-bottom: none; + margin-right: 20px; + width: 30%; + display: inline-block; + vertical-align: top; + + .u-field-social-help { + @include font-size(12); + + color: $m-gray-d1; + } + } + } + + .account-deletion-details { + .btn-outline-primary { + @extend %ui-clear-button; + + // set styles + @extend %btn-pl-default-base; + + @include font-size(18); + + border: 1px solid $blue; + color: $blue; + padding: 11px 14px; + line-height: normal; + margin: 20px 0; + } + + .paragon__modal-open { + overflow-y: scroll; + color: $dark-gray; + + .paragon__modal-title { + font-weight: $font-semibold; + } + + .paragon__modal-body { + line-height: 1.5; + + .alert-title { + line-height: 1.5; + } + } + + .paragon__alert-warning { + color: $dark-gray; + } + + .next-steps { + margin-bottom: 10px; + font-weight: $font-semibold; + } + + .confirm-password-input { + width: 50%; + } + + .paragon__btn:not(.cancel-btn) { + @extend %btn-primary-blue; + } + } + + .modal-alert { + display: flex; + + .icon-wrapper { + padding-right: 15px; + } + + .alert-content { + .alert-title { + color: $dark-gray; + margin-bottom: 10px; + font: { + size: 1rem; + weight: $font-semibold; + } + } + + a { + color: $blue-u1; + } + } + } + + .delete-confirmation-wrapper { + .paragon__modal-footer { + .paragon__btn-outline-primary { + @extend %ui-clear-button; + + // set styles + @extend %btn-pl-default-base; + + @include margin-left(25px); + + border-color: $blue; + color: $blue; + padding: 11px 14px; + line-height: normal; + } + } + } + } + + &:last-child { + border-bottom: none; + } + } +} + +// * +Alert Messages +.account-settings-message, +.account-settings-section-message { + font-size: 16px; + line-height: 22px; + margin-top: 15px; + margin-bottom: 30px; + + .alert-message { + color: #292b2c; + font-family: $font-family-sans-serif; + position: relative; + padding: 10px 10px 10px 35px; + border: 1px solid transparent; + border-radius: 0; + box-shadow: none; + margin-bottom: 8px; + + & > .fa { + position: absolute; + left: 11px; + top: 13px; + font-size: 16px; + } + + span { + display: block; + + a { + text-decoration: underline; + } + } + } + + .success { + background-color: #ecfaec; + border-color: #b9edb9; + } + + .info { + background-color: #d8edf8; + border-color: #bbdff2; + } + + .warning { + background-color: #fcf8e3; + border-color: #faebcc; + } + + .error { + background-color: #f2dede; + border-color: #ebccd1; + } +} + +.account-settings-message { + margin-bottom: 0; + + .alert-message { + padding: 10px; + + .alert-actions { + margin-top: 10px; + + .btn-alert-primary { + @extend %btn-primary-blue; + + @include font-size(18); + + border: 1px solid $m-blue-d3; + border-radius: 3px; + box-shadow: none; + padding: 11px 14px; + line-height: normal; + } + + .btn-alert-secondary { + @extend %ui-clear-button; + + // set styles + @extend %btn-pl-default-base; + + @include font-size(18); + + background-color: white; + border: 1px solid $blue; + color: $blue; + padding: 11px 14px; + line-height: normal; + } + } + } +} diff --git a/lms/templates/dashboard/_dashboard_third_party_error.html b/lms/templates/dashboard/_dashboard_third_party_error.html new file mode 100644 index 000000000000..5b9efe0bbddd --- /dev/null +++ b/lms/templates/dashboard/_dashboard_third_party_error.html @@ -0,0 +1,14 @@ +<%page expression_filter="h"/> + +<%! from django.utils.translation import gettext as _ %> +
+
+
+

${_("Could Not Link Accounts")}

+
+ ## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment. +

${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name=duplicate_provider, platform_name=platform_name)}

+
+
+
+
diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html index 5cb2b5f5b840..2e7e168a6937 100644 --- a/lms/templates/header/user_dropdown.html +++ b/lms/templates/header/user_dropdown.html @@ -4,14 +4,13 @@ <%! import json -from urllib.parse import urljoin from django.conf import settings from django.urls import reverse from django.utils.translation import gettext as _ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user -from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed +from openedx.core.djangoapps.user_api.accounts.toggles import should_redirect_to_order_history_microfrontend from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name, get_enterprise_learner_portal %> @@ -24,7 +23,7 @@ enterprise_customer_portal = get_enterprise_learner_portal(request) ## Enterprises with the learner portal enabled should not show order history, as it does ## not apply to the learner's method of purchasing content. -should_show_order_history = not enterprise_customer_portal +should_show_order_history = should_redirect_to_order_history_microfrontend() and not enterprise_customer_portal %> @@ -36,7 +36,7 @@ % else: diff --git a/lms/urls.py b/lms/urls.py index a82d134afe7a..8b314f9f1ea2 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -669,6 +669,12 @@ include('openedx.features.calendar_sync.urls'), ), + # Learner profile + path( + 'u/', + include('openedx.features.learner_profile.urls'), + ), + # Survey Report re_path( fr'^survey_report/', diff --git a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py index 41f91c7d1eb5..d7578f25eb87 100644 --- a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py +++ b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py @@ -44,6 +44,21 @@ def test_footer(self): # This string comes from header.html of test-theme self.assertContains(resp, "This is a footer for test-theme.") + @with_comprehensive_theme("edx.org") + def test_account_settings_hide_nav(self): + """ + Test that theme header doesn't show marketing site links for Account Settings page. + """ + self._login() + + account_settings_url = reverse('account_settings') + response = self.client.get(account_settings_url) + + # Verify that the header navigation links are hidden for the edx.org version + self.assertNotContains(response, "How it Works") + self.assertNotContains(response, "Find courses") + self.assertNotContains(response, "Schools & Partners") + @with_comprehensive_theme("test-theme") def test_logo_image(self): """ 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 000000000000..002695d4e33f --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/settings_views.py @@ -0,0 +1,299 @@ +""" 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.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] + + 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") + } + + 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 diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py b/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py new file mode 100644 index 000000000000..782549aea0b5 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py @@ -0,0 +1,241 @@ +""" +Test that various filters are fired for views in the certificates app. +""" +from django.http import HttpResponse +from django.test import override_settings +from django.urls import reverse +from openedx_filters import PipelineStep +from openedx_filters.learning.filters import AccountSettingsRenderStarted +from rest_framework import status +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from common.djangoapps.student.tests.factories import UserFactory + + +class TestRenderInvalidAccountSettings(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """ + Pipeline step that stops the course about render process. + """ + raise AccountSettingsRenderStarted.RenderInvalidAccountSettings( + "You can't access the account settings page.", + account_settings_template="static_templates/server-error.html", + ) + + +class TestRedirectToPage(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """ + Pipeline step that redirects to dashboard before rendering the account settings page. + + When raising RedirectToPage, this filter uses a redirect_to field handled by + the course about view that redirects to that URL. + """ + raise AccountSettingsRenderStarted.RedirectToPage( + "You can't access this page, redirecting to dashboard.", + redirect_to="/courses", + ) + + +class TestRedirectToDefaultPage(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """ + Pipeline step that redirects to dashboard before rendering the account settings page. + + When raising RedirectToPage, this filter uses a redirect_to field handled by + the course about view that redirects to that URL. + """ + raise AccountSettingsRenderStarted.RedirectToPage( + "You can't access this page, redirecting to dashboard." + ) + + +class TestRenderCustomResponse(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that returns a custom response when rendering the account settings page.""" + response = HttpResponse("Here's the text of the web page.") + raise AccountSettingsRenderStarted.RenderCustomResponse( + "You can't access this page.", + response=response, + ) + + +class TestAccountSettingsRender(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that returns a custom response when rendering the account settings page.""" + template_name = 'static_templates/about.html' + return { + "context": context, "template_name": template_name, + } + + +@skip_unless_lms +class TestAccountSettingsFilters(SharedModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the account settings proccess. + + This class guarantees that the following filters are triggered during the user's account settings rendering: + + - AccountSettingsRenderStarted + """ + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.user = UserFactory.create( + username="somestudent", + first_name="Student", + last_name="Person", + email="robot@robot.org", + is_active=True, + password="password", + ) + self.client.login(username=self.user.username, password="password") + self.account_settings_url = '/account/settings' + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.student.settings.render.started.v1": { + "pipeline": [ + "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestAccountSettingsRender", + ], + "fail_silently": False, + }, + }, + ) + def test_account_settings_render_filter_executed(self): + """ + Test whether the account settings filter is triggered before the user's + account settings page is rendered. + + Expected result: + - AccountSettingsRenderStarted is triggered and executes TestAccountSettingsRender + """ + response = self.client.get(self.account_settings_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertContains(response, "This page left intentionally blank. Feel free to add your own content.") + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.student.settings.render.started.v1": { + "pipeline": [ + "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderInvalidAccountSettings", # pylint: disable=line-too-long + ], + "fail_silently": False, + }, + }, + PLATFORM_NAME="My site", + ) + def test_account_settings_render_alternative(self): + """ + Test whether the account settings filter is triggered before the user's + account settings page is rendered. + + Expected result: + - AccountSettingsRenderStarted is triggered and executes TestRenderInvalidAccountSettings # pylint: disable=line-too-long + """ + response = self.client.get(self.account_settings_url) + + self.assertContains(response, "There has been a 500 error on the My site servers") + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.student.settings.render.started.v1": { + "pipeline": [ + "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderCustomResponse", + ], + "fail_silently": False, + }, + }, + ) + def test_account_settings_render_custom_response(self): + """ + Test whether the account settings filter is triggered before the user's + account settings page is rendered. + + Expected result: + - AccountSettingsRenderStarted is triggered and executes TestRenderCustomResponse + """ + response = self.client.get(self.account_settings_url) + + self.assertEqual(response.content, b"Here's the text of the web page.") + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.student.settings.render.started.v1": { + "pipeline": [ + "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToPage", + ], + "fail_silently": False, + }, + }, + ) + def test_account_settings_redirect_to_page(self): + """ + Test whether the account settings filter is triggered before the user's + account settings page is rendered. + + Expected result: + - AccountSettingsRenderStarted is triggered and executes TestRedirectToPage + """ + response = self.client.get(self.account_settings_url) + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual('/courses', response.url) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.student.settings.render.started.v1": { + "pipeline": [ + "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToDefaultPage", + ], + "fail_silently": False, + }, + }, + ) + def test_account_settings_redirect_default(self): + """ + Test whether the account settings filter is triggered before the user's + account settings page is rendered. + + Expected result: + - AccountSettingsRenderStarted is triggered and executes TestRedirectToDefaultPage + """ + response = self.client.get(self.account_settings_url) + + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + self.assertEqual(f"{reverse('dashboard')}", response.url) + + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_account_settings_render_without_filter_config(self): + """ + Test whether the course about filter is triggered before the course about + render without affecting its execution flow. + + Expected result: + - AccountSettingsRenderStarted executes a noop (empty pipeline). Without any + modification comparing it with the effects of TestAccountSettingsRender. + - The view response is HTTP_200_OK. + """ + response = self.client.get(self.account_settings_url) + + self.assertNotContains(response, "This page left intentionally blank. Feel free to add your own content.") diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py new file mode 100644 index 000000000000..b49be866ff4c --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py @@ -0,0 +1,271 @@ +""" Tests for views related to account settings. """ + + +from unittest import mock +from django.conf import settings +from django.contrib import messages +from django.contrib.messages.middleware import MessageMiddleware +from django.http import HttpRequest +from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse +from edx_rest_api_client import exceptions + +from edx_toggles.toggles.testutils import override_waffle_flag +from lms.djangoapps.commerce.models import CommerceConfiguration +from lms.djangoapps.commerce.tests import factories +from lms.djangoapps.commerce.tests.mocks import mock_get_orders +from openedx.core.djangoapps.dark_lang.models import DarkLangConfig +from openedx.core.djangoapps.lang_pref.tests.test_api import EN, LT_LT +from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory +from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin +from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context, get_user_orders +from openedx.core.djangoapps.user_api.accounts.toggles import REDIRECT_TO_ACCOUNT_MICROFRONTEND +from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields +from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin + + +@skip_unless_lms +class AccountSettingsViewTest(ThirdPartyAuthTestMixin, SiteMixin, ProgramsApiConfigMixin, TestCase): + """ Tests for the account settings view. """ + + USERNAME = 'student' + PASSWORD = 'password' + FIELDS = [ + 'country', + 'gender', + 'language', + 'level_of_education', + 'password', + 'year_of_birth', + 'preferred_language', + 'time_zone', + ] + + @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage') + def setUp(self): # pylint: disable=arguments-differ + super().setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + CommerceConfiguration.objects.create(cache_ttl=10, enabled=True) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + + self.request = HttpRequest() + self.request.user = self.user + + # For these tests, two third party auth providers are enabled by default: + self.configure_google_provider(enabled=True, visible=True) + self.configure_facebook_provider(enabled=True, visible=True) + + # Python-social saves auth failure notifcations in Django messages. + # See pipeline.get_duplicate_provider() for details. + self.request.COOKIES = {} + MessageMiddleware(get_response=lambda request: None).process_request(self.request) + messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook') + + @mock.patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') + def test_context(self, mock_enterprise_customer_for_request): + self.request.site = SiteFactory.create() + UserPreferenceFactory(user=self.user, key='pref-lang', value='lt-lt') + DarkLangConfig( + released_languages='en', + changed_by=self.user, + enabled=True, + beta_languages='lt-lt', + enable_beta_languages=True + ).save() + mock_enterprise_customer_for_request.return_value = {} + + with override_settings(LANGUAGES=[EN, LT_LT], LANGUAGE_CODE='en'): + context = account_settings_context(self.request) + + user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) + assert context['user_accounts_api_url'] == user_accounts_api_url + + user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) + assert context['user_preferences_api_url'] == user_preferences_api_url + + for attribute in self.FIELDS: + assert attribute in context['fields'] + + assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username}) + assert context['user_preferences_api_url'] ==\ + reverse('preferences_api', kwargs={'username': self.user.username}) + + assert context['duplicate_provider'] == 'facebook' + assert context['auth']['providers'][0]['name'] == 'Facebook' + assert context['auth']['providers'][1]['name'] == 'Google' + + assert context['sync_learner_profile_data'] is False + assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK + assert context['enterprise_name'] is None + assert context['enterprise_readonly_account_fields'] ==\ + {'fields': list(get_enterprise_readonly_account_fields(self.user))} + + expected_beta_language = {'code': 'lt-lt', 'name': settings.LANGUAGE_DICT.get('lt-lt')} + assert context['beta_language'] == expected_beta_language + + @mock.patch('openedx.core.djangoapps.user_api.accounts.settings_views.enterprise_customer_for_request') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') + def test_context_for_enterprise_learner( + self, mock_get_auth_provider, mock_enterprise_customer_for_request + ): + dummy_enterprise_customer = { + 'uuid': 'real-ent-uuid', + 'name': 'Dummy Enterprise', + 'identity_provider': 'saml-ubc' + } + mock_enterprise_customer_for_request.return_value = dummy_enterprise_customer + self.request.site = SiteFactory.create() + mock_get_auth_provider.return_value.sync_learner_profile_data = True + context = account_settings_context(self.request) + + user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) + assert context['user_accounts_api_url'] == user_accounts_api_url + + user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) + assert context['user_preferences_api_url'] == user_preferences_api_url + + for attribute in self.FIELDS: + assert attribute in context['fields'] + + assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username}) + assert context['user_preferences_api_url'] ==\ + reverse('preferences_api', kwargs={'username': self.user.username}) + + assert context['duplicate_provider'] == 'facebook' + assert context['auth']['providers'][0]['name'] == 'Facebook' + assert context['auth']['providers'][1]['name'] == 'Google' + + assert context['sync_learner_profile_data'] == mock_get_auth_provider.return_value.sync_learner_profile_data + assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK + assert context['enterprise_name'] == dummy_enterprise_customer['name'] + assert context['enterprise_readonly_account_fields'] ==\ + {'fields': list(get_enterprise_readonly_account_fields(self.user))} + + def test_view(self): + """ + Test that all fields are visible + """ + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + for attribute in self.FIELDS: + self.assertContains(response, attribute) + + def test_header_with_programs_listing_enabled(self): + """ + Verify that tabs header will be shown while program listing is enabled. + """ + self.create_programs_config() + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + self.assertContains(response, 'global-header') + + def test_header_with_programs_listing_disabled(self): + """ + Verify that nav header will be shown while program listing is disabled. + """ + self.create_programs_config(enabled=False) + view_path = reverse('account_settings') + response = self.client.get(path=view_path) + + self.assertContains(response, 'global-header') + + def test_commerce_order_detail(self): + """ + Verify that get_user_orders returns the correct order data. + """ + with mock_get_orders(): + order_detail = get_user_orders(self.user) + + for i, order in enumerate(mock_get_orders.default_response['results']): + expected = { + 'number': order['number'], + 'price': order['total_excl_tax'], + 'order_date': 'Jan 01, 2016', + 'receipt_url': '/checkout/receipt/?order_number=' + order['number'], + 'lines': order['lines'], + } + assert order_detail[i] == expected + + def test_commerce_order_detail_exception(self): + with mock_get_orders(exception=exceptions.HttpNotFoundError): + order_detail = get_user_orders(self.user) + + assert not order_detail + + def test_incomplete_order_detail(self): + response = { + 'results': [ + factories.OrderFactory( + status='Incomplete', + lines=[ + factories.OrderLineFactory( + product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()]) + ) + ] + ) + ] + } + with mock_get_orders(response=response): + order_detail = get_user_orders(self.user) + + assert not order_detail + + def test_order_history_with_no_product(self): + response = { + 'results': [ + factories.OrderFactory( + lines=[ + factories.OrderLineFactory( + product=None + ), + factories.OrderLineFactory( + product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory( + name='certificate_type', + value='verified' + )]) + ) + ] + ) + ] + } + with mock_get_orders(response=response): + order_detail = get_user_orders(self.user) + + assert len(order_detail) == 1 + + def test_redirect_view(self): + old_url_path = reverse('account_settings') + with override_waffle_flag(REDIRECT_TO_ACCOUNT_MICROFRONTEND, active=True): + # Test with waffle flag active and none site setting, redirects to microfrontend + response = self.client.get(path=old_url_path) + self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False) + + # Test with waffle flag disabled and site setting disabled, does not redirect + response = self.client.get(path=old_url_path) + for attribute in self.FIELDS: + self.assertContains(response, attribute) + + # Test with site setting disabled, does not redirect + site_domain = 'othersite.example.com' + site = self.set_up_site(site_domain, { + 'SITE_NAME': site_domain, + 'ENABLE_ACCOUNT_MICROFRONTEND': False + }) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + response = self.client.get(path=old_url_path) + for attribute in self.FIELDS: + self.assertContains(response, attribute) + + # Test with site setting enabled, redirects to microfrontend + site.configuration.site_values['ENABLE_ACCOUNT_MICROFRONTEND'] = True + site.configuration.save() + site.__class__.objects.clear_cache() + response = self.client.get(path=old_url_path) + self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False) diff --git a/openedx/core/djangoapps/user_api/accounts/toggles.py b/openedx/core/djangoapps/user_api/accounts/toggles.py new file mode 100644 index 000000000000..80de4fa75692 --- /dev/null +++ b/openedx/core/djangoapps/user_api/accounts/toggles.py @@ -0,0 +1,44 @@ +""" +Toggles for accounts related code. +""" + +from edx_toggles.toggles import WaffleFlag + +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + +# .. toggle_name: order_history.redirect_to_microfrontend +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the order history page. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2019-04-11 +# .. toggle_target_removal_date: 2020-12-31 +# .. toggle_warning: Also set settings.ORDER_HISTORY_MICROFRONTEND_URL and site's +# ENABLE_ORDER_HISTORY_MICROFRONTEND. +# .. toggle_tickets: DEPR-17 +REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND = WaffleFlag('order_history.redirect_to_microfrontend', __name__) + + +def should_redirect_to_order_history_microfrontend(): + return ( + configuration_helpers.get_value('ENABLE_ORDER_HISTORY_MICROFRONTEND') and + REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND.is_enabled() + ) + + +# .. toggle_name: account.redirect_to_microfrontend +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the account page. +# Its action can be overridden using site's ENABLE_ACCOUNT_MICROFRONTEND setting. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2019-04-30 +# .. toggle_target_removal_date: 2021-12-31 +# .. toggle_warning: Also set settings.ACCOUNT_MICROFRONTEND_URL. +# .. toggle_tickets: DEPR-17 +REDIRECT_TO_ACCOUNT_MICROFRONTEND = WaffleFlag('account.redirect_to_microfrontend', __name__) + + +def should_redirect_to_account_microfrontend(): + return configuration_helpers.get_value('ENABLE_ACCOUNT_MICROFRONTEND', + REDIRECT_TO_ACCOUNT_MICROFRONTEND.is_enabled()) diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py index ad02f7f19ce8..b3f707f64b50 100644 --- a/openedx/core/djangoapps/user_api/legacy_urls.py +++ b/openedx/core/djangoapps/user_api/legacy_urls.py @@ -5,6 +5,7 @@ from rest_framework import routers from . import views as user_api_views +from .accounts.settings_views import account_settings from .models import UserPreference USER_API_ROUTER = routers.DefaultRouter() @@ -12,6 +13,7 @@ USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet) urlpatterns = [ + path('account/settings', account_settings, name='account_settings'), path('user_api/v1/', include(USER_API_ROUTER.urls)), re_path( fr'^user_api/v1/preferences/(?P{UserPreference.KEY_REGEX})/users/$', diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py index 036baf2125b2..24f929698fa7 100644 --- a/openedx/core/djangoapps/user_authn/cookies.py +++ b/openedx/core/djangoapps/user_authn/cookies.py @@ -6,7 +6,6 @@ import json import logging import time -from urllib.parse import urljoin from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user @@ -245,8 +244,8 @@ def _get_user_info_cookie_data(request, user): # External sites will need to have fallback mechanisms to handle this case # (most likely just hiding the links). try: - header_urls['account_settings'] = settings.ACCOUNT_MICROFRONTEND_URL - header_urls['learner_profile'] = urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}') + header_urls['account_settings'] = reverse('account_settings') + header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username}) except NoReverseMatch: pass diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index 826ef1e1209a..a90f20f19469 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -4,7 +4,6 @@ from datetime import date import json from unittest.mock import MagicMock, patch -from urllib.parse import urljoin from django.conf import settings from django.http import HttpResponse from django.test import RequestFactory, TestCase @@ -58,8 +57,8 @@ def _get_expected_image_urls(self): def _get_expected_header_urls(self): expected_header_urls = { 'logout': reverse('logout'), - 'account_settings': settings.ACCOUNT_MICROFRONTEND_URL, - 'learner_profile': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{self.user.username}'), + 'account_settings': reverse('account_settings'), + 'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}), } block_url = retrieve_last_sitewide_block_completed(self.user) if block_url: diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index a8a591083fae..1e8a4c3ed510 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -496,7 +496,7 @@ def test_login_user_info_cookie(self): # Check that the URLs are absolute for url in user_info["header_urls"].values(): - assert 'http://' in url + assert 'http://testserver/' in url def test_logout_deletes_mktg_cookies(self): response, _ = self._login_response(self.user_email, self.password) diff --git a/openedx/features/learner_profile/README.rst b/openedx/features/learner_profile/README.rst new file mode 100644 index 000000000000..0dce8e10ccdc --- /dev/null +++ b/openedx/features/learner_profile/README.rst @@ -0,0 +1,8 @@ +Learner Profile +--------------- + +This directory contains a Django application that provides a view to render +a profile for any Open edX learner. See `Exploring Your Dashboard and Profile`_ +for more details. + +.. _Exploring Your Dashboard and Profile: https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/SFD_dashboard_profile_SectionHead.html?highlight=profile diff --git a/openedx/features/learner_profile/__init__.py b/openedx/features/learner_profile/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html b/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html new file mode 100644 index 000000000000..61c139210a20 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html @@ -0,0 +1,40 @@ +
+
+
+ + +
+
+

+ + + + + Loading + +

+
+ +
diff --git a/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js b/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js new file mode 100644 index 000000000000..6419dd17703a --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js @@ -0,0 +1,246 @@ +(function(define) { + 'use strict'; + + define([ + 'gettext', + 'jquery', + 'underscore', + 'backbone', + 'logger', + 'edx-ui-toolkit/js/utils/string-utils', + 'edx-ui-toolkit/js/pagination/paging-collection', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'js/views/fields', + 'learner_profile/js/views/learner_profile_fields', + 'learner_profile/js/views/learner_profile_view', + 'learner_profile/js/models/badges_model', + 'learner_profile/js/views/badge_list_container', + 'js/student_account/views/account_settings_fields', + 'js/views/message_banner', + 'string_utils' + ], function(gettext, $, _, Backbone, Logger, StringUtils, PagingCollection, AccountSettingsModel, + AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, BadgeModel, + BadgeListContainer, AccountSettingsFieldViews, MessageBannerView) { + return function(options) { + var $learnerProfileElement = $('.wrapper-profile'); + + var accountSettingsModel = new AccountSettingsModel( + _.extend( + options.account_settings_data, + { + default_public_account_fields: options.default_public_account_fields, + parental_consent_age_limit: options.parental_consent_age_limit, + enable_coppa_compliance: options.enable_coppa_compliance + } + ), + {parse: true} + ); + var AccountPreferencesModelWithDefaults = AccountPreferencesModel.extend({ + defaults: { + account_privacy: options.default_visibility + } + }); + var accountPreferencesModel = new AccountPreferencesModelWithDefaults(options.preferences_data); + + var editable = options.own_profile ? 'toggle' : 'never'; + + var messageView = new MessageBannerView({ + el: $('.message-banner') + }); + + var accountPrivacyFieldView, + profileImageFieldView, + usernameFieldView, + nameFieldView, + sectionOneFieldViews, + sectionTwoFieldViews, + BadgeCollection, + badgeCollection, + badgeListContainer, + learnerProfileView, + getProfileVisibility, + showLearnerProfileView; + + accountSettingsModel.url = options.accounts_api_url; + accountPreferencesModel.url = options.preferences_api_url; + + accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({ + model: accountPreferencesModel, + required: true, + editable: 'always', + showMessages: false, + title: gettext('Profile Visibility:'), + valueAttribute: 'account_privacy', + options: [ + ['private', gettext('Limited Profile')], + ['all_users', gettext('Full Profile')] + ], + helpMessage: '', + accountSettingsPageUrl: options.account_settings_page_url, + persistChanges: true + }); + + profileImageFieldView = new LearnerProfileFieldsView.ProfileImageFieldView({ + model: accountSettingsModel, + valueAttribute: 'profile_image', + editable: editable === 'toggle', + messageView: messageView, + imageMaxBytes: options.profile_image_max_bytes, + imageMinBytes: options.profile_image_min_bytes, + imageUploadUrl: options.profile_image_upload_url, + imageRemoveUrl: options.profile_image_remove_url + }); + + usernameFieldView = new FieldsView.ReadonlyFieldView({ + model: accountSettingsModel, + screenReaderTitle: gettext('Username'), + valueAttribute: 'username', + helpMessage: '' + }); + + nameFieldView = new FieldsView.ReadonlyFieldView({ + model: accountSettingsModel, + screenReaderTitle: gettext('Full Name'), + valueAttribute: 'name', + helpMessage: '' + }); + + sectionOneFieldViews = [ + new LearnerProfileFieldsView.SocialLinkIconsView({ + model: accountSettingsModel, + socialPlatforms: options.social_platforms, + ownProfile: options.own_profile + }), + + new FieldsView.DateFieldView({ + title: gettext('Joined'), + titleVisible: true, + model: accountSettingsModel, + screenReaderTitle: gettext('Joined Date'), + valueAttribute: 'date_joined', + helpMessage: '', + userLanguage: accountSettingsModel.get('language'), + userTimezone: accountPreferencesModel.get('time_zone'), + dateFormat: 'MMMM YYYY' // not localized, but hopefully ok. + }), + + new FieldsView.DropdownFieldView({ + title: gettext('Location'), + titleVisible: true, + model: accountSettingsModel, + screenReaderTitle: gettext('Country'), + required: true, + editable: editable, + showMessages: false, + placeholderValue: gettext('Add Country'), + valueAttribute: 'country', + options: options.country_options, + helpMessage: '', + persistChanges: true + }), + + new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + title: gettext('Language'), + titleVisible: true, + model: accountSettingsModel, + screenReaderTitle: gettext('Preferred Language'), + required: false, + editable: editable, + showMessages: false, + placeholderValue: gettext('Add language'), + valueAttribute: 'language_proficiencies', + options: options.language_options, + helpMessage: '', + persistChanges: true + }) + ]; + + sectionTwoFieldViews = [ + new FieldsView.TextareaFieldView({ + model: accountSettingsModel, + editable: editable, + showMessages: false, + title: gettext('About me'), + // eslint-disable-next-line max-len + placeholderValue: gettext("Tell other learners a little about yourself: where you live, what your interests are, why you're taking courses, or what you hope to learn."), + valueAttribute: 'bio', + helpMessage: '', + persistChanges: true, + messagePosition: 'header', + maxCharacters: 300 + }) + ]; + + BadgeCollection = PagingCollection.extend({ + queryParams: { + currentPage: 'current_page' + } + }); + badgeCollection = new BadgeCollection(); + badgeCollection.url = options.badges_api_url; + + badgeListContainer = new BadgeListContainer({ + attributes: {class: 'badge-set-display'}, + collection: badgeCollection, + find_courses_url: options.find_courses_url, + ownProfile: options.own_profile, + badgeMeta: { + badges_logo: options.badges_logo, + backpack_ui_img: options.backpack_ui_img, + badges_icon: options.badges_icon + } + }); + + learnerProfileView = new LearnerProfileView({ + el: $learnerProfileElement, + ownProfile: options.own_profile, + has_preferences_access: options.has_preferences_access, + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + profileImageFieldView: profileImageFieldView, + usernameFieldView: usernameFieldView, + nameFieldView: nameFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews, + badgeListContainer: badgeListContainer, + platformName: options.platform_name + }); + + getProfileVisibility = function() { + if (options.has_preferences_access) { + return accountPreferencesModel.get('account_privacy'); + } else { + return accountSettingsModel.get('profile_is_public') ? 'all_users' : 'private'; + } + }; + + showLearnerProfileView = function() { + // Record that the profile page was viewed + Logger.log('edx.user.settings.viewed', { + page: 'profile', + visibility: getProfileVisibility(), + user_id: options.profile_user_id + }); + + // Render the view for the first time + learnerProfileView.render(); + }; + + if (options.has_preferences_access) { + if (accountSettingsModel.get('requires_parental_consent')) { + accountPreferencesModel.set('account_privacy', 'private'); + } + } + showLearnerProfileView(); + + return { + accountSettingsModel: accountSettingsModel, + accountPreferencesModel: accountPreferencesModel, + learnerProfileView: learnerProfileView, + badgeListContainer: badgeListContainer + }; + }; + }); +}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js b/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js new file mode 100644 index 000000000000..42da19ef7677 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js @@ -0,0 +1,8 @@ +(function(define) { + 'use strict'; + + define(['backbone'], function(Backbone) { + var BadgesModel = Backbone.Model.extend({}); + return BadgesModel; + }); +}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js new file mode 100644 index 000000000000..f5b8f4bec6b5 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js @@ -0,0 +1,222 @@ +define( + [ + 'backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'learner_profile/js/spec_helpers/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'learner_profile/js/views/learner_profile_view', + 'learner_profile/js/views/learner_profile_fields', + 'learner_profile/js/learner_profile_factory', + 'js/views/message_banner' + ], + function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, + UserAccountModel, UserPreferencesModel, LearnerProfileView, LearnerProfileFields, LearnerProfilePage) { + 'use strict'; + + describe('edx.user.LearnerProfileFactory', function() { + var createProfilePage; + + beforeEach(function() { + loadFixtures('learner_profile/fixtures/learner_profile.html'); + }); + + afterEach(function() { + Backbone.history.stop(); + }); + + createProfilePage = function(ownProfile, options) { + return new LearnerProfilePage({ + accounts_api_url: Helpers.USER_ACCOUNTS_API_URL, + preferences_api_url: Helpers.USER_PREFERENCES_API_URL, + badges_api_url: Helpers.BADGES_API_URL, + own_profile: ownProfile, + account_settings_page_url: Helpers.USER_ACCOUNTS_API_URL, + country_options: Helpers.FIELD_OPTIONS, + language_options: Helpers.FIELD_OPTIONS, + has_preferences_access: true, + profile_image_max_bytes: Helpers.IMAGE_MAX_BYTES, + profile_image_min_bytes: Helpers.IMAGE_MIN_BYTES, + profile_image_upload_url: Helpers.IMAGE_UPLOAD_API_URL, + profile_image_remove_url: Helpers.IMAGE_REMOVE_API_URL, + default_visibility: 'all_users', + platform_name: 'edX', + find_courses_url: '/courses/', + account_settings_data: Helpers.createAccountSettingsData(options), + preferences_data: Helpers.createUserPreferencesData() + }); + }; + + it('renders the full profile for a user', function() { + var context, + learnerProfileView; + AjaxHelpers.requests(this); + context = createProfilePage(true); + learnerProfileView = context.learnerProfileView; + + // sets the profile for full view. + context.accountPreferencesModel.set({account_privacy: 'all_users'}); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, false); + }); + + it("renders the limited profile for undefined 'year_of_birth'", function() { + var context = createProfilePage(true, {year_of_birth: '', requires_parental_consent: true}), + learnerProfileView = context.learnerProfileView; + + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it("doesn't show the mode toggle if badges are disabled", function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: false}), + tabbedView = context.learnerProfileView.tabbedView, + learnerProfileView = context.learnerProfileView; + + LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it("doesn't show the mode toggle if badges fail to fetch", function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: false}), + tabbedView = context.learnerProfileView.tabbedView, + learnerProfileView = context.learnerProfileView; + + LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it('renders the mode toggle if there are badges', function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: true}), + tabbedView = context.learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + + LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView); + }); + + it('renders the mode toggle if badges enabled but none exist', function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: true}), + tabbedView = context.learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges); + + LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView); + }); + + it('displays the badges when the accomplishments toggle is selected', function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); + + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); + tabbedView.$el.find('[data-url="about_me"]').click(); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it('displays a placeholder on the last page of badges', function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges); + + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true); + tabbedView.$el.find('[data-url="about_me"]').click(); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it('displays a placeholder when the accomplishments toggle is selected and no badges exist', function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges); + + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 0, true); + tabbedView.$el.find('[data-url="about_me"]').click(); + LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); + }); + + it('shows a paginated list of badges', function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + + tabbedView.$el.find('[data-url="accomplishments"]').click(); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges); + }); + + it('allows forward and backward navigation of badges', function() { + var requests = AjaxHelpers.requests(this), + context = createProfilePage(true, {accomplishments_shared: true}), + learnerProfileView = context.learnerProfileView, + tabbedView = learnerProfileView.tabbedView, + badgeListContainer = context.badgeListContainer; + + AjaxHelpers.expectRequest(requests, 'POST', '/event'); + AjaxHelpers.respondWithError(requests, 404); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + + tabbedView.$el.find('[data-url="accomplishments"]').click(); + + badgeListContainer.$el.find('.next-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges); + + badgeListContainer.$el.find('.next-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.thirdPageBadges); + + badgeListContainer.$el.find('.previous-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges); + LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); + + badgeListContainer.$el.find('.previous-page-link').click(); + AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); + LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges); + }); + + it('renders the limited profile for under 13 users', function() { + var context = createProfilePage( + true, + {year_of_birth: new Date().getFullYear() - 10, requires_parental_consent: true} + ); + var learnerProfileView = context.learnerProfileView; + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + }); + }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js new file mode 100644 index 000000000000..20342c4a6709 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js @@ -0,0 +1,99 @@ +define([ + 'backbone', + 'jquery', + 'underscore', + 'URI', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'edx-ui-toolkit/js/pagination/paging-collection', + 'learner_profile/js/spec_helpers/helpers', + 'learner_profile/js/views/badge_list_container' +], +function(Backbone, $, _, URI, AjaxHelpers, PagingCollection, LearnerProfileHelpers, BadgeListContainer) { + 'use strict'; + + describe('edx.user.BadgeListContainer', function() { + var view; + + var createView = function(requests, pageNum, badgeListObject) { + var BadgeCollection = PagingCollection.extend({ + queryParams: { + currentPage: 'current_page' + } + }); + var badgeCollection = new BadgeCollection(); + var models = []; + var badgeListContainer; + var request; + var path; + badgeCollection.url = '/api/badges/v1/assertions/user/staff/'; + _.each(_.range(badgeListObject.count), function(idx) { + models.push(LearnerProfileHelpers.makeBadge(idx)); + }); + badgeListObject.results = models; // eslint-disable-line no-param-reassign + badgeCollection.setPage(pageNum); + request = AjaxHelpers.currentRequest(requests); + path = new URI(request.url).path(); + expect(path).toBe('/api/badges/v1/assertions/user/staff/'); + AjaxHelpers.respondWithJson(requests, badgeListObject); + badgeListContainer = new BadgeListContainer({ + collection: badgeCollection + + }); + badgeListContainer.render(); + return badgeListContainer; + }; + + afterEach(function() { + view.$el.remove(); + }); + + it('displays all badges', function() { + var requests = AjaxHelpers.requests(this), + badges; + view = createView(requests, 1, { + count: 30, + previous: '/arbitrary/url', + num_pages: 3, + next: null, + start: 20, + current_page: 1, + results: [] + }); + badges = view.$el.find('div.badge-display'); + expect(badges.length).toBe(30); + }); + + it('displays placeholder on last page', function() { + var requests = AjaxHelpers.requests(this), + placeholder; + view = createView(requests, 3, { + count: 30, + previous: '/arbitrary/url', + num_pages: 3, + next: null, + start: 20, + current_page: 3, + results: [] + }); + placeholder = view.$el.find('span.accomplishment-placeholder'); + expect(placeholder.length).toBe(1); + }); + + it('does not display placeholder on first page', function() { + var requests = AjaxHelpers.requests(this), + placeholder; + view = createView(requests, 1, { + count: 30, + previous: '/arbitrary/url', + num_pages: 3, + next: null, + start: 0, + current_page: 1, + results: [] + }); + placeholder = view.$el.find('span.accomplishment-placeholder'); + expect(placeholder.length).toBe(0); + }); + }); +} +); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js new file mode 100644 index 000000000000..e8cfd32d4c38 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js @@ -0,0 +1,81 @@ +define([ + 'backbone', + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/pagination/paging-collection', + 'learner_profile/js/spec_helpers/helpers', + 'learner_profile/js/views/badge_list_view' +], +function(Backbone, $, _, PagingCollection, LearnerProfileHelpers, BadgeListView) { + 'use strict'; + + describe('edx.user.BadgeListView', function() { + var view; + + var createView = function(badges, pages, page, hasNextPage) { + var badgeCollection = new PagingCollection(); + var models = []; + var badgeList; + badgeCollection.url = '/api/badges/v1/assertions/user/staff/'; + _.each(badges, function(element) { + models.push(new Backbone.Model(element)); + }); + badgeCollection.models = models; + badgeCollection.length = badges.length; + badgeCollection.currentPage = page; + badgeCollection.totalPages = pages; + badgeCollection.hasNextPage = function() { + return hasNextPage; + }; + badgeList = new BadgeListView({ + collection: badgeCollection + + }); + return badgeList; + }; + + afterEach(function() { + view.$el.remove(); + }); + + it('there is a single row if there is only one badge', function() { + var rows; + view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 1, false); + view.render(); + rows = view.$el.find('div.row'); + expect(rows.length).toBe(1); + }); + + it('accomplishments placeholder is visible on a last page', function() { + var placeholder; + view = createView([LearnerProfileHelpers.makeBadge(1)], 2, 2, false); + view.render(); + placeholder = view.$el.find('span.accomplishment-placeholder'); + expect(placeholder.length).toBe(1); + }); + + it('accomplishments placeholder to be not visible on a first page', function() { + var placeholder; + view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 2, true); + view.render(); + placeholder = view.$el.find('span.accomplishment-placeholder'); + expect(placeholder.length).toBe(0); + }); + + it('badges are in two columns (checked by counting rows for a known number of badges)', function() { + var badges = []; + var placeholder; + var rows; + _.each(_.range(4), function(item) { + badges.push(LearnerProfileHelpers.makeBadge(item)); + }); + view = createView(badges, 1, 2, true); + view.render(); + placeholder = view.$el.find('span.accomplishment-placeholder'); + expect(placeholder.length).toBe(0); + rows = view.$el.find('div.row'); + expect(rows.length).toBe(2); + }); + }); +} +); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js new file mode 100644 index 000000000000..8ac88ae0b17e --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js @@ -0,0 +1,114 @@ +define([ + 'backbone', 'jquery', 'underscore', + 'learner_profile/js/spec_helpers/helpers', + 'learner_profile/js/views/badge_view' +], +function(Backbone, $, _, LearnerProfileHelpers, BadgeView) { + 'use strict'; + + describe('edx.user.BadgeView', function() { + var view, + badge, + testBadgeNameIsDisplayed, + testBadgeIconIsDisplayed; + + var createView = function(ownProfile) { + var options, + testView; + badge = LearnerProfileHelpers.makeBadge(1); + options = { + model: new Backbone.Model(badge), + ownProfile: ownProfile, + badgeMeta: {} + }; + testView = new BadgeView(options); + testView.render(); + $('body').append(testView.$el); + testView.$el.show(); + expect(testView.$el.is(':visible')).toBe(true); + return testView; + }; + + afterEach(function() { + view.$el.remove(); + $('.badges-modal').remove(); + }); + + it('profile of other has no share button', function() { + view = createView(false); + expect(view.context.ownProfile).toBeFalsy(); + expect(view.$el.find('button.share-button').length).toBe(0); + }); + + it('own profile has share button', function() { + view = createView(true); + expect(view.context.ownProfile).toBeTruthy(); + expect(view.$el.find('button.share-button').length).toBe(1); + }); + + it('click on share button calls createModal function', function() { + var shareButton; + view = createView(true); + spyOn(view, 'createModal'); + view.delegateEvents(); + expect(view.context.ownProfile).toBeTruthy(); + shareButton = view.$el.find('button.share-button'); + expect(shareButton.length).toBe(1); + expect(view.createModal).not.toHaveBeenCalled(); + shareButton.click(); + expect(view.createModal).toHaveBeenCalled(); + }); + + it('click on share button calls shows the dialog', function(done) { + var shareButton, + $modalElement; + view = createView(true); + expect(view.context.ownProfile).toBeTruthy(); + shareButton = view.$el.find('button.share-button'); + expect(shareButton.length).toBe(1); + $modalElement = $('.badges-modal'); + expect($modalElement.length).toBe(0); + expect($modalElement.is(':visible')).toBeFalsy(); + shareButton.click(); + // Note: this element should have appeared in the dom during: shareButton.click(); + $modalElement = $('.badges-modal'); + jasmine.waitUntil(function() { + return $modalElement.is(':visible'); + }).always(done); + }); + + testBadgeNameIsDisplayed = function(ownProfile) { + var badgeDiv; + view = createView(ownProfile); + badgeDiv = view.$el.find('.badge-name'); + expect(badgeDiv.length).toBeTruthy(); + expect(badgeDiv.is(':visible')).toBe(true); + expect(_.count(badgeDiv.html(), badge.badge_class.display_name)).toBeTruthy(); + }; + + it('test badge name is displayed for own profile', function() { + testBadgeNameIsDisplayed(true); + }); + + it('test badge name is displayed for other profile', function() { + testBadgeNameIsDisplayed(false); + }); + + testBadgeIconIsDisplayed = function(ownProfile) { + var badgeImg; + view = createView(ownProfile); + badgeImg = view.$el.find('img.badge'); + expect(badgeImg.length).toBe(1); + expect(badgeImg.attr('src')).toEqual(badge.image_url); + }; + + it('test badge icon is displayed for own profile', function() { + testBadgeIconIsDisplayed(true); + }); + + it('test badge icon is displayed for other profile', function() { + testBadgeIconIsDisplayed(false); + }); + }); +} +); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js new file mode 100644 index 000000000000..49b3dbc630df --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js @@ -0,0 +1,381 @@ +define( + [ + 'backbone', + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'js/student_account/models/user_account_model', + 'learner_profile/js/views/learner_profile_fields', + 'js/views/message_banner' + ], + function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, UserAccountModel, LearnerProfileFields, + MessageBannerView) { + 'use strict'; + + describe('edx.user.LearnerProfileFields', function() { + var MOCK_YEAR_OF_BIRTH = 1989; + var MOCK_IMAGE_MAX_BYTES = 64; + var MOCK_IMAGE_MIN_BYTES = 16; + + var createImageView = function(options) { + var yearOfBirth = _.isUndefined(options.yearOfBirth) ? MOCK_YEAR_OF_BIRTH : options.yearOfBirth; + var imageMaxBytes = _.isUndefined(options.imageMaxBytes) ? MOCK_IMAGE_MAX_BYTES : options.imageMaxBytes; + var imageMinBytes = _.isUndefined(options.imageMinBytes) ? MOCK_IMAGE_MIN_BYTES : options.imageMinBytes; + var messageView; + + var imageData = { + image_url_large: '/media/profile-images/default.jpg', + has_image: !!options.hasImage + }; + + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set({profile_image: imageData}); + accountSettingsModel.set({year_of_birth: yearOfBirth}); + accountSettingsModel.set({requires_parental_consent: !!_.isEmpty(yearOfBirth)}); + + accountSettingsModel.url = Helpers.USER_ACCOUNTS_API_URL; + + messageView = new MessageBannerView({ + el: $('.message-banner') + }); + + return new LearnerProfileFields.ProfileImageFieldView({ + model: accountSettingsModel, + valueAttribute: 'profile_image', + editable: options.ownProfile, + messageView: messageView, + imageMaxBytes: imageMaxBytes, + imageMinBytes: imageMinBytes, + imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, + imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL + }); + }; + + var createSocialLinksView = function(ownProfile, socialPlatformLinks) { + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set({social_platforms: socialPlatformLinks}); + + return new LearnerProfileFields.SocialLinkIconsView({ + model: accountSettingsModel, + socialPlatforms: ['twitter', 'facebook', 'linkedin'], + ownProfile: ownProfile + }); + }; + + var createFakeImageFile = function(size) { + var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf'; + return new Blob( + [fileFakeData.substr(0, size)], + {type: 'image/jpg'} + ); + }; + + var initializeUploader = function(view) { + view.$('.upload-button-input').fileupload({ + url: Helpers.IMAGE_UPLOAD_API_URL, + type: 'POST', + add: view.fileSelected, + done: view.imageChangeSucceeded, + fail: view.imageChangeFailed + }); + }; + + beforeEach(function() { + loadFixtures('learner_profile/fixtures/learner_profile.html'); + TemplateHelpers.installTemplate('templates/fields/field_image'); + TemplateHelpers.installTemplate('templates/fields/message_banner'); + TemplateHelpers.installTemplate('learner_profile/templates/social_icons'); + }); + + afterEach(function() { + // image_field.js's window.onBeforeUnload breaks Karma in Chrome, clean it up after each test + $(window).off('beforeunload'); + }); + + describe('ProfileImageFieldView', function() { + var verifyImageUploadButtonMessage = function(view, inProgress) { + var iconName = inProgress ? 'fa-spinner' : 'fa-camera'; + var message = inProgress ? view.titleUploading : view.uploadButtonTitle(); + expect(view.$('.upload-button-icon span').attr('class')).toContain(iconName); + expect(view.$('.upload-button-title').text().trim()).toBe(message); + }; + + var verifyImageRemoveButtonMessage = function(view, inProgress) { + var iconName = inProgress ? 'fa-spinner' : 'fa-remove'; + var message = inProgress ? view.titleRemoving : view.removeButtonTitle(); + expect(view.$('.remove-button-icon span').attr('class')).toContain(iconName); + expect(view.$('.remove-button-title').text().trim()).toBe(message); + }; + + it('can upload profile image', function() { + var requests = AjaxHelpers.requests(this); + var imageName = 'profile_image.jpg'; + var imageView = createImageView({ownProfile: true, hasImage: false}); + var data; + imageView.render(); + + initializeUploader(imageView); + + // Remove button should not be present for default image + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + // For default image, image title should be `Upload an image` + verifyImageUploadButtonMessage(imageView, false); + + // Add image to upload queue. Validate the image size and send POST request to upload image + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + + // Verify image upload progress message + verifyImageUploadButtonMessage(imageView, true); + + // Verify if POST request received for image upload + AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); + + // Send 204 NO CONTENT to confirm the image upload success + AjaxHelpers.respondWithNoContent(requests); + + // Upon successful image upload, account settings model will be fetched to + // get the url for newly uploaded image, So we need to send the response for that GET + data = { + profile_image: { + image_url_large: '/media/profile-images/' + imageName, + has_image: true + } + }; + AjaxHelpers.respondWithJson(requests, data); + + // Verify uploaded image name + expect(imageView.$('.image-frame').attr('src')).toContain(imageName); + + // Remove button should be present after successful image upload + expect(imageView.$('.u-field-remove-button').css('display') !== 'none').toBeTruthy(); + + // After image upload, image title should be `Change image` + verifyImageUploadButtonMessage(imageView, false); + }); + + it('can remove profile image', function() { + var requests = AjaxHelpers.requests(this); + var imageView = createImageView({ownProfile: true, hasImage: false}); + var data; + imageView.render(); + + // Verify image remove title + verifyImageRemoveButtonMessage(imageView, false); + + imageView.$('.u-field-remove-button').click(); + + // Verify image remove progress message + verifyImageRemoveButtonMessage(imageView, true); + + // Verify if POST request received for image remove + AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_REMOVE_API_URL, null); + + // Send 204 NO CONTENT to confirm the image removal success + AjaxHelpers.respondWithNoContent(requests); + + // Upon successful image removal, account settings model will be fetched to get default image url + // So we need to send the response for that GET + data = { + profile_image: { + image_url_large: '/media/profile-images/default.jpg', + has_image: false + } + }; + AjaxHelpers.respondWithJson(requests, data); + + // Remove button should not be present for default image + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + }); + + it("can't remove default profile image", function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + spyOn(imageView, 'clickedRemoveButton'); + + // Remove button should not be present for default image + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + imageView.$('.u-field-remove-button').click(); + + // Remove button click handler should not be called + expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); + }); + + it("can't upload image having size greater than max size", function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + initializeUploader(imageView); + + // Add image to upload queue, this will validate the image size + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(70)]}); + + // Verify error message + expect($('.message-banner').text().trim()) + .toBe('The file must be smaller than 64 bytes in size.'); + }); + + it("can't upload image having size less than min size", function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + initializeUploader(imageView); + + // Add image to upload queue, this will validate the image size + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(10)]}); + + // Verify error message + expect($('.message-banner').text().trim()).toBe('The file must be at least 16 bytes in size.'); + }); + + it("can't upload and remove image if parental consent required", function() { + var imageView = createImageView({ownProfile: true, hasImage: false, yearOfBirth: ''}); + imageView.render(); + + spyOn(imageView, 'clickedUploadButton'); + spyOn(imageView, 'clickedRemoveButton'); + + expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + imageView.$('.u-field-upload-button').click(); + imageView.$('.u-field-remove-button').click(); + + expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); + expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); + }); + + it("can't upload and remove image on others profile", function() { + var imageView = createImageView({ownProfile: false}); + imageView.render(); + + spyOn(imageView, 'clickedUploadButton'); + spyOn(imageView, 'clickedRemoveButton'); + + expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); + expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); + + imageView.$('.u-field-upload-button').click(); + imageView.$('.u-field-remove-button').click(); + + expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); + expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); + }); + + it('shows message if we try to navigate away during image upload/remove', function() { + var imageView = createImageView({ownProfile: true, hasImage: false}); + spyOn(imageView, 'onBeforeUnload'); + imageView.render(); + + initializeUploader(imageView); + + // Add image to upload queue, this will validate image size and send POST request to upload image + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + + // Verify image upload progress message + verifyImageUploadButtonMessage(imageView, true); + + window.onbeforeunload = null; + $(window).trigger('beforeunload'); + expect(imageView.onBeforeUnload).toHaveBeenCalled(); + }); + + it('shows error message for HTTP 500', function() { + var requests = AjaxHelpers.requests(this); + var imageView = createImageView({ownProfile: true, hasImage: false}); + imageView.render(); + + initializeUploader(imageView); + + // Add image to upload queue. Validate the image size and send POST request to upload image + imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); + + // Verify image upload progress message + verifyImageUploadButtonMessage(imageView, true); + + // Verify if POST request received for image upload + AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); + + // Send HTTP 500 + AjaxHelpers.respondWithError(requests); + + expect($('.message-banner').text().trim()).toBe(imageView.errorMessage); + }); + }); + + describe('SocialLinkIconsView', function() { + var socialPlatformLinks, + socialLinkData, + socialLinksView, + socialPlatform, + $icon; + + it('icons are visible and links to social profile if added in account settings', function() { + socialPlatformLinks = { + twitter: { + platform: 'twitter', + social_link: 'https://www.twitter.com/edX' + }, + facebook: { + platform: 'facebook', + social_link: 'https://www.facebook.com/edX' + }, + linkedin: { + platform: 'linkedin', + social_link: '' + } + }; + + socialLinksView = createSocialLinksView(true, socialPlatformLinks); + + // Icons should be present and contain links if defined + for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top + socialPlatform = Object.keys(socialPlatformLinks)[i]; + socialLinkData = socialPlatformLinks[socialPlatform]; + if (socialLinkData.social_link) { + // Icons with a social_link value should be displayed with a surrounding link + $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); + expect($icon).toExist(); + expect($icon.parent().is('a')); + } else { + // Icons without a social_link value should be displayed without a surrounding link + $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); + expect($icon).toExist(); + expect(!$icon.parent().is('a')); + } + } + }); + + it('icons are not visible on a profile with no links', function() { + socialPlatformLinks = { + twitter: { + platform: 'twitter', + social_link: '' + }, + facebook: { + platform: 'facebook', + social_link: '' + }, + linkedin: { + platform: 'linkedin', + social_link: '' + } + }; + + socialLinksView = createSocialLinksView(false, socialPlatformLinks); + + // Icons should not be present if not defined on another user's profile + for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top + socialPlatform = Object.keys(socialPlatformLinks)[i]; + socialLinkData = socialPlatformLinks[socialPlatform]; + $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); + expect($icon).toBe(null); + } + }); + }); + }); + }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js new file mode 100644 index 000000000000..1797b0de05e7 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js @@ -0,0 +1,240 @@ +/* eslint-disable vars-on-top */ +define( + [ + 'gettext', + 'backbone', + 'jquery', + 'underscore', + 'edx-ui-toolkit/js/pagination/paging-collection', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', + 'js/spec/student_account/helpers', + 'learner_profile/js/spec_helpers/helpers', + 'js/views/fields', + 'js/student_account/models/user_account_model', + 'js/student_account/models/user_preferences_model', + 'learner_profile/js/views/learner_profile_fields', + 'learner_profile/js/views/learner_profile_view', + 'learner_profile/js/views/badge_list_container', + 'js/student_account/views/account_settings_fields', + 'js/views/message_banner' + ], + function(gettext, Backbone, $, _, PagingCollection, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, + FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, + BadgeListContainer, AccountSettingsFieldViews, MessageBannerView) { + 'use strict'; + + describe('edx.user.LearnerProfileView', function() { + var createLearnerProfileView = function(ownProfile, accountPrivacy, profileIsPublic) { + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set(Helpers.createAccountSettingsData()); + accountSettingsModel.set({profile_is_public: profileIsPublic}); + accountSettingsModel.set({profile_image: Helpers.PROFILE_IMAGE}); + + var accountPreferencesModel = new AccountPreferencesModel(); + accountPreferencesModel.set({account_privacy: accountPrivacy}); + + accountPreferencesModel.url = Helpers.USER_PREFERENCES_API_URL; + + var editable = ownProfile ? 'toggle' : 'never'; + + var accountPrivacyFieldView = new LearnerProfileFields.AccountPrivacyFieldView({ + model: accountPreferencesModel, + required: true, + editable: 'always', + showMessages: false, + title: 'edX learners can see my:', + valueAttribute: 'account_privacy', + options: [ + ['all_users', 'Full Profile'], + ['private', 'Limited Profile'] + ], + helpMessage: '', + accountSettingsPageUrl: '/account/settings/' + }); + + var messageView = new MessageBannerView({ + el: $('.message-banner') + }); + + var profileImageFieldView = new LearnerProfileFields.ProfileImageFieldView({ + model: accountSettingsModel, + valueAttribute: 'profile_image', + editable: editable, + messageView: messageView, + imageMaxBytes: Helpers.IMAGE_MAX_BYTES, + imageMinBytes: Helpers.IMAGE_MIN_BYTES, + imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, + imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL + }); + + var usernameFieldView = new FieldViews.ReadonlyFieldView({ + model: accountSettingsModel, + valueAttribute: 'username', + helpMessage: '' + }); + + var nameFieldView = new FieldViews.ReadonlyFieldView({ + model: accountSettingsModel, + valueAttribute: 'name', + helpMessage: '' + }); + + var sectionOneFieldViews = [ + new LearnerProfileFields.SocialLinkIconsView({ + model: accountSettingsModel, + socialPlatforms: Helpers.SOCIAL_PLATFORMS, + ownProfile: true + }), + + new FieldViews.DropdownFieldView({ + title: gettext('Location'), + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + placeholderValue: '', + valueAttribute: 'country', + options: Helpers.FIELD_OPTIONS, + helpMessage: '' + }), + + new AccountSettingsFieldViews.LanguageProficienciesFieldView({ + title: gettext('Language'), + model: accountSettingsModel, + required: false, + editable: editable, + showMessages: false, + placeholderValue: 'Add language', + valueAttribute: 'language_proficiencies', + options: Helpers.FIELD_OPTIONS, + helpMessage: '' + }), + + new FieldViews.DateFieldView({ + model: accountSettingsModel, + valueAttribute: 'date_joined', + helpMessage: '' + }) + ]; + + var sectionTwoFieldViews = [ + new FieldViews.TextareaFieldView({ + model: accountSettingsModel, + editable: editable, + showMessages: false, + title: 'About me', + placeholderValue: 'Tell other edX learners a little about yourself: where you live, ' + + "what your interests are, why you're taking courses on edX, or what you hope to learn.", + valueAttribute: 'bio', + helpMessage: '', + messagePosition: 'header' + }) + ]; + + var badgeCollection = new PagingCollection(); + badgeCollection.url = Helpers.BADGES_API_URL; + + var badgeListContainer = new BadgeListContainer({ + attributes: {class: 'badge-set-display'}, + collection: badgeCollection, + find_courses_url: Helpers.FIND_COURSES_URL + }); + + return new LearnerProfileView( + { + el: $('.wrapper-profile'), + ownProfile: ownProfile, + hasPreferencesAccess: true, + accountSettingsModel: accountSettingsModel, + preferencesModel: accountPreferencesModel, + accountPrivacyFieldView: accountPrivacyFieldView, + usernameFieldView: usernameFieldView, + nameFieldView: nameFieldView, + profileImageFieldView: profileImageFieldView, + sectionOneFieldViews: sectionOneFieldViews, + sectionTwoFieldViews: sectionTwoFieldViews, + badgeListContainer: badgeListContainer + }); + }; + + beforeEach(function() { + loadFixtures('learner_profile/fixtures/learner_profile.html'); + }); + + afterEach(function() { + Backbone.history.stop(); + }); + + it('shows loading error correctly', function() { + var learnerProfileView = createLearnerProfileView(false, 'all_users'); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + learnerProfileView.showLoadingError(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); + }); + + it('renders all fields as expected for self with full access', function() { + var learnerProfileView = createLearnerProfileView(true, 'all_users', true); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it('renders all fields as expected for self with limited access', function() { + var learnerProfileView = createLearnerProfileView(true, 'private', false); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); + }); + + it('renders the fields as expected for others with full access', function() { + var learnerProfileView = createLearnerProfileView(false, 'all_users', true); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); + }); + + it('renders the fields as expected for others with limited access', function() { + var learnerProfileView = createLearnerProfileView(false, 'private', false); + + Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + + learnerProfileView.render(); + + Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); + LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); + }); + + it("renders an error if the badges can't be fetched", function() { + var learnerProfileView = createLearnerProfileView(false, 'all_users', true); + learnerProfileView.options.accountSettingsModel.set({accomplishments_shared: true}); + var requests = AjaxHelpers.requests(this); + + learnerProfileView.render(); + + LearnerProfileHelpers.breakBadgeLoading(learnerProfileView, requests); + LearnerProfileHelpers.expectBadgeLoadingErrorIsRendered(learnerProfileView); + }); + }); + }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js new file mode 100644 index 000000000000..d0e22d670beb --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js @@ -0,0 +1,113 @@ +/* eslint-disable vars-on-top */ +define( + [ + 'backbone', 'jquery', 'underscore', + 'js/spec/student_account/helpers', + 'learner_profile/js/views/section_two_tab', + 'js/views/fields', + 'js/student_account/models/user_account_model' + ], + function(Backbone, $, _, Helpers, SectionTwoTabView, FieldViews, UserAccountModel) { + 'use strict'; + + describe('edx.user.SectionTwoTab', function() { + var createSectionTwoView = function(ownProfile, profileIsPublic) { + var accountSettingsModel = new UserAccountModel(); + accountSettingsModel.set(Helpers.createAccountSettingsData()); + accountSettingsModel.set({profile_is_public: profileIsPublic}); + accountSettingsModel.set({profile_image: Helpers.PROFILE_IMAGE}); + + var editable = ownProfile ? 'toggle' : 'never'; + + var sectionTwoFieldViews = [ + new FieldViews.TextareaFieldView({ + model: accountSettingsModel, + editable: editable, + showMessages: false, + title: 'About me', + placeholderValue: 'Tell other edX learners a little about yourself: where you live, ' + + "what your interests are, why you're taking courses on edX, or what you hope to learn.", + valueAttribute: 'bio', + helpMessage: '', + messagePosition: 'header' + }) + ]; + + return new SectionTwoTabView({ + viewList: sectionTwoFieldViews, + showFullProfile: function() { + return profileIsPublic; + }, + ownProfile: ownProfile + }); + }; + + it('full profile displayed for public profile', function() { + var view = createSectionTwoView(false, true); + view.render(); + var bio = view.$el.find('.u-field-bio'); + expect(bio.length).toBe(1); + }); + + it('profile field parts are actually rendered for public profile', function() { + var view = createSectionTwoView(false, true); + _.each(view.options.viewList, function(fieldView) { + spyOn(fieldView, 'render').and.callThrough(); + }); + view.render(); + _.each(view.options.viewList, function(fieldView) { + expect(fieldView.render).toHaveBeenCalled(); + }); + }); + + var testPrivateProfile = function(ownProfile, messageString) { + var view = createSectionTwoView(ownProfile, false); + view.render(); + var bio = view.$el.find('.u-field-bio'); + expect(bio.length).toBe(0); + var msg = view.$el.find('span.profile-private-message'); + expect(msg.length).toBe(1); + expect(_.count(msg.html(), messageString)).toBeTruthy(); + }; + + it('no profile when profile is private for other people', function() { + testPrivateProfile(false, 'This learner is currently sharing a limited profile'); + }); + + it('no profile when profile is private for the user herself', function() { + testPrivateProfile(true, 'You are currently sharing a limited profile'); + }); + + var testProfilePrivatePartsDoNotRender = function(ownProfile) { + var view = createSectionTwoView(ownProfile, false); + _.each(view.options.viewList, function(fieldView) { + spyOn(fieldView, 'render'); + }); + view.render(); + _.each(view.options.viewList, function(fieldView) { + expect(fieldView.render).not.toHaveBeenCalled(); + }); + }; + + it('profile field parts are not rendered for private profile for owner', function() { + testProfilePrivatePartsDoNotRender(true); + }); + + it('profile field parts are not rendered for private profile for other people', function() { + testProfilePrivatePartsDoNotRender(false); + }); + + it('does not allow fields to be edited when visiting a profile for other people', function() { + var view = createSectionTwoView(false, true); + var bio = view.options.viewList[0]; + expect(bio.editable).toBe('never'); + }); + + it("allows fields to be edited when visiting one's own profile", function() { + var view = createSectionTwoView(true, true); + var bio = view.options.viewList[0]; + expect(bio.editable).toBe('toggle'); + }); + }); + } +); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js new file mode 100644 index 000000000000..e3d15659ffa6 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js @@ -0,0 +1,63 @@ +define( + [ + 'backbone', 'jquery', 'underscore', 'moment', + 'js/spec/student_account/helpers', + 'learner_profile/js/spec_helpers/helpers', + 'learner_profile/js/views/share_modal_view', + 'jquery.simulate' + ], + function(Backbone, $, _, Moment, Helpers, LearnerProfileHelpers, ShareModalView) { + 'use strict'; + + describe('edx.user.ShareModalView', function() { + var keys = $.simulate.keyCode; + + var view; + + var createModalView = function() { + var badge = LearnerProfileHelpers.makeBadge(1); + var context = _.extend(badge, { + created: new Moment(badge.created), + ownProfile: true, + badgeMeta: {} + }); + return new ShareModalView({ + model: new Backbone.Model(context), + shareButton: $(' +

<%- gettext("Share on Mozilla Backpack") %>

+

<%- gettext("To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.") %> +

+ + +
    +
  1. + <%= edx.HtmlUtils.interpolateHtml( + gettext("Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account"), + { + link_start: edx.HtmlUtils.HTML(''), + link_end: edx.HtmlUtils.HTML('') + } + ) + %> +
  2. + +
  3. + <%= edx.HtmlUtils.interpolateHtml( + gettext("{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack."), + { + download_link_start: edx.HtmlUtils.joinHtml( + edx.HtmlUtils.HTML(''), + ), + link_end: edx.HtmlUtils.HTML(''), + upload_link_start: edx.HtmlUtils.HTML('') + } + ) + %> +
  4. +
+
+ +
+ +
\ No newline at end of file diff --git a/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore b/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore new file mode 100644 index 000000000000..52b864cfb669 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore @@ -0,0 +1,9 @@ +
diff --git a/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html b/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html new file mode 100644 index 000000000000..07e14bc48ab5 --- /dev/null +++ b/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html @@ -0,0 +1,47 @@ +<%page expression_filter="h"/> +<%! +from django.utils.translation import gettext as _ +from common.djangoapps.third_party_auth import pipeline +%> + +
  • + + ## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account. + ${_("Connected Accounts")} + + + + % for state in provider_user_states: +
    +
    + % if state.has_account: + ${_('Linked')} + % else: + ${_('Not Linked')} + % endif +
    + ${state.provider.name} + +
    + % if state.has_account: + + + + % elif state.provider.display_for_login: + + ## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn). + ${_("Link")} + + % endif +
    +
    +
    + % endfor +
    +
  • diff --git a/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html b/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html new file mode 100644 index 000000000000..09e6ce36b9b3 --- /dev/null +++ b/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html @@ -0,0 +1,69 @@ +## mako + +<%page expression_filter="h"/> + +<%namespace name='static' file='/static_content.html'/> + +<%! +from django.utils.translation import gettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> + +
    + % if course_certificates or own_profile: +

    Course Certificates

    + % if course_certificates: + % for certificate in course_certificates: + <% + course = certificate['course'] + + completion_date_message_html = Text(_('Completed {completion_date_html}')).format( + completion_date_html=HTML( + '' + ).format( + completion_date=certificate['created'], + user_timezone=user_timezone, + user_language=user_language, + ), + ) + %> +
    + +
    +
    ${course.display_org_with_default}
    +
    ${course.display_name_with_default}
    +

    ${completion_date_message_html}

    +
    +
    + % endfor + % elif own_profile: +
    +

    ${_("You haven't earned any certificates yet.")}

    + % if settings.FEATURES.get('COURSES_ARE_BROWSABLE'): +

    + + + ${_('Explore New Courses')} + +

    + % endif +
    + % endif + % endif +
    + +<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> + DateUtilFactory.transform('.localized-datetime'); + diff --git a/openedx/features/learner_profile/templates/learner_profile/learner_profile.html b/openedx/features/learner_profile/templates/learner_profile/learner_profile.html new file mode 100644 index 000000000000..6de4744e66f7 --- /dev/null +++ b/openedx/features/learner_profile/templates/learner_profile/learner_profile.html @@ -0,0 +1,79 @@ +## mako + +<%page expression_filter="h"/> +<%inherit file="/main.html" /> +<%def name="online_help_token()"><% return "profile" %> +<%namespace name='static' file='/static_content.html'/> + +<%! +import json +from django.urls import reverse +from django.utils.translation import gettext as _ +from openedx.core.djangolib.js_utils import dump_js_escaped_json +from openedx.core.djangolib.markup import HTML +%> + +<%block name="pagetitle">${_("Learner Profile")} + +<%block name="bodyclass">view-profile + +<%block name="headextra"> +<%static:css group='style-course'/> + + +
    +
    +
    +
    + + % if own_profile: +
    +

    ${_("My Profile")}

    +
    + ${_('Build out your profile to personalize your identity on {platform_name}.').format( + platform_name=platform_name, + )} +
    +
    + % endif + +
    +
    +
    + +<%block name="js_extra"> +<%static:require_module module_name="learner_profile/js/learner_profile_factory" class_name="LearnerProfileFactory"> + var options = ${data | n, dump_js_escaped_json}; + LearnerProfileFactory(options); + + diff --git a/openedx/features/learner_profile/tests/__init__.py b/openedx/features/learner_profile/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/learner_profile/tests/views/__init__.py b/openedx/features/learner_profile/tests/views/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/learner_profile/tests/views/test_learner_profile.py b/openedx/features/learner_profile/tests/views/test_learner_profile.py new file mode 100644 index 000000000000..c4c83520008b --- /dev/null +++ b/openedx/features/learner_profile/tests/views/test_learner_profile.py @@ -0,0 +1,281 @@ +""" Tests for student profile views. """ + + +import datetime +from unittest import mock + +import ddt +from django.conf import settings +from django.test.client import RequestFactory +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.locator import CourseLocator + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from common.djangoapps.util.testing import UrlResetMixin +from lms.djangoapps.certificates.data import CertificateStatuses +from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory +from lms.envs.test import CREDENTIALS_PUBLIC_SERVICE_URL +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin +from openedx.features.learner_profile.toggles import REDIRECT_TO_PROFILE_MICROFRONTEND +from openedx.features.learner_profile.views.learner_profile import learner_profile_context +from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order + + +@ddt.ddt +class LearnerProfileViewTest(SiteMixin, UrlResetMixin, ModuleStoreTestCase): + """ Tests for the student profile view. """ + + USERNAME = "username" + OTHER_USERNAME = "other_user" + PASSWORD = "password" + DOWNLOAD_URL = "http://www.example.com/certificate.pdf" + CONTEXT_DATA = [ + 'default_public_account_fields', + 'accounts_api_url', + 'preferences_api_url', + 'account_settings_page_url', + 'has_preferences_access', + 'own_profile', + 'country_options', + 'language_options', + 'account_settings_data', + 'preferences_data', + ] + + def setUp(self): + super().setUp() + self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) + self.other_user = UserFactory.create(username=self.OTHER_USERNAME, password=self.PASSWORD) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + self.course = CourseFactory.create( + start=datetime.datetime(2013, 9, 16, 7, 17, 28), + end=datetime.datetime.now(), + certificate_available_date=datetime.datetime.now(), + ) + + def test_context(self): + """ + Verify learner profile page context data. + """ + request = RequestFactory().get('/url') + request.user = self.user + + context = learner_profile_context(request, self.USERNAME, self.user.is_staff) + + assert context['data']['default_public_account_fields'] == \ + settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'] + + assert context['data']['accounts_api_url'] == \ + reverse('accounts_api', kwargs={'username': self.user.username}) + + assert context['data']['preferences_api_url'] == \ + reverse('preferences_api', kwargs={'username': self.user.username}) + + assert context['data']['profile_image_upload_url'] == \ + reverse('profile_image_upload', kwargs={'username': self.user.username}) + + assert context['data']['profile_image_remove_url'] == \ + reverse('profile_image_remove', kwargs={'username': self.user.username}) + + assert context['data']['profile_image_max_bytes'] == settings.PROFILE_IMAGE_MAX_BYTES + + assert context['data']['profile_image_min_bytes'] == settings.PROFILE_IMAGE_MIN_BYTES + + assert context['data']['account_settings_page_url'] == reverse('account_settings') + + for attribute in self.CONTEXT_DATA: + assert attribute in context['data'] + + def test_view(self): + """ + Verify learner profile page view. + """ + profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) + response = self.client.get(path=profile_path) + + for attribute in self.CONTEXT_DATA: + self.assertContains(response, attribute) + + def test_redirect_view(self): + with override_waffle_flag(REDIRECT_TO_PROFILE_MICROFRONTEND, active=True): + profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) + + # Test with waffle flag active and site setting disabled, does not redirect + response = self.client.get(path=profile_path) + for attribute in self.CONTEXT_DATA: + self.assertContains(response, attribute) + + # Test with waffle flag active and site setting enabled, redirects to microfrontend + site_domain = 'othersite.example.com' + self.set_up_site(site_domain, { + 'SITE_NAME': site_domain, + 'ENABLE_PROFILE_MICROFRONTEND': True + }) + self.client.login(username=self.USERNAME, password=self.PASSWORD) + response = self.client.get(path=profile_path) + profile_url = settings.PROFILE_MICROFRONTEND_URL + self.assertRedirects(response, profile_url + self.USERNAME, fetch_redirect_response=False) + + def test_records_link(self): + profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) + response = self.client.get(path=profile_path) + self.assertContains(response, f'') + + def test_undefined_profile_page(self): + """ + Verify that a 404 is returned for a non-existent profile page. + """ + profile_path = reverse('learner_profile', kwargs={'username': "no_such_user"}) + response = self.client.get(path=profile_path) + assert 404 == response.status_code + + def _create_certificate(self, course_key=None, enrollment_mode=CourseMode.HONOR, status='downloadable'): + """Simulate that the user has a generated certificate. """ + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, mode=enrollment_mode) + return GeneratedCertificateFactory( + user=self.user, + course_id=course_key or self.course.id, + mode=enrollment_mode, + download_url=self.DOWNLOAD_URL, + status=status, + ) + + @ddt.data(CourseMode.HONOR, CourseMode.PROFESSIONAL, CourseMode.VERIFIED) + def test_certificate_visibility(self, cert_mode): + """ + Verify that certificates are displayed with the correct card mode. + """ + # Add new certificate + cert = self._create_certificate(enrollment_mode=cert_mode) + cert.save() + + response = self.client.get(f'/u/{self.user.username}') + + self.assertContains(response, f'card certificate-card mode-{cert_mode}') + + @ddt.data( + ['downloadable', True], + ['notpassing', False], + ) + @ddt.unpack + def test_certificate_status_visibility(self, status, is_passed_status): + """ + Verify that certificates are only displayed for passing status. + """ + # Add new certificate + cert = self._create_certificate(status=status) + cert.save() + + # Ensure that this test is actually using both passing and non-passing certs. + assert CertificateStatuses.is_passing_status(cert.status) == is_passed_status + + response = self.client.get(f'/u/{self.user.username}') + + if is_passed_status: + self.assertContains(response, f'card certificate-card mode-{cert.mode}') + else: + self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') + + def test_certificate_for_missing_course(self): + """ + Verify that a certificate is not shown for a missing course. + """ + # Add new certificate + cert = self._create_certificate(course_key=CourseLocator.from_string('course-v1:edX+INVALID+1')) + cert.save() + + response = self.client.get(f'/u/{self.user.username}') + + self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') + + @ddt.data(True, False) + def test_no_certificate_visibility(self, own_profile): + """ + Verify that the 'You haven't earned any certificates yet.' well appears on the user's + own profile when they do not have certificates and does not appear when viewing + another user that does not have any certificates. + """ + profile_username = self.user.username if own_profile else self.other_user.username + response = self.client.get(f'/u/{profile_username}') + + if own_profile: + self.assertContains(response, 'You haven't earned any certificates yet.') + else: + self.assertNotContains(response, 'You haven't earned any certificates yet.') + + @ddt.data(True, False) + def test_explore_courses_visibility(self, courses_browsable): + with mock.patch.dict('django.conf.settings.FEATURES', {'COURSES_ARE_BROWSABLE': courses_browsable}): + response = self.client.get(f'/u/{self.user.username}') + if courses_browsable: + self.assertContains(response, 'Explore New Courses') + else: + self.assertNotContains(response, 'Explore New Courses') + + def test_certificate_for_visibility_for_not_viewable_course(self): + """ + Verify that a certificate is not shown if certificate are not viewable to users. + """ + # add new course with certificate_available_date is future date. + course = CourseFactory.create( + certificate_available_date=datetime.datetime.now() + datetime.timedelta(days=5), + certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE + ) + + cert = self._create_certificate(course_key=course.id) + cert.save() + + response = self.client.get(f'/u/{self.user.username}') + + self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') + + def test_certificates_visible_only_for_staff_and_profile_user(self): + """ + Verify that certificates data are passed to template only in case of staff user + and profile user. + """ + request = RequestFactory().get('/url') + request.user = self.user + profile_username = self.other_user.username + user_is_staff = True + context = learner_profile_context(request, profile_username, user_is_staff) + + assert 'achievements_fragment' in context + + user_is_staff = False + context = learner_profile_context(request, profile_username, user_is_staff) + assert 'achievements_fragment' not in context + + profile_username = self.user.username + context = learner_profile_context(request, profile_username, user_is_staff) + assert 'achievements_fragment' in context + + @mock.patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + def test_certificate_visibility_with_no_cert_config(self): + """ + Verify that certificates are not displayed until there is an active + certificate configuration. + """ + # Add new certificate + cert = self._create_certificate(enrollment_mode=CourseMode.VERIFIED) + cert.download_url = '' + cert.save() + + response = self.client.get(f'/u/{self.user.username}') + self.assertNotContains( + response, f'card certificate-card mode-{CourseMode.VERIFIED}' + ) + + course_overview = CourseOverview.get_from_id(self.course.id) + course_overview.has_any_active_web_certificate = True + course_overview.save() + + response = self.client.get(f'/u/{self.user.username}') + self.assertContains( + response, f'card certificate-card mode-{CourseMode.VERIFIED}' + ) diff --git a/openedx/features/learner_profile/toggles.py b/openedx/features/learner_profile/toggles.py new file mode 100644 index 000000000000..08378b6e9042 --- /dev/null +++ b/openedx/features/learner_profile/toggles.py @@ -0,0 +1,29 @@ +""" +Toggles for Learner Profile page. +""" + + +from edx_toggles.toggles import WaffleFlag +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + +# Namespace for learner profile waffle flags. +WAFFLE_FLAG_NAMESPACE = 'learner_profile' + +# Waffle flag to redirect to another learner profile experience. +# .. toggle_name: learner_profile.redirect_to_microfrontend +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the profile page. +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2019-02-19 +# .. toggle_target_removal_date: 2020-12-31 +# .. toggle_warning: Also set settings.PROFILE_MICROFRONTEND_URL and site's ENABLE_PROFILE_MICROFRONTEND. +# .. toggle_tickets: DEPR-17 +REDIRECT_TO_PROFILE_MICROFRONTEND = WaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.redirect_to_microfrontend', __name__) + + +def should_redirect_to_profile_microfrontend(): + return ( + configuration_helpers.get_value('ENABLE_PROFILE_MICROFRONTEND') and + REDIRECT_TO_PROFILE_MICROFRONTEND.is_enabled() + ) diff --git a/openedx/features/learner_profile/urls.py b/openedx/features/learner_profile/urls.py new file mode 100644 index 000000000000..0f020765686b --- /dev/null +++ b/openedx/features/learner_profile/urls.py @@ -0,0 +1,24 @@ +""" +Defines URLs for the learner profile. +""" + + +from django.conf import settings +from django.urls import path, re_path + +from openedx.features.learner_profile.views.learner_profile import learner_profile + +from .views.learner_achievements import LearnerAchievementsFragmentView + +urlpatterns = [ + re_path( + r'^{username_pattern}$'.format( + username_pattern=settings.USERNAME_PATTERN, + ), + learner_profile, + name='learner_profile', + ), + path('achievements', LearnerAchievementsFragmentView.as_view(), + name='openedx.learner_profile.learner_achievements_fragment_view', + ), +] diff --git a/openedx/features/learner_profile/views/__init__.py b/openedx/features/learner_profile/views/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/learner_profile/views/learner_achievements.py b/openedx/features/learner_profile/views/learner_achievements.py new file mode 100644 index 000000000000..6a7a07e3392d --- /dev/null +++ b/openedx/features/learner_profile/views/learner_achievements.py @@ -0,0 +1,58 @@ +""" +Views to render a learner's achievements. +""" + + +from django.template.loader import render_to_string +from web_fragments.fragment import Fragment + +from lms.djangoapps.certificates import api as certificate_api +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.plugin_api.views import EdxFragmentView + + +class LearnerAchievementsFragmentView(EdxFragmentView): + """ + A fragment to render a learner's achievements. + """ + + def render_to_fragment(self, request, username=None, own_profile=False, **kwargs): # lint-amnesty, pylint: disable=arguments-differ + """ + Renders the current learner's achievements. + """ + course_certificates = self._get_ordered_certificates_for_user(request, username) + context = { + 'course_certificates': course_certificates, + 'own_profile': own_profile, + 'disable_courseware_js': True, + } + if course_certificates or own_profile: + html = render_to_string('learner_profile/learner-achievements-fragment.html', context) + return Fragment(html) + else: + return None + + def _get_ordered_certificates_for_user(self, request, username): + """ + Returns a user's certificates sorted by course name. + """ + course_certificates = certificate_api.get_certificates_for_user(username) + passing_certificates = [] + for course_certificate in course_certificates: + if course_certificate.get('is_passing', False): + course_key = course_certificate['course_key'] + try: + course_overview = CourseOverview.get_from_id(course_key) + course_certificate['course'] = course_overview + if certificate_api.certificates_viewable_for_course(course_overview): + # add certificate into passing certificate list only if it's a PDF certificate + # or there is an active certificate configuration. + if course_certificate['is_pdf_certificate'] or course_overview.has_any_active_web_certificate: + passing_certificates.append(course_certificate) + except CourseOverview.DoesNotExist: + # This is unlikely to fail as the course should exist. + # Ideally the cert should have all the information that + # it needs. This might be solved by the Credentials API. + pass + passing_certificates.sort(key=lambda certificate: certificate['course'].display_name_with_default) + return passing_certificates diff --git a/openedx/features/learner_profile/views/learner_profile.py b/openedx/features/learner_profile/views/learner_profile.py new file mode 100644 index 000000000000..e19e6853e8eb --- /dev/null +++ b/openedx/features/learner_profile/views/learner_profile.py @@ -0,0 +1,134 @@ +""" Views for a student's profile information. """ + + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.contrib.staticfiles.storage import staticfiles_storage +from django.core.exceptions import ObjectDoesNotExist +from django.http import Http404 +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views.decorators.http import require_http_methods +from django_countries import countries + +from lms.djangoapps.badges.utils import badges_enabled +from common.djangoapps.edxmako.shortcuts import marketing_link +from openedx.core.djangoapps.credentials.utils import get_credentials_records_url +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.api import get_account_settings +from openedx.core.djangoapps.user_api.errors import UserNotAuthorized, UserNotFound +from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences +from openedx.features.learner_profile.toggles import should_redirect_to_profile_microfrontend +from openedx.features.learner_profile.views.learner_achievements import LearnerAchievementsFragmentView +from common.djangoapps.student.models import User + + +@login_required +@require_http_methods(['GET']) +def learner_profile(request, username): + """Render the profile page for the specified username. + + Args: + request (HttpRequest) + username (str): username of user whose profile is requested. + + 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 + Raises: + Http404: 404 if the specified user is not authorized or does not exist + + Example usage: + GET /account/profile + """ + if should_redirect_to_profile_microfrontend(): + profile_microfrontend_url = f"{settings.PROFILE_MICROFRONTEND_URL}{username}" + if request.GET: + profile_microfrontend_url += f'?{request.GET.urlencode()}' + return redirect(profile_microfrontend_url) + + try: + context = learner_profile_context(request, username, request.user.is_staff) + return render( + request=request, + template_name='learner_profile/learner_profile.html', + context=context + ) + except (UserNotAuthorized, UserNotFound, ObjectDoesNotExist): + raise Http404 # lint-amnesty, pylint: disable=raise-missing-from + + +def learner_profile_context(request, profile_username, user_is_staff): + """Context for the learner profile page. + + Args: + logged_in_user (object): Logged In user. + profile_username (str): username of user whose profile is requested. + user_is_staff (bool): Logged In user has staff access. + build_absolute_uri_func (): + + Returns: + dict + + Raises: + ObjectDoesNotExist: the specified profile_username does not exist. + """ + profile_user = User.objects.get(username=profile_username) + logged_in_user = request.user + + own_profile = (logged_in_user.username == profile_username) + + account_settings_data = get_account_settings(request, [profile_username])[0] + + preferences_data = get_user_preferences(profile_user, profile_username) + + context = { + 'own_profile': own_profile, + 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), + 'data': { + 'profile_user_id': profile_user.id, + 'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'], + 'default_visibility': settings.ACCOUNT_VISIBILITY_CONFIGURATION['default_visibility'], + 'accounts_api_url': reverse("accounts_api", kwargs={'username': profile_username}), + 'preferences_api_url': reverse('preferences_api', kwargs={'username': profile_username}), + 'preferences_data': preferences_data, + 'account_settings_data': account_settings_data, + 'profile_image_upload_url': reverse('profile_image_upload', kwargs={'username': profile_username}), + 'profile_image_remove_url': reverse('profile_image_remove', kwargs={'username': profile_username}), + 'profile_image_max_bytes': settings.PROFILE_IMAGE_MAX_BYTES, + 'profile_image_min_bytes': settings.PROFILE_IMAGE_MIN_BYTES, + 'account_settings_page_url': reverse('account_settings'), + 'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff), + 'own_profile': own_profile, + 'country_options': list(countries), + 'find_courses_url': marketing_link('COURSES'), + 'language_options': settings.ALL_LANGUAGES, + 'badges_logo': staticfiles_storage.url('certificates/images/backpack-logo.png'), + 'badges_icon': staticfiles_storage.url('certificates/images/ico-mozillaopenbadges.png'), + 'backpack_ui_img': staticfiles_storage.url('certificates/images/backpack-ui.png'), + 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), + 'social_platforms': settings.SOCIAL_PLATFORMS, + 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE, + 'parental_consent_age_limit': settings.PARENTAL_CONSENT_AGE_LIMIT + }, + 'show_program_listing': ProgramsApiConfig.is_enabled(), + 'show_dashboard_tabs': True, + 'disable_courseware_js': True, + 'nav_hidden': True, + 'records_url': get_credentials_records_url(), + } + + if own_profile or user_is_staff: + achievements_fragment = LearnerAchievementsFragmentView().render_to_fragment( + request, + username=profile_user.username, + own_profile=own_profile, + ) + context['achievements_fragment'] = achievements_fragment + + if badges_enabled(): + context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username}) + + return context diff --git a/webpack-config/file-lists.js b/webpack-config/file-lists.js index 67bd319c28ef..ddb9cf4f7806 100644 --- a/webpack-config/file-lists.js +++ b/webpack-config/file-lists.js @@ -79,6 +79,9 @@ module.exports = { path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/program_header_view.js'), path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/sidebar_view.js'), path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/upgrade_message_view.js'), + path.resolve(__dirname, '../lms/static/js/student_account/views/account_section_view.js'), + path.resolve(__dirname, '../lms/static/js/student_account/views/account_settings_fields.js'), + path.resolve(__dirname, '../lms/static/js/student_account/views/account_settings_view.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/FormView.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/LoginView.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/RegisterView.js'),