feat!: Legacy account, profile, order history removal (#36219)

* feat!: Legacy account, profile, order history removal

This removes the legacy account and profile applications, and the order
history page. This is primarily a reapplication of #31893, which was
rolled back due to prior blockers.

FIXES: APER-3884
FIXES: openedx/public-engineering#71


Co-authored-by: Muhammad Abdullah Waheed <42172960+abdullahwaheed@users.noreply.github.com>
Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
This commit is contained in:
Deborah Kaplan
2025-02-10 14:39:13 -05:00
committed by GitHub
parent 36c16d6952
commit 29de9b2dc4
73 changed files with 752 additions and 7391 deletions

View File

@@ -26,7 +26,8 @@ from xmodule.modulestore.modulestore_settings import update_module_store_setting
from .common import *
# import settings from LMS for consistent behavior with CMS
from lms.envs.test import ( # pylint: disable=wrong-import-order
from lms.envs.test import ( # pylint: disable=wrong-import-order, disable=unused-import
ACCOUNT_MICROFRONTEND_URL,
COMPREHENSIVE_THEME_DIRS, # unimport:skip
DEFAULT_FILE_STORAGE,
ECOMMERCE_API_URL,
@@ -35,8 +36,10 @@ from lms.envs.test import ( # pylint: disable=wrong-import-order
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,
@@ -51,28 +54,26 @@ STUDIO_NAME = gettext_lazy("Your Platform 𝓢𝓽𝓾𝓭𝓲𝓸")
STUDIO_SHORT_NAME = gettext_lazy("𝓢𝓽𝓾𝓭𝓲𝓸")
# Allow all hosts during tests, we use a lot of different ones all over the codebase.
ALLOWED_HOSTS = [
'*'
]
ALLOWED_HOSTS = ["*"]
# mongo connection settings
MONGO_PORT_NUM = int(os.environ.get('EDXAPP_TEST_MONGO_PORT', '27017'))
MONGO_HOST = os.environ.get('EDXAPP_TEST_MONGO_HOST', 'localhost')
MONGO_PORT_NUM = int(os.environ.get("EDXAPP_TEST_MONGO_PORT", "27017"))
MONGO_HOST = os.environ.get("EDXAPP_TEST_MONGO_HOST", "localhost")
THIS_UUID = uuid4().hex[:5]
TEST_ROOT = path('test_root')
TEST_ROOT = path("test_root")
# Want static files in the same dir for running on jenkins.
STATIC_ROOT = TEST_ROOT / "staticfiles"
WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json"
WEBPACK_LOADER["DEFAULT"]["STATS_FILE"] = STATIC_ROOT / "webpack-stats.json"
GITHUB_REPO_ROOT = TEST_ROOT / "data"
DATA_DIR = TEST_ROOT / "data"
COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
# For testing "push to lms"
FEATURES['ENABLE_EXPORT_GIT'] = True
FEATURES["ENABLE_EXPORT_GIT"] = True
GIT_REPO_EXPORT_DIR = TEST_ROOT / "export_course_repos"
# TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing # lint-amnesty, pylint: disable=line-too-long
@@ -90,51 +91,47 @@ STATICFILES_DIRS += [
# If we don't add these settings, then Django templates that can't
# find pipelined assets will raise a ValueError.
# http://stackoverflow.com/questions/12816941/unit-testing-with-django-pipeline
STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineStorage'
STATICFILES_STORAGE = "pipeline.storage.NonPackagingPipelineStorage"
STATIC_URL = "/static/"
# Update module store settings per defaults for tests
update_module_store_settings(
MODULESTORE,
module_store_options={
'default_class': 'xmodule.hidden_block.HiddenBlock',
'fs_root': TEST_ROOT / "data",
"default_class": "xmodule.hidden_block.HiddenBlock",
"fs_root": TEST_ROOT / "data",
},
doc_store_settings={
'db': f'test_xmodule_{THIS_UUID}',
'host': MONGO_HOST,
'port': MONGO_PORT_NUM,
'collection': 'test_modulestore',
"db": f"test_xmodule_{THIS_UUID}",
"host": MONGO_HOST,
"port": MONGO_PORT_NUM,
"collection": "test_modulestore",
},
)
CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'DOC_STORE_CONFIG': {
'host': MONGO_HOST,
'db': f'test_xcontent_{THIS_UUID}',
'port': MONGO_PORT_NUM,
'collection': 'dont_trip',
"ENGINE": "xmodule.contentstore.mongo.MongoContentStore",
"DOC_STORE_CONFIG": {
"host": MONGO_HOST,
"db": f"test_xcontent_{THIS_UUID}",
"port": MONGO_PORT_NUM,
"collection": "dont_trip",
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
"ADDITIONAL_OPTIONS": {"trashcan": {"bucket": "trash_fs"}},
}
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': TEST_ROOT / "db" / "cms.db",
'ATOMIC_REQUESTS': True,
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": TEST_ROOT / "db" / "cms.db",
"ATOMIC_REQUESTS": True,
},
}
LMS_BASE = "localhost:8000"
LMS_ROOT_URL = f"http://{LMS_BASE}"
FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost"
FEATURES["PREVIEW_LMS_BASE"] = "preview.localhost"
CMS_BASE = "localhost:8001"
CMS_ROOT_URL = f"http://{CMS_BASE}"
@@ -145,49 +142,47 @@ DISCUSSIONS_MICROFRONTEND_URL = "http://discussions-mfe"
CACHES = {
# This is the cache used for most things.
# In staging/prod envs, the sessions also live here.
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_loc_mem_cache',
'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key',
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "edx_loc_mem_cache",
"KEY_FUNCTION": "common.djangoapps.util.memcache.safe_key",
},
# The general cache is what you get if you use our util.cache. It's used for
# things like caching the course.xml file for different A/B test groups.
# We set it to be a DummyCache to force reloading of course.xml in dev.
# In staging environments, we would grab VERSION from data uploaded by the
# push process.
'general': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
'KEY_PREFIX': 'general',
'VERSION': 4,
'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key',
"general": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
"KEY_PREFIX": "general",
"VERSION": 4,
"KEY_FUNCTION": "common.djangoapps.util.memcache.safe_key",
},
'mongo_metadata_inheritance': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': os.path.join(tempfile.gettempdir(), 'mongo_metadata_inheritance'),
'TIMEOUT': 300,
'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key',
"mongo_metadata_inheritance": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": os.path.join(tempfile.gettempdir(), "mongo_metadata_inheritance"),
"TIMEOUT": 300,
"KEY_FUNCTION": "common.djangoapps.util.memcache.safe_key",
},
'loc_cache': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'edx_location_mem_cache',
"loc_cache": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "edx_location_mem_cache",
},
'course_structure_cache': {
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
"course_structure_cache": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
},
}
################################# CELERY ######################################
CELERY_ALWAYS_EAGER = True
CELERY_RESULT_BACKEND = 'django-cache'
CELERY_RESULT_BACKEND = "django-cache"
CLEAR_REQUEST_CACHE_ON_TASK_COMPLETION = False
# test_status_cancel in cms/cms_user_tasks/test.py is failing without this
# @override_setting for BROKER_URL is not working in testcase, so updating here
BROKER_URL = 'memory://localhost/'
BROKER_URL = "memory://localhost/"
########################### Server Ports ###################################
@@ -202,99 +197,99 @@ VIDEO_SOURCE_PORT = 8777
################### Make tests faster
# http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.SHA1PasswordHasher',
'django.contrib.auth.hashers.MD5PasswordHasher',
"django.contrib.auth.hashers.SHA1PasswordHasher",
"django.contrib.auth.hashers.MD5PasswordHasher",
]
# No segment key
CMS_SEGMENT_KEY = None
FEATURES['DISABLE_SET_JWT_COOKIES_FOR_TESTS'] = True
FEATURES["DISABLE_SET_JWT_COOKIES_FOR_TESTS"] = True
FEATURES['ENABLE_SERVICE_STATUS'] = True
FEATURES["ENABLE_SERVICE_STATUS"] = True
# Toggles embargo on for testing
FEATURES['EMBARGO'] = True
FEATURES["EMBARGO"] = True
TEST_THEME = COMMON_ROOT / "test" / "test-theme"
# For consistency in user-experience, keep the value of this setting in sync with
# the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
FEATURES["ENABLE_DISCUSSION_SERVICE"] = False
# Enable a parental consent age limit for testing
PARENTAL_CONSENT_AGE_LIMIT = 13
# Enable certificates for the tests
FEATURES['CERTIFICATES_HTML_VIEW'] = True
FEATURES["CERTIFICATES_HTML_VIEW"] = True
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
FEATURES["ENABLE_CONTENT_LIBRARIES"] = True
FEATURES['ENABLE_EDXNOTES'] = True
FEATURES["ENABLE_EDXNOTES"] = True
# MILESTONES
FEATURES['MILESTONES_APP'] = True
FEATURES["MILESTONES_APP"] = True
# ENTRANCE EXAMS
FEATURES['ENTRANCE_EXAMS'] = True
FEATURES["ENTRANCE_EXAMS"] = True
ENTRANCE_EXAM_MIN_SCORE_PCT = 50
VIDEO_CDN_URL = {
'CN': 'http://api.xuetangx.com/edx/video?s3_url='
}
VIDEO_CDN_URL = {"CN": "http://api.xuetangx.com/edx/video?s3_url="}
# Courseware Search Index
FEATURES['ENABLE_COURSEWARE_INDEX'] = True
FEATURES['ENABLE_LIBRARY_INDEX'] = True
FEATURES["ENABLE_COURSEWARE_INDEX"] = True
FEATURES["ENABLE_LIBRARY_INDEX"] = True
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
FEATURES["ENABLE_ENROLLMENT_TRACK_USER_PARTITION"] = True
########################## AUTHOR PERMISSION #######################
FEATURES['ENABLE_CREATOR_GROUP'] = False
FEATURES["ENABLE_CREATOR_GROUP"] = False
# teams feature
FEATURES['ENABLE_TEAMS'] = True
FEATURES["ENABLE_TEAMS"] = True
# Dummy secret key for dev/test
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
SECRET_KEY = "85920908f28904ed733fe576320db18cabd7b6cd"
######### custom courses #########
INSTALLED_APPS += [
'openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig',
'common.djangoapps.third_party_auth.apps.ThirdPartyAuthConfig',
"openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig",
"common.djangoapps.third_party_auth.apps.ThirdPartyAuthConfig",
]
FEATURES['CUSTOM_COURSES_EDX'] = True
FEATURES["CUSTOM_COURSES_EDX"] = True
########################## VIDEO IMAGE STORAGE ############################
VIDEO_IMAGE_SETTINGS = dict(
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB
VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
),
DIRECTORY_PREFIX='video-images/',
DIRECTORY_PREFIX="video-images/",
BASE_URL=MEDIA_URL,
)
VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png'
VIDEO_IMAGE_DEFAULT_FILENAME = "default_video_image.png"
########################## VIDEO TRANSCRIPTS STORAGE ############################
VIDEO_TRANSCRIPTS_SETTINGS = dict(
VIDEO_TRANSCRIPTS_MAX_BYTES=3 * 1024 * 1024, # 3 MB
VIDEO_TRANSCRIPTS_MAX_BYTES=3 * 1024 * 1024, # 3 MB
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-transcripts/',
DIRECTORY_PREFIX="video-transcripts/",
)
####################### Plugin Settings ##########################
# pylint: disable=wrong-import-position, wrong-import-order
from edx_django_utils.plugins import add_plugins
# pylint: disable=wrong-import-position, wrong-import-order
from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType
add_plugins(__name__, ProjectType.CMS, SettingsType.TEST)
########################## Derive Any Derived Settings #######################
@@ -310,22 +305,22 @@ PROCTORING_SETTINGS = {}
# Used in edx-proctoring for ID generation in lieu of SECRET_KEY - dummy value
# (ref MST-637)
PROCTORING_USER_OBFUSCATION_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
PROCTORING_USER_OBFUSCATION_KEY = "85920908f28904ed733fe576320db18cabd7b6cd"
##### LOGISTRATION RATE LIMIT SETTINGS #####
LOGISTRATION_RATELIMIT_RATE = '5/5m'
LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = '6/5m'
LOGISTRATION_API_RATELIMIT = '5/m'
LOGISTRATION_RATELIMIT_RATE = "5/5m"
LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = "6/5m"
LOGISTRATION_API_RATELIMIT = "5/m"
REGISTRATION_VALIDATION_RATELIMIT = '5/minute'
REGISTRATION_RATELIMIT = '5/minute'
OPTIONAL_FIELD_API_RATELIMIT = '5/m'
REGISTRATION_VALIDATION_RATELIMIT = "5/minute"
REGISTRATION_RATELIMIT = "5/minute"
OPTIONAL_FIELD_API_RATELIMIT = "5/m"
RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = '2/m'
RESET_PASSWORD_API_RATELIMIT = '2/m'
RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = "2/m"
RESET_PASSWORD_API_RATELIMIT = "2/m"
############### Settings for proctoring ###############
PROCTORING_USER_OBFUSCATION_KEY = 'test_key'
PROCTORING_USER_OBFUSCATION_KEY = "test_key"
#################### Network configuration ####################
# Tests are not behind any proxies
@@ -339,10 +334,5 @@ COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = {
############## openedx-learning (Learning Core) config ##############
OPENEDX_LEARNING = {
'MEDIA': {
'BACKEND': 'django.core.files.storage.InMemoryStorage',
'OPTIONS': {
'location': MEDIA_ROOT + "_private"
}
}
"MEDIA": {"BACKEND": "django.core.files.storage.InMemoryStorage", "OPTIONS": {"location": MEDIA_ROOT + "_private"}}
}

View File

@@ -1,6 +1,7 @@
"""
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
@@ -421,7 +422,7 @@ class StudentDashboardFiltersTest(ModuleStoreTestCase):
response = self.client.get(self.dashboard_url)
self.assertEqual(status.HTTP_302_FOUND, response.status_code)
self.assertEqual(reverse("account_settings"), response.url)
self.assertEqual(settings.ACCOUNT_MICROFRONTEND_URL, response.url)
@override_settings(
OPEN_EDX_FILTERS_CONFIG={

View File

@@ -233,7 +233,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
"""
UserProfile.objects.get(user=self.user).delete()
response = self.client.get(self.path)
self.assertRedirects(response, reverse('account_settings'))
self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, target_status_code=302)
@patch('common.djangoapps.student.views.dashboard.learner_home_mfe_enabled')
def test_redirect_to_learner_home(self, mock_learner_home_mfe_enabled):

View File

@@ -518,7 +518,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(reverse('account_settings'))
return redirect(settings.ACCOUNT_MICROFRONTEND_URL)
if learner_home_mfe_enabled():
return redirect(settings.LEARNER_HOME_MICROFRONTEND_URL)
@@ -623,7 +623,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("<a href='{account_setting_page}'>").format(
account_setting_page=reverse('account_settings'),
account_setting_page=settings.ACCOUNT_MICROFRONTEND_URL,
),
link_end=HTML("</a>")
)
@@ -892,7 +892,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 reverse('account_settings'))
response = HttpResponseRedirect(exc.redirect_to or settings.ACCOUNT_MICROFRONTEND_URL)
except DashboardRenderStarted.RenderCustomResponse as exc:
response = exc.response
else:

View File

@@ -2,10 +2,11 @@
Tests for the Third Party Auth REST API
"""
import urllib
from unittest.mock import patch
import ddt
import six
from django.conf import settings
from django.http import QueryDict
from django.test.utils import override_settings
from django.urls import reverse
@@ -219,7 +220,7 @@ class UserViewV2APITests(UserViewsMixin, TpaAPITestCase):
"""
return '?'.join([
reverse('third_party_auth_users_api_v2'),
six.moves.urllib.parse.urlencode(identifier)
urllib.parse.urlencode(identifier)
])
@@ -377,11 +378,12 @@ class TestThirdPartyAuthUserStatusView(ThirdPartyAuthTestMixin, APITestCase):
"""
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': '/auth/login/google-oauth2/?auth_entry=account_settings&next=%2Faccount%2Fsettings',
'connect_url': f'/auth/login/google-oauth2/?auth_entry=account_settings&next={next_url}',
'connected': False, 'id': 'oa2-google-oauth2'
}])

View File

@@ -9,7 +9,6 @@ from django.conf import settings
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
@@ -425,7 +424,7 @@ class ThirdPartyAuthUserStatusView(APIView):
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'),
redirect_url=settings.ACCOUNT_MICROFRONTEND_URL,
),
'accepts_logins': state.provider.accepts_logins,
# If the user is connected, sending a POST request to this url removes the connection

View File

@@ -2,7 +2,6 @@
Base integration test for provider implementations.
"""
import json
import unittest
from contextlib import contextmanager
@@ -11,7 +10,7 @@ from unittest import mock
import pytest
from django import test
from django.conf import settings
from django.contrib import auth
from django.contrib import auth, messages
from django.contrib.auth import models as auth_models
from django.contrib.messages.storage import fallback
from django.contrib.sessions.backends import cache
@@ -28,7 +27,6 @@ from openedx.core.djangoapps.user_authn.views.login_form import login_and_regist
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
@@ -56,9 +54,9 @@ class HelperMixin:
test_username (str): username to check the form initialization with.
expected (str): expected cleaned username after the form initialization.
"""
form_data['username'] = test_username
form_data["username"] = test_username
form_field_data = self.provider.get_register_form_data(form_data)
assert form_field_data['username'] == expected
assert form_field_data["username"] == expected
def assert_redirect_to_provider_looks_correct(self, response):
"""Asserts the redirect to the provider's site looks correct.
@@ -70,9 +68,11 @@ class HelperMixin:
example, more details about the format of the Location header.
"""
assert 302 == response.status_code
assert response.has_header('Location')
assert response.has_header("Location")
def assert_register_response_in_pipeline_looks_correct(self, response, pipeline_kwargs, required_fields): # lint-amnesty, pylint: disable=invalid-name
def assert_register_response_in_pipeline_looks_correct(
self, response, pipeline_kwargs, required_fields
): # lint-amnesty, pylint: disable=invalid-name
"""Performs spot checks of the rendered register.html page.
When we display the new account registration form after the user signs
@@ -84,10 +84,7 @@ class HelperMixin:
assertions in your test, override this method.
"""
# Check that the correct provider was selected.
self.assertContains(
response,
'"errorMessage": null'
)
self.assertContains(response, '"errorMessage": null')
self.assertContains(
response,
f'"currentProvider": "{self.provider.name}"',
@@ -99,46 +96,68 @@ class HelperMixin:
if prepopulated_form_data in required_fields:
self.assertContains(response, form_field_data[prepopulated_form_data])
def assert_register_form_populates_unicode_username_correctly(self, request): # lint-amnesty, pylint: disable=invalid-name
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.
The field could be empty or prefilled depending on whether ENABLE_UNICODE_USERNAME feature is disabled/enabled.
"""
unicode_username = 'Червона_Калина'
ascii_substring = 'untouchable'
unicode_username = "Червона_Калина"
ascii_substring = "untouchable"
partial_unicode_username = unicode_username + ascii_substring
pipeline_kwargs = pipeline.get(request)['kwargs']
pipeline_kwargs = pipeline.get(request)["kwargs"]
assert settings.FEATURES['ENABLE_UNICODE_USERNAME'] is False
assert settings.FEATURES["ENABLE_UNICODE_USERNAME"] is False
self._check_registration_form_username(pipeline_kwargs, unicode_username, '')
self._check_registration_form_username(pipeline_kwargs, unicode_username, "")
self._check_registration_form_username(pipeline_kwargs, partial_unicode_username, ascii_substring)
with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_UNICODE_USERNAME': True}):
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.
@@ -147,49 +166,48 @@ class HelperMixin:
"""
exception_middleware = middleware.ExceptionMiddleware(get_response=lambda request: None)
request, _ = self.get_request_and_strategy(auth_entry=auth_entry)
response = exception_middleware.process_exception(
request, exceptions.AuthCanceled(request.backend))
location = response.get('Location')
response = exception_middleware.process_exception(request, exceptions.AuthCanceled(request.backend))
location = response.get("Location")
assert 302 == response.status_code
assert 'canceled' in location
assert "canceled" in location
assert self.backend_name in location
assert location.startswith(expected_uri + '?')
assert location.startswith(expected_uri + "?")
def assert_json_failure_response_is_inactive_account(self, response):
"""Asserts failure on /login for inactive account looks right."""
assert 400 == response.status_code
payload = json.loads(response.content.decode('utf-8'))
payload = json.loads(response.content.decode("utf-8"))
context = {
'platformName': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
'supportLink': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK)
"platformName": configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME),
"supportLink": configuration_helpers.get_value("SUPPORT_SITE_LINK", settings.SUPPORT_SITE_LINK),
}
assert not payload.get('success')
assert 'inactive-user' in payload.get('error_code')
assert context == payload.get('context')
assert not payload.get("success")
assert "inactive-user" in payload.get("error_code")
assert context == payload.get("context")
def assert_json_failure_response_is_missing_social_auth(self, response):
"""Asserts failure on /login for missing social auth looks right."""
assert 403 == response.status_code
payload = json.loads(response.content.decode('utf-8'))
assert not payload.get('success')
assert payload.get('error_code') == 'third-party-auth-with-no-linked-account'
payload = json.loads(response.content.decode("utf-8"))
assert not payload.get("success")
assert payload.get("error_code") == "third-party-auth-with-no-linked-account"
def assert_json_failure_response_is_username_collision(self, response):
"""Asserts the json response indicates a username collision."""
assert 409 == response.status_code
payload = json.loads(response.content.decode('utf-8'))
assert not payload.get('success')
assert 'It looks like this username is already taken' == payload['username'][0]['user_message']
payload = json.loads(response.content.decode("utf-8"))
assert not payload.get("success")
assert "It looks like this username is already taken" == payload["username"][0]["user_message"]
def assert_json_success_response_looks_correct(self, response, verify_redirect_url):
"""Asserts the json response indicates success and redirection."""
assert 200 == response.status_code
payload = json.loads(response.content.decode('utf-8'))
assert payload.get('success')
payload = json.loads(response.content.decode("utf-8"))
assert payload.get("success")
if verify_redirect_url:
assert pipeline.get_complete_url(self.provider.backend_name) == payload.get('redirect_url')
assert pipeline.get_complete_url(self.provider.backend_name) == payload.get("redirect_url")
def assert_login_response_before_pipeline_looks_correct(self, response):
"""Asserts a GET of /login not in the pipeline looks correct."""
@@ -218,19 +236,19 @@ class HelperMixin:
assert 302 == response.status_code
# NOTE: Ideally we should use assertRedirects(), however it errors out due to the hostname, testserver,
# not being properly set. This may be an issue with the call made by PSA, but we are not certain.
assert response.get('Location').endswith(
assert response.get("Location").endswith(
expected_redirect_url or django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL
)
def assert_redirect_to_login_looks_correct(self, response):
"""Asserts a response would redirect to /login."""
assert 302 == response.status_code
assert '/login' == response.get('Location')
assert "/login" == response.get("Location")
def assert_redirect_to_register_looks_correct(self, response):
"""Asserts a response would redirect to /register."""
assert 302 == response.status_code
assert '/register' == response.get('Location')
assert "/register" == response.get("Location")
def assert_register_response_before_pipeline_looks_correct(self, response):
"""Asserts a GET of /register not in the pipeline looks correct."""
@@ -241,43 +259,41 @@ class HelperMixin:
def assert_social_auth_does_not_exist_for_user(self, user, strategy):
"""Asserts a user does not have an auth with the expected provider."""
social_auths = strategy.storage.user.get_social_auth_for_user(
user, provider=self.provider.backend_name)
social_auths = strategy.storage.user.get_social_auth_for_user(user, provider=self.provider.backend_name)
assert 0 == len(social_auths)
def assert_social_auth_exists_for_user(self, user, strategy):
"""Asserts a user has a social auth with the expected provider."""
social_auths = strategy.storage.user.get_social_auth_for_user(
user, provider=self.provider.backend_name)
social_auths = strategy.storage.user.get_social_auth_for_user(user, provider=self.provider.backend_name)
assert 1 == len(social_auths)
assert self.backend_name == social_auths[0].provider
def assert_logged_in_cookie_redirect(self, response):
"""Verify that the user was redirected in order to set the logged in cookie. """
"""Verify that the user was redirected in order to set the logged in cookie."""
assert response.status_code == 302
assert response['Location'] == pipeline.get_complete_url(self.provider.backend_name)
assert response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value == 'true'
assert response["Location"] == pipeline.get_complete_url(self.provider.backend_name)
assert response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value == "true"
assert django_settings.EDXMKTG_USER_INFO_COOKIE_NAME in response.cookies
@property
def backend_name(self):
""" Shortcut for the backend name """
"""Shortcut for the backend name"""
return self.provider.backend_name
def get_registration_post_vars(self, overrides=None):
"""POST vars generated by the registration form."""
defaults = {
'username': 'username',
'name': 'First Last',
'gender': '',
'year_of_birth': '',
'level_of_education': '',
'goals': '',
'honor_code': 'true',
'terms_of_service': 'true',
'password': 'password',
'mailing_address': '',
'email': 'user@email.com',
"username": "username",
"name": "First Last",
"gender": "",
"year_of_birth": "",
"level_of_education": "",
"goals": "",
"honor_code": "true",
"terms_of_service": "true",
"password": "password",
"mailing_address": "",
"email": "user@email.com",
}
if overrides:
@@ -294,12 +310,13 @@ class HelperMixin:
social_django.utils.strategy().
"""
request = self.request_factory.get(
pipeline.get_complete_url(self.backend_name) +
'?redirect_state=redirect_state_value&code=code_value&state=state_value')
pipeline.get_complete_url(self.backend_name)
+ "?redirect_state=redirect_state_value&code=code_value&state=state_value"
)
request.site = SiteFactory.create()
request.user = auth_models.AnonymousUser()
request.session = cache.SessionStore()
request.session[self.backend_name + '_state'] = 'state_value'
request.session[self.backend_name + "_state"] = "state_value"
if auth_entry:
request.session[pipeline.AUTH_ENTRY_KEY] = auth_entry
@@ -312,7 +329,7 @@ class HelperMixin:
def _get_login_post_request(self, strategy):
"""Gets a fully-configured login POST request given a strategy and pipeline."""
request = self.request_factory.post(reverse('login_api'))
request = self.request_factory.post(reverse("login_api"))
# Note: The shared GET request can't be used for login, which is now POST-only,
# so this POST request is given a copy of all configuration from the GET request
@@ -329,7 +346,7 @@ class HelperMixin:
def _patch_edxmako_current_request(self, request):
"""Make ``request`` be the current request for edxmako template rendering."""
with mock.patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request):
with mock.patch("common.djangoapps.edxmako.request_context.get_current_request", return_value=request):
yield
def get_user_by_email(self, strategy, email):
@@ -337,11 +354,13 @@ class HelperMixin:
return strategy.storage.user.user_model().objects.get(email=email)
def set_logged_in_cookies(self, request):
"""Simulate setting the marketing site cookie on the request. """
request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = 'true'
request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps({
'version': django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
})
"""Simulate setting the marketing site cookie on the request."""
request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = "true"
request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps(
{
"version": django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION,
}
)
def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False):
"""Creates user, profile, registration, and (usually) social auth.
@@ -371,10 +390,10 @@ class HelperMixin:
"""
args = ()
kwargs = {
'request': strategy.request,
'backend': strategy.request.backend,
'user': None,
'response': self.get_response_data(),
"request": strategy.request,
"backend": strategy.request.backend,
"user": None,
"response": self.get_response_data(),
}
return strategy.authenticate(*args, **kwargs)
@@ -386,6 +405,7 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
currently less comprehensive. Some providers are tested with this, others with
IntegrationTest.
"""
# Provider information:
PROVIDER_NAME = "override"
PROVIDER_BACKEND = "override"
@@ -399,8 +419,8 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
super().setUp()
self.request_factory = test.RequestFactory()
self.login_page_url = reverse('signin_user')
self.register_page_url = reverse('register_user')
self.login_page_url = reverse("signin_user")
self.register_page_url = reverse("register_user")
patcher = testutil.patch_mako_templates()
patcher.start()
self.addCleanup(patcher.stop)
@@ -415,47 +435,44 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
try_login_response = self.client.get(provider_register_url)
# The user should be redirected to the provider's login page:
assert try_login_response.status_code == 302
provider_response = self.do_provider_login(try_login_response['Location'])
provider_response = self.do_provider_login(try_login_response["Location"])
# We should be redirected to the register screen since this account is not linked to an edX account:
assert provider_response.status_code == 302
assert provider_response['Location'] == self.register_page_url
assert provider_response["Location"] == self.register_page_url
register_response = self.client.get(self.register_page_url)
tpa_context = register_response.context["data"]["third_party_auth"]
assert tpa_context['errorMessage'] is None
assert tpa_context["errorMessage"] is None
# Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown.
assert tpa_context['currentProvider'] == self.PROVIDER_NAME
assert tpa_context["currentProvider"] == self.PROVIDER_NAME
# Check that the data (e.g. email) from the provider is displayed in the form:
form_data = register_response.context['data']['registration_form_desc']
form_fields = {field['name']: field for field in form_data['fields']}
assert form_fields['email']['defaultValue'] == self.USER_EMAIL
assert form_fields['name']['defaultValue'] == self.USER_NAME
assert form_fields['username']['defaultValue'] == self.USER_USERNAME
form_data = register_response.context["data"]["registration_form_desc"]
form_fields = {field["name"]: field for field in form_data["fields"]}
assert form_fields["email"]["defaultValue"] == self.USER_EMAIL
assert form_fields["name"]["defaultValue"] == self.USER_NAME
assert form_fields["username"]["defaultValue"] == self.USER_USERNAME
for field_name, value in extra_defaults.items():
assert form_fields[field_name]['defaultValue'] == value
assert form_fields[field_name]["defaultValue"] == value
registration_values = {
'email': 'email-edited@tpa-test.none',
'name': 'My Customized Name',
'username': 'new_username',
'honor_code': True,
"email": "email-edited@tpa-test.none",
"name": "My Customized Name",
"username": "new_username",
"honor_code": True,
}
# Now complete the form:
ajax_register_response = self.client.post(
reverse('user_api_registration'),
registration_values
)
ajax_register_response = self.client.post(reverse("user_api_registration"), registration_values)
assert ajax_register_response.status_code == 200
# Then the AJAX will finish the third party auth:
continue_response = self.client.get(tpa_context["finishAuthUrl"])
# And we should be redirected to the dashboard:
assert continue_response.status_code == 302
assert continue_response['Location'] == reverse('dashboard')
assert continue_response["Location"] == reverse("dashboard")
# Now check that we can login again, whether or not we have yet verified the account:
self.client.logout()
self._test_return_login(user_is_activated=False)
self.client.logout()
self.verify_user_email('email-edited@tpa-test.none')
self.verify_user_email("email-edited@tpa-test.none")
self._test_return_login(user_is_activated=True)
def _test_login(self):
@@ -468,27 +485,27 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
try_login_response = self.client.get(provider_login_url)
# The user should be redirected to the provider's login page:
assert try_login_response.status_code == 302
complete_response = self.do_provider_login(try_login_response['Location'])
complete_response = self.do_provider_login(try_login_response["Location"])
# We should be redirected to the login screen since this account is not linked to an edX account:
assert complete_response.status_code == 302
assert complete_response['Location'] == self.login_page_url
assert complete_response["Location"] == self.login_page_url
login_response = self.client.get(self.login_page_url)
tpa_context = login_response.context["data"]["third_party_auth"]
assert tpa_context['errorMessage'] is None
assert tpa_context["errorMessage"] is None
# Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown.
assert tpa_context['currentProvider'] == self.PROVIDER_NAME
assert tpa_context["currentProvider"] == self.PROVIDER_NAME
# Now the user enters their username and password.
# The AJAX on the page will log them in:
ajax_login_response = self.client.post(
reverse('user_api_login_session', kwargs={'api_version': 'v1'}),
{'email': self.user.email, 'password': 'Password1234'}
reverse("user_api_login_session", kwargs={"api_version": "v1"}),
{"email": self.user.email, "password": "Password1234"},
)
assert ajax_login_response.status_code == 200
# Then the AJAX will finish the third party auth:
continue_response = self.client.get(tpa_context["finishAuthUrl"])
# And we should be redirected to the dashboard:
assert continue_response.status_code == 302
assert continue_response['Location'] == reverse('dashboard')
assert continue_response["Location"] == reverse("dashboard")
# Now check that we can login again:
self.client.logout()
@@ -502,9 +519,9 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
raise NotImplementedError
def _test_return_login(self, user_is_activated=True, previous_session_timed_out=False):
""" Test logging in to an account that is already linked. """
"""Test logging in to an account that is already linked."""
# Make sure we're not logged in:
dashboard_response = self.client.get(reverse('dashboard'))
dashboard_response = self.client.get(reverse("dashboard"))
assert dashboard_response.status_code == 302
# The user goes to the login page, and sees a button to login with this provider:
provider_login_url = self._check_login_page()
@@ -512,22 +529,22 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
try_login_response = self.client.get(provider_login_url)
# The user should be redirected to the provider:
assert try_login_response.status_code == 302
login_response = self.do_provider_login(try_login_response['Location'])
login_response = self.do_provider_login(try_login_response["Location"])
# If the previous session was manually logged out, there will be one weird redirect
# required to set the login cookie (it sticks around if the main session times out):
if not previous_session_timed_out:
assert login_response.status_code == 302
assert login_response['Location'] == (self.complete_url + '?')
assert login_response["Location"] == (self.complete_url + "?")
# And then we should be redirected to the dashboard:
login_response = self.client.get(login_response['Location'])
login_response = self.client.get(login_response["Location"])
assert login_response.status_code == 302
if user_is_activated:
url_expected = reverse('dashboard')
url_expected = reverse("dashboard")
else:
url_expected = reverse('third_party_inactive_redirect') + '?next=' + reverse('dashboard')
assert login_response['Location'] == url_expected
url_expected = reverse("third_party_inactive_redirect") + "?next=" + reverse("dashboard")
assert login_response["Location"] == url_expected
# Now we are logged in:
dashboard_response = self.client.get(reverse('dashboard'))
dashboard_response = self.client.get(reverse("dashboard"))
assert dashboard_response.status_code == 200
def _check_login_page(self):
@@ -545,22 +562,23 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
return self._check_login_or_register_page(self.register_page_url, "registerUrl")
def _check_login_or_register_page(self, url, url_to_return):
""" Shared logic for _check_login_page() and _check_register_page() """
"""Shared logic for _check_login_page() and _check_register_page()"""
response = self.client.get(url)
self.assertContains(response, self.PROVIDER_NAME)
context_data = response.context['data']['third_party_auth']
provider_urls = {provider['id']: provider[url_to_return] for provider in context_data['providers']}
context_data = response.context["data"]["third_party_auth"]
provider_urls = {provider["id"]: provider[url_to_return] for provider in context_data["providers"]}
assert self.PROVIDER_ID in provider_urls
return provider_urls[self.PROVIDER_ID]
@property
def complete_url(self):
""" Get the auth completion URL for this provider """
return reverse('social:complete', kwargs={'backend': self.PROVIDER_BACKEND})
"""Get the auth completion URL for this provider"""
return reverse("social:complete", kwargs={"backend": self.PROVIDER_BACKEND})
@unittest.skipUnless(
testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES')
testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + " not in settings.FEATURES"
)
@django_utils.override_settings() # For settings reversion on a method-by-method basis.
class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
"""Abstract base class for provider integration tests."""
@@ -572,46 +590,51 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# Actual tests, executed once per child.
def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self):
self.assert_exception_redirect_looks_correct('/login', auth_entry=pipeline.AUTH_ENTRY_LOGIN)
self.assert_exception_redirect_looks_correct("/login", auth_entry=pipeline.AUTH_ENTRY_LOGIN)
def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self):
self.assert_exception_redirect_looks_correct('/register', auth_entry=pipeline.AUTH_ENTRY_REGISTER)
self.assert_exception_redirect_looks_correct("/register", auth_entry=pipeline.AUTH_ENTRY_REGISTER)
def test_canceling_authentication_redirects_to_account_settings_when_auth_entry_account_settings(self):
self.assert_exception_redirect_looks_correct(
'/account/settings', auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS
"/account/settings", auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS
)
def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self):
self.assert_exception_redirect_looks_correct('/')
self.assert_exception_redirect_looks_correct("/")
@mock.patch('common.djangoapps.third_party_auth.pipeline.segment.track')
@mock.patch("common.djangoapps.third_party_auth.pipeline.segment.track")
def test_full_pipeline_succeeds_for_linking_account(self, _mock_segment_track):
# First, create, the GET request and strategy that store pipeline state,
# configure the backend, and mock out wire traffic.
get_request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
get_request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
get_request.user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True)
partial_pipeline_token = strategy.session_get('partial_pipeline_token')
strategy, "user@example.com", "password", self.get_username(), skip_social_auth=True
)
partial_pipeline_token = strategy.session_get("partial_pipeline_token")
partial_data = strategy.storage.partial.load(partial_pipeline_token)
# Instrument the pipeline to get to the dashboard with the full
# expected state.
self.client.get(
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access
request=get_request)
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(
get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access
)
post_request = self._get_login_post_request(strategy)
login_user(post_request)
actions.do_complete(post_request.backend, social_views._do_login, # pylint: disable=protected-access, no-member
request=post_request)
actions.do_complete(
post_request.backend,
social_views._do_login, # pylint: disable=protected-access, no-member
request=post_request,
)
# First we expect that we're in the unlinked state, and that there
# really is no association in the backend.
self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False)
self.assert_third_party_accounts_state(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
@@ -630,16 +653,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# 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_account_settings_context_looks_correct(account_settings_context(get_request), linked=True)
self.assert_third_party_accounts_state(get_request, linked=True)
def test_full_pipeline_succeeds_for_unlinking_account(self):
# First, create, the GET request and strategy that store pipeline state,
# configure the backend, and mock out wire traffic.
get_request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
get_request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username())
strategy, "user@example.com", "password", self.get_username()
)
self.assert_social_auth_exists_for_user(user, strategy)
# We're already logged in, so simulate that the cookie is set correctly
@@ -647,36 +672,37 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# Instrument the pipeline to get to the dashboard with the full
# expected state.
self.client.get(
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access
request=get_request)
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(
get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access
)
post_request = self._get_login_post_request(strategy)
with self._patch_edxmako_current_request(post_request):
login_user(post_request)
actions.do_complete(post_request.backend, social_views._do_login, user=user, # pylint: disable=protected-access, no-member
request=post_request)
actions.do_complete(
post_request.backend,
social_views._do_login, # pylint: disable=protected-access
user=user, # pylint: disable=no-member
request=post_request,
)
# Copy the user that was set on the post_request object back to the original get_request object.
get_request.user = post_request.user
# First we expect that we're in the linked state, with a backend entry.
self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=True)
self.assert_third_party_accounts_state(get_request, linked=True)
self.assert_social_auth_exists_for_user(get_request.user, strategy)
# Fire off the disconnect pipeline to unlink.
self.assert_redirect_after_pipeline_completes(
actions.do_disconnect(
get_request.backend,
get_request.user,
None,
redirect_field_name=auth.REDIRECT_FIELD_NAME
get_request.backend, get_request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME
)
)
# Now we expect to be in the unlinked state, with no backend entry.
self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False)
self.assert_third_party_accounts_state(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):
@@ -684,16 +710,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# test_already_associated_exception_populates_dashboard_with_error. It
# verifies the exception gets raised when we expect; the latter test
# covers exception handling.
email = 'user@example.com'
password = 'password'
email = "user@example.com"
password = "password"
username = self.get_username()
_, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
backend = strategy.request.backend
backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
linked_user = self.create_user_models_for_existing_account(strategy, email, password, username)
unlinked_user = social_utils.Storage.user.create_user(
email='other_' + email, password=password, username='other_' + username)
email="other_" + email, password=password, username="other_" + username
)
self.assert_social_auth_exists_for_user(linked_user, strategy)
self.assert_social_auth_does_not_exist_for_user(unlinked_user, strategy)
@@ -711,42 +739,50 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# covered in other tests. Using linked=True does, however, let us test
# that the duplicate error has no effect on the state of the controls.
get_request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username())
strategy, "user@example.com", "password", self.get_username()
)
self.assert_social_auth_exists_for_user(user, strategy)
self.client.get('/login')
self.client.get("/login")
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access
request=get_request)
actions.do_complete(
get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access
)
post_request = self._get_login_post_request(strategy)
with self._patch_edxmako_current_request(post_request):
login_user(post_request)
actions.do_complete(post_request.backend, social_views._do_login, # pylint: disable=protected-access, no-member
user=user, request=post_request)
actions.do_complete(
post_request.backend,
social_views._do_login, # pylint: disable=protected-access, no-member
user=user,
request=post_request,
)
# Monkey-patch storage for messaging; pylint: disable=protected-access
post_request._messages = fallback.FallbackStorage(post_request)
middleware.ExceptionMiddleware(get_response=lambda request: None).process_exception(
post_request,
exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.'))
post_request, exceptions.AuthAlreadyAssociated(self.provider.backend_name, "account is already in use.")
)
self.assert_account_settings_context_looks_correct(
account_settings_context(post_request), duplicate=True, linked=True)
self.assert_third_party_accounts_state(post_request, duplicate=True, linked=True)
@mock.patch('common.djangoapps.third_party_auth.pipeline.segment.track')
@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):
# First, create, the GET request and strategy that store pipeline state,
# configure the backend, and mock out wire traffic.
get_request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username())
partial_pipeline_token = strategy.session_get('partial_pipeline_token')
strategy, "user@example.com", "password", self.get_username()
)
partial_pipeline_token = strategy.session_get("partial_pipeline_token")
partial_data = strategy.storage.partial.load(partial_pipeline_token)
self.assert_social_auth_exists_for_user(user, strategy)
@@ -754,19 +790,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# Begin! Ensure that the login form contains expected controls before
# the user starts the pipeline.
self.assert_login_response_before_pipeline_looks_correct(self.client.get('/login'))
self.assert_login_response_before_pipeline_looks_correct(self.client.get("/login"))
# The pipeline starts by a user GETting /auth/login/<provider>.
# Synthesize that request and check that it redirects to the correct
# provider page.
self.assert_redirect_to_provider_looks_correct(self.client.get(
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)))
self.assert_redirect_to_provider_looks_correct(
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
)
# Next, the provider makes a request against /auth/complete/<provider>
# to resume the pipeline.
# pylint: disable=protected-access
self.assert_redirect_to_login_looks_correct(actions.do_complete(get_request.backend, social_views._do_login,
request=get_request))
self.assert_redirect_to_login_looks_correct(
actions.do_complete(get_request.backend, social_views._do_login, request=get_request)
)
# At this point we know the pipeline has resumed correctly. Next we
# fire off the view that displays the login form and posts it via JS.
@@ -781,10 +819,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# We should be redirected back to the complete page, setting
# the "logged in" cookie for the marketing site.
self.assert_logged_in_cookie_redirect(actions.do_complete(
post_request.backend, social_views._do_login, post_request.user, None, # pylint: disable=protected-access, no-member
redirect_field_name=auth.REDIRECT_FIELD_NAME, request=post_request
))
self.assert_logged_in_cookie_redirect(
actions.do_complete(
post_request.backend,
social_views._do_login,
post_request.user,
None, # pylint: disable=protected-access, no-member
redirect_field_name=auth.REDIRECT_FIELD_NAME,
request=post_request,
)
)
# Set the cookie and try again
self.set_logged_in_cookies(get_request)
@@ -795,14 +839,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
self.assert_redirect_after_pipeline_completes(
self.do_complete(strategy, get_request, partial_pipeline_token, partial_data, user)
)
self.assert_account_settings_context_looks_correct(account_settings_context(get_request))
self.assert_third_party_accounts_state(get_request)
def test_signin_fails_if_account_not_active(self):
_, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account(strategy, 'user@example.com', 'password',
self.get_username())
user = self.create_user_models_for_existing_account(
strategy, "user@example.com", "password", self.get_username()
)
user.is_active = False
user.save()
@@ -813,25 +859,28 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
def test_signin_fails_if_no_account_associated(self):
_, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True)
strategy, "user@example.com", "password", self.get_username(), skip_social_auth=True
)
post_request = self._get_login_post_request(strategy)
self.assert_json_failure_response_is_missing_social_auth(login_user(post_request))
def test_signin_associates_user_if_oauth_provider_and_tpa_is_required(self):
username, email, password = self.get_username(), 'user@example.com', 'password'
username, email, password = self.get_username(), "user@example.com", "password"
_, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
user = self.create_user_models_for_existing_account(strategy, email, password, username, skip_social_auth=True)
with mock.patch(
'common.djangoapps.third_party_auth.pipeline.get_associated_user_by_email_response',
return_value=[{'user': user}, True],
"common.djangoapps.third_party_auth.pipeline.get_associated_user_by_email_response",
return_value=[{"user": user}, True],
):
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
@@ -839,30 +888,37 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
self.assert_json_success_response_looks_correct(login_user(post_request), verify_redirect_url=True)
def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_email_in_request(self):
self.assert_first_party_auth_trumps_third_party_auth(email='user@example.com')
self.assert_first_party_auth_trumps_third_party_auth(email="user@example.com")
def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_password_in_request(self):
self.assert_first_party_auth_trumps_third_party_auth(password='password')
self.assert_first_party_auth_trumps_third_party_auth(password="password")
def test_first_party_auth_trumps_third_party_auth_and_fails_when_credentials_bad(self):
self.assert_first_party_auth_trumps_third_party_auth(
email='user@example.com', password='password', success=False)
email="user@example.com", password="password", success=False
)
def test_first_party_auth_trumps_third_party_auth_and_succeeds_when_credentials_good(self):
self.assert_first_party_auth_trumps_third_party_auth(
email='user@example.com', password='password', success=True)
email="user@example.com", password="password", success=True
)
def test_pipeline_redirects_to_requested_url(self):
requested_redirect_url = 'foo' # something different from '/dashboard'
request, strategy = self.get_request_and_strategy(redirect_uri='social:complete')
requested_redirect_url = "foo" # something different from '/dashboard'
request, strategy = self.get_request_and_strategy(redirect_uri="social:complete")
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
request.session[pipeline.AUTH_REDIRECT_KEY] = requested_redirect_url
user = self.create_user_models_for_existing_account(strategy, 'user@foo.com', 'password', self.get_username())
user = self.create_user_models_for_existing_account(strategy, "user@foo.com", "password", self.get_username())
self.set_logged_in_cookies(request)
self.assert_redirect_after_pipeline_completes(
actions.do_complete(request.backend, social_views._do_login, user=user, request=request), # pylint: disable=protected-access
actions.do_complete(
request.backend,
social_views._do_login, # pylint: disable=protected-access
user=user,
request=request,
),
requested_redirect_url,
)
@@ -870,44 +926,47 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
# First, create, the request and strategy that store pipeline state.
# Mock out wire traffic.
request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete"
)
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
partial_pipeline_token = strategy.session_get('partial_pipeline_token')
partial_pipeline_token = strategy.session_get("partial_pipeline_token")
partial_data = strategy.storage.partial.load(partial_pipeline_token)
# Begin! Grab the registration page and check the login control on it.
self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register'))
self.assert_register_response_before_pipeline_looks_correct(self.client.get("/register"))
# The pipeline starts by a user GETting /auth/login/<provider>.
# Synthesize that request and check that it redirects to the correct
# provider page.
self.assert_redirect_to_provider_looks_correct(self.client.get(
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)))
self.assert_redirect_to_provider_looks_correct(
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
)
# Next, the provider makes a request against /auth/complete/<provider>.
# pylint: disable=protected-access
self.assert_redirect_to_register_looks_correct(actions.do_complete(request.backend, social_views._do_login,
request=request))
self.assert_redirect_to_register_looks_correct(
actions.do_complete(request.backend, social_views._do_login, request=request)
)
# At this point we know the pipeline has resumed correctly. Next we
# fire off the view that displays the registration form.
with self._patch_edxmako_current_request(request):
self.assert_register_form_populates_unicode_username_correctly(request)
self.assert_register_response_in_pipeline_looks_correct(
login_and_registration_form(strategy.request, initial_mode='register'),
pipeline.get(request)['kwargs'],
['name', 'username', 'email']
login_and_registration_form(strategy.request, initial_mode="register"),
pipeline.get(request)["kwargs"],
["name", "username", "email"],
)
# Next, we invoke the view that handles the POST. Not all providers
# supply email. Manually add it as the user would have to; this
# also serves as a test of overriding provider values. Always provide a
# password for us to check that we override it properly.
overridden_password = strategy.request.POST.get('password')
email = 'new@example.com'
overridden_password = strategy.request.POST.get("password")
email = "new@example.com"
if not strategy.request.POST.get('email'):
strategy.request.POST = self.get_registration_post_vars({'email': email})
if not strategy.request.POST.get("email"):
strategy.request.POST = self.get_registration_post_vars({"email": email})
# The user must not exist yet...
with pytest.raises(auth_models.User.DoesNotExist):
@@ -935,41 +994,44 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
self.assert_redirect_after_pipeline_completes(
self.do_complete(strategy, request, partial_pipeline_token, partial_data, created_user)
)
# Now the user has been redirected to the dashboard. Their third party account should now be linked.
# Their third party account should now be linked.
self.assert_social_auth_exists_for_user(created_user, strategy)
self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True)
self.assert_third_party_accounts_state(request, linked=True)
def test_new_account_registration_assigns_distinct_username_on_collision(self):
original_username = self.get_username()
request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete"
)
# Create a colliding username in the backend, then proceed with
# assignment via pipeline to make sure a distinct username is created.
strategy.storage.user.create_user(username=self.get_username(), email='user@email.com', password='password')
strategy.storage.user.create_user(username=self.get_username(), email="user@email.com", password="password")
backend = strategy.request.backend
backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
# pylint: disable=protected-access
response = actions.do_complete(backend, social_views._do_login, request=request)
assert response.status_code == 302
response = json.loads(create_account(strategy.request).content.decode('utf-8'))
assert response['username'] != original_username
response = json.loads(create_account(strategy.request).content.decode("utf-8"))
assert response["username"] != original_username
def test_new_account_registration_fails_if_email_exists(self):
request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete"
)
backend = strategy.request.backend
backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
# pylint: disable=protected-access
self.assert_redirect_to_register_looks_correct(actions.do_complete(backend, social_views._do_login,
request=request))
self.assert_redirect_to_register_looks_correct(
actions.do_complete(backend, social_views._do_login, request=request)
)
with self._patch_edxmako_current_request(request):
self.assert_register_response_in_pipeline_looks_correct(
login_and_registration_form(strategy.request, initial_mode='register'),
pipeline.get(request)['kwargs'],
['name', 'username', 'email']
login_and_registration_form(strategy.request, initial_mode="register"),
pipeline.get(request)["kwargs"],
["name", "username", "email"],
)
with self._patch_edxmako_current_request(strategy.request):
@@ -979,18 +1041,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
self.assert_json_failure_response_is_username_collision(create_account(strategy.request))
def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self):
auth_entry = 'invalid'
auth_entry = "invalid"
assert auth_entry not in pipeline._AUTH_ENTRY_CHOICES # pylint: disable=protected-access
_, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri='social:complete')
_, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri="social:complete")
with pytest.raises(pipeline.AuthEntryError):
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
def test_pipeline_assumes_login_if_auth_entry_missing(self):
_, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri='social:complete')
_, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri="social:complete")
response = self.fake_auth_complete(strategy)
assert response.url == reverse('signin_user')
assert response.url == reverse("signin_user")
def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=None, success=None):
"""Asserts first party auth was used in place of third party auth.
@@ -1004,33 +1066,35 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
one of username or password will be missing).
"""
_, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy))
self.create_user_models_for_existing_account(
strategy, email, password, self.get_username(), skip_social_auth=True)
strategy, email, password, self.get_username(), skip_social_auth=True
)
post_request = self._get_login_post_request(strategy)
post_request.POST = dict(post_request.POST)
if email:
post_request.POST['email'] = email
post_request.POST["email"] = email
if password:
post_request.POST['password'] = 'bad_' + password if success is False else password
post_request.POST["password"] = "bad_" + password if success is False else password
self.assert_pipeline_running(post_request)
payload = json.loads(login_user(post_request).content.decode('utf-8'))
payload = json.loads(login_user(post_request).content.decode("utf-8"))
if success is None:
# Request malformed -- just one of email/password given.
assert not payload.get('success')
assert 'There was an error receiving your login information' in payload.get('value')
assert not payload.get("success")
assert "There was an error receiving your login information" in payload.get("value")
elif success:
# Request well-formed and credentials good.
assert payload.get('success')
assert payload.get("success")
else:
# Request well-formed but credentials bad.
assert not payload.get('success')
assert 'incorrect' in payload.get('value')
assert not payload.get("success")
assert "incorrect" in payload.get("value")
def get_response_data(self):
"""Gets a dict of response data of the form given by the provider.
@@ -1064,8 +1128,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin):
if not user:
user = request.user
return actions.do_complete(
request.backend, social_views._do_login, user, None, # pylint: disable=protected-access
redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request, partial_token=partial_pipeline_token
request.backend,
social_views._do_login, # pylint: disable=protected-access
user,
None,
redirect_field_name=auth.REDIRECT_FIELD_NAME,
request=request,
partial_token=partial_pipeline_token,
)

View File

@@ -2,7 +2,6 @@
Third_party_auth integration tests using a mock version of the TestShib provider
"""
import datetime
import json
import logging
@@ -27,16 +26,15 @@ from common.djangoapps.third_party_auth.saml import SapSuccessFactorsIdentityPro
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
from .base import IntegrationTestMixin
TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth'
TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml'
TESTSHIB_METADATA_URL_WITH_CACHE_DURATION = 'https://mock.testshib.org/metadata/testshib-providers-cache.xml'
TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO'
TESTSHIB_ENTITY_ID = "https://idp.testshib.org/idp/shibboleth"
TESTSHIB_METADATA_URL = "https://mock.testshib.org/metadata/testshib-providers.xml"
TESTSHIB_METADATA_URL_WITH_CACHE_DURATION = "https://mock.testshib.org/metadata/testshib-providers-cache.xml"
TESTSHIB_SSO_URL = "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO"
class SamlIntegrationTestUtilities:
@@ -44,6 +42,7 @@ class SamlIntegrationTestUtilities:
Class contains methods particular to SAML integration testing so that they
can be separated out from the actual test methods.
"""
PROVIDER_ID = "saml-testshib"
PROVIDER_NAME = "TestShib"
PROVIDER_BACKEND = "tpa-saml"
@@ -67,51 +66,59 @@ class SamlIntegrationTestUtilities:
self.addCleanup(httpretty.disable) # lint-amnesty, pylint: disable=no-member
def metadata_callback(_request, _uri, headers):
""" Return a cached copy of TestShib's metadata by reading it from disk """
return (200, headers, self.read_data_file('testshib_metadata.xml')) # lint-amnesty, pylint: disable=no-member
"""Return a cached copy of TestShib's metadata by reading it from disk"""
return (
200,
headers,
self.read_data_file("testshib_metadata.xml"),
) # lint-amnesty, pylint: disable=no-member
httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback)
httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type="text/xml", body=metadata_callback)
def cache_duration_metadata_callback(_request, _uri, headers):
"""Return a cached copy of TestShib's metadata with a cacheDuration attribute"""
return (200, headers, self.read_data_file('testshib_metadata_with_cache_duration.xml')) # lint-amnesty, pylint: disable=no-member
return (
200,
headers,
self.read_data_file("testshib_metadata_with_cache_duration.xml"),
) # lint-amnesty, pylint: disable=no-member
httpretty.register_uri(
httpretty.GET,
TESTSHIB_METADATA_URL_WITH_CACHE_DURATION,
content_type='text/xml',
body=cache_duration_metadata_callback
content_type="text/xml",
body=cache_duration_metadata_callback,
)
# Configure the SAML library to use the same request ID for every request.
# Doing this and freezing the time allows us to play back recorded request/response pairs
uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID')
uid_patch = patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id", return_value="TESTID")
uid_patch.start()
self.addCleanup(uid_patch.stop) # lint-amnesty, pylint: disable=no-member
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
def _freeze_time(self, timestamp):
""" Mock the current time for SAML, so we can replay canned requests/responses """
now_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.now', return_value=timestamp)
"""Mock the current time for SAML, so we can replay canned requests/responses"""
now_patch = patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.now", return_value=timestamp)
now_patch.start()
self.addCleanup(now_patch.stop) # lint-amnesty, pylint: disable=no-member
def _configure_testshib_provider(self, **kwargs):
""" Enable and configure the TestShib SAML IdP as a third_party_auth provider """
fetch_metadata = kwargs.pop('fetch_metadata', True)
assert_metadata_updates = kwargs.pop('assert_metadata_updates', True)
kwargs.setdefault('name', self.PROVIDER_NAME)
kwargs.setdefault('enabled', True)
kwargs.setdefault('visible', True)
"""Enable and configure the TestShib SAML IdP as a third_party_auth provider"""
fetch_metadata = kwargs.pop("fetch_metadata", True)
assert_metadata_updates = kwargs.pop("assert_metadata_updates", True)
kwargs.setdefault("name", self.PROVIDER_NAME)
kwargs.setdefault("enabled", True)
kwargs.setdefault("visible", True)
kwargs.setdefault("backend_name", "tpa-saml")
kwargs.setdefault('slug', self.PROVIDER_IDP_SLUG)
kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID)
kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL)
kwargs.setdefault('icon_class', 'fa-university')
kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName
kwargs.setdefault('max_session_length', None)
kwargs.setdefault('send_to_registration_first', False)
kwargs.setdefault('skip_email_verification', False)
kwargs.setdefault("slug", self.PROVIDER_IDP_SLUG)
kwargs.setdefault("entity_id", TESTSHIB_ENTITY_ID)
kwargs.setdefault("metadata_source", TESTSHIB_METADATA_URL)
kwargs.setdefault("icon_class", "fa-university")
kwargs.setdefault("attr_email", "urn:oid:1.3.6.1.4.1.5923.1.1.1.6") # eduPersonPrincipalName
kwargs.setdefault("max_session_length", None)
kwargs.setdefault("send_to_registration_first", False)
kwargs.setdefault("skip_email_verification", False)
saml_provider = self.configure_saml_provider(**kwargs) # pylint: disable=no-member
if fetch_metadata:
@@ -127,17 +134,17 @@ class SamlIntegrationTestUtilities:
return saml_provider
def do_provider_login(self, provider_redirect_url):
""" Mocked: the user logs in to TestShib and then gets redirected back """
"""Mocked: the user logs in to TestShib and then gets redirected back"""
# The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response:
assert provider_redirect_url.startswith(TESTSHIB_SSO_URL) # lint-amnesty, pylint: disable=no-member
saml_response_xml = utils.read_and_pre_process_xml(
os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'testshib_saml_response.xml')
os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "testshib_saml_response.xml")
)
return self.client.post( # lint-amnesty, pylint: disable=no-member
self.complete_url, # lint-amnesty, pylint: disable=no-member
content_type='application/x-www-form-urlencoded',
content_type="application/x-www-form-urlencoded",
data=utils.prepare_saml_response_from_xml(saml_response_xml),
)
@@ -150,16 +157,16 @@ class TestIndexExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin,
"""
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',
"access_token": "access_token_value",
"expires_in": "expires_in_value",
}
USER_RESPONSE_DATA = {
'lastName': 'lastName_value',
'id': 'id_value',
'firstName': 'firstName_value',
'idp_name': 'testshib',
'attributes': {'urn:oid:0.9.2342.19200300.100.1.1': [], 'name_id': '1'},
'session_index': '1',
"lastName": "lastName_value",
"id": "id_value",
"firstName": "firstName_value",
"idp_name": "testshib",
"attributes": {"urn:oid:0.9.2342.19200300.100.1.1": [], "name_id": "1"},
"session_index": "1",
}
def test_index_error_from_empty_list_saml_attribute(self):
@@ -169,7 +176,8 @@ class TestIndexExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin,
"""
self.provider = self._configure_testshib_provider()
request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
with self.assertRaises(IncorrectConfigurationException):
request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy))
@@ -188,16 +196,16 @@ class TestKeyExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, t
"""
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',
"access_token": "access_token_value",
"expires_in": "expires_in_value",
}
USER_RESPONSE_DATA = {
'lastName': 'lastName_value',
'id': 'id_value',
'firstName': 'firstName_value',
'idp_name': 'testshib',
'attributes': {'name_id': '1'},
'session_index': '1',
"lastName": "lastName_value",
"id": "id_value",
"firstName": "firstName_value",
"idp_name": "testshib",
"attributes": {"name_id": "1"},
"session_index": "1",
}
def test_key_error_from_missing_saml_attributes(self):
@@ -207,7 +215,8 @@ class TestKeyExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, t
"""
self.provider = self._configure_testshib_provider()
request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
with self.assertRaises(IncorrectConfigurationException):
request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy))
@@ -226,25 +235,23 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
"""
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',
"access_token": "access_token_value",
"expires_in": "expires_in_value",
}
USER_RESPONSE_DATA = {
'lastName': 'lastName_value',
'id': 'id_value',
'firstName': 'firstName_value',
'idp_name': 'testshib',
'attributes': {'urn:oid:0.9.2342.19200300.100.1.1': ['myself'], 'name_id': '1'},
'session_index': '1',
"lastName": "lastName_value",
"id": "id_value",
"firstName": "firstName_value",
"idp_name": "testshib",
"attributes": {"urn:oid:0.9.2342.19200300.100.1.1": ["myself"], "name_id": "1"},
"session_index": "1",
}
@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')
@patch("openedx.features.enterprise_support.api.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,
):
@@ -252,10 +259,12 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
# configure the backend, and mock out wire traffic.
self.provider = self._configure_testshib_provider()
request, strategy = self.get_request_and_strategy(
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete')
auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete"
)
request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy))
user = self.create_user_models_for_existing_account(
strategy, 'user@example.com', 'password', self.get_username())
strategy, "user@example.com", "password", self.get_username()
)
self.assert_social_auth_exists_for_user(user, strategy)
request.user = user
@@ -267,70 +276,67 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
enterprise_customer = EnterpriseCustomerFactory()
assert EnterpriseCustomerUser.objects.count() == 0, "Precondition check: no link records should exist"
EnterpriseCustomerUser.objects.link_user(enterprise_customer, user.email)
assert (EnterpriseCustomerUser.objects
.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 1)
EnterpriseCustomerIdentityProvider.objects.get_or_create(enterprise_customer=enterprise_customer,
provider_id=self.provider.provider_id)
assert (
EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 1
)
EnterpriseCustomerIdentityProvider.objects.get_or_create(
enterprise_customer=enterprise_customer, provider_id=self.provider.provider_id
)
enterprise_customer_data = {
'uuid': enterprise_customer.uuid,
'name': enterprise_customer.name,
'identity_provider': 'saml-default',
'identity_providers': [
"uuid": enterprise_customer.uuid,
"name": enterprise_customer.name,
"identity_provider": "saml-default",
"identity_providers": [
{
"provider_id": "saml-default",
}
],
}
mock_auth_provider.return_value.backend_name = 'tpa-saml'
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(
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
actions.do_complete(request.backend, social_views._do_login, # pylint: disable=protected-access
request=request)
actions.do_complete(
request.backend, social_views._do_login, request=request # pylint: disable=protected-access
)
with self._patch_edxmako_current_request(strategy.request):
login_user(strategy.request)
actions.do_complete(request.backend, social_views._do_login, user=user, # pylint: disable=protected-access
request=request)
actions.do_complete(
request.backend, social_views._do_login, user=user, request=request # pylint: disable=protected-access
)
# First we expect that we're in the linked state, with a backend entry.
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()
FEATURES_WITH_ENTERPRISE_ENABLED['ENABLE_ENTERPRISE_INTEGRATION'] = True
FEATURES_WITH_ENTERPRISE_ENABLED["ENABLE_ENTERPRISE_INTEGRATION"] = True
with patch.dict("django.conf.settings.FEATURES", FEATURES_WITH_ENTERPRISE_ENABLED):
# Fire off the disconnect pipeline without the user information.
actions.do_disconnect(
request.backend,
None,
None,
redirect_field_name=auth.REDIRECT_FIELD_NAME,
request=request
request.backend, None, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request
)
assert (
EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count()
!= 0
)
assert EnterpriseCustomerUser.objects\
.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() != 0
# Fire off the disconnect pipeline to unlink.
self.assert_redirect_after_pipeline_completes(
actions.do_disconnect(
request.backend,
user,
None,
redirect_field_name=auth.REDIRECT_FIELD_NAME,
request=request
request.backend, user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request
)
)
# Now we expect to be in the unlinked state, with no backend entry.
self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=False)
self.assert_third_party_accounts_state(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
assert (
EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count()
== 0
)
def get_response_data(self):
"""Gets dict (string -> object) of merged data about the user."""
@@ -340,7 +346,7 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
def get_username(self):
response_data = self.get_response_data()
return response_data.get('idp_name')
return response_data.get("idp_name")
def test_login_before_metadata_fetched(self):
self._configure_testshib_provider(fetch_metadata=False)
@@ -350,18 +356,18 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
try_login_response = self.client.get(testshib_login_url)
# The user should be redirected to back to the login page:
assert try_login_response.status_code == 302
assert try_login_response['Location'] == self.login_page_url
assert try_login_response["Location"] == self.login_page_url
# When loading the login page, the user will see an error message:
response = self.client.get(self.login_page_url)
self.assertContains(response, 'Authentication with TestShib is currently unavailable.')
self.assertContains(response, "Authentication with TestShib is currently unavailable.")
def test_login(self):
""" Configure TestShib before running the login test """
"""Configure TestShib before running the login test"""
self._configure_testshib_provider()
self._test_login()
def test_register(self):
""" Configure TestShib before running the register test """
"""Configure TestShib before running the register test"""
self._configure_testshib_provider()
self._test_register()
@@ -374,17 +380,17 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
user=self.user, provider=self.PROVIDER_BACKEND, uid__startswith=self.PROVIDER_IDP_SLUG
)
attributes = record.extra_data
assert attributes.get('urn:oid:1.3.6.1.4.1.5923.1.1.1.9') == ['Member@testshib.org', 'Staff@testshib.org']
assert attributes.get('urn:oid:2.5.4.3') == ['Me Myself And I']
assert attributes.get('urn:oid:0.9.2342.19200300.100.1.1') == ['myself']
assert attributes.get('urn:oid:2.5.4.20') == ['555-5555']
assert attributes.get("urn:oid:1.3.6.1.4.1.5923.1.1.1.9") == ["Member@testshib.org", "Staff@testshib.org"]
assert attributes.get("urn:oid:2.5.4.3") == ["Me Myself And I"]
assert attributes.get("urn:oid:0.9.2342.19200300.100.1.1") == ["myself"]
assert attributes.get("urn:oid:2.5.4.20") == ["555-5555"]
# Phone number
@ddt.data(True, False)
def test_debug_mode_login(self, debug_mode_enabled):
""" Test SAML login logs with debug mode enabled or not """
"""Test SAML login logs with debug mode enabled or not"""
self._configure_testshib_provider(debug_mode=debug_mode_enabled)
with patch.object(saml_log, 'info') as mock_log:
with patch.object(saml_log, "info") as mock_log:
self._test_login()
if debug_mode_enabled:
# We expect that test_login() does two full logins, and each attempt generates two
@@ -393,38 +399,37 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin
expected_next_url = "/dashboard"
(msg, action_type, idp_name, request_data, next_url, xml), _kwargs = mock_log.call_args_list[0]
assert msg.startswith('SAML login %s')
assert action_type == 'request'
assert msg.startswith("SAML login %s")
assert action_type == "request"
assert idp_name == self.PROVIDER_IDP_SLUG
self.assertDictContainsSubset(
{"idp": idp_name, "auth_entry": "login", "next": expected_next_url},
request_data
{"idp": idp_name, "auth_entry": "login", "next": expected_next_url}, request_data
)
assert next_url == expected_next_url
assert '<samlp:AuthnRequest' in xml
assert "<samlp:AuthnRequest" in xml
(msg, action_type, idp_name, response_data, next_url, xml), _kwargs = mock_log.call_args_list[1]
assert msg.startswith('SAML login %s')
assert action_type == 'response'
assert msg.startswith("SAML login %s")
assert action_type == "response"
assert idp_name == self.PROVIDER_IDP_SLUG
self.assertDictContainsSubset({"RelayState": idp_name}, response_data)
assert 'SAMLResponse' in response_data
assert "SAMLResponse" in response_data
assert next_url == expected_next_url
assert '<saml2p:Response' in xml
assert "<saml2p:Response" in xml
else:
assert not mock_log.called
def test_configure_testshib_provider_with_cache_duration(self):
""" Enable and configure the TestShib SAML IdP as a third_party_auth provider """
"""Enable and configure the TestShib SAML IdP as a third_party_auth provider"""
kwargs = {}
kwargs.setdefault('name', self.PROVIDER_NAME)
kwargs.setdefault('enabled', True)
kwargs.setdefault('visible', True)
kwargs.setdefault('slug', self.PROVIDER_IDP_SLUG)
kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID)
kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL_WITH_CACHE_DURATION)
kwargs.setdefault('icon_class', 'fa-university')
kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName
kwargs.setdefault("name", self.PROVIDER_NAME)
kwargs.setdefault("enabled", True)
kwargs.setdefault("visible", True)
kwargs.setdefault("slug", self.PROVIDER_IDP_SLUG)
kwargs.setdefault("entity_id", TESTSHIB_ENTITY_ID)
kwargs.setdefault("metadata_source", TESTSHIB_METADATA_URL_WITH_CACHE_DURATION)
kwargs.setdefault("icon_class", "fa-university")
kwargs.setdefault("attr_email", "urn:oid:1.3.6.1.4.1.5923.1.1.1.6") # eduPersonPrincipalName
self.configure_saml_provider(**kwargs)
assert httpretty.is_enabled()
num_total, num_skipped, num_attempted, num_updated, num_failed, failure_messages = fetch_saml_metadata()
@@ -476,70 +481,72 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
super().setUp()
# Mock the call to the SAP SuccessFactors assertion endpoint
SAPSF_ASSERTION_URL = 'http://successfactors.com/oauth/idp'
SAPSF_ASSERTION_URL = "http://successfactors.com/oauth/idp"
def assertion_callback(_request, _uri, headers):
"""
Return a fake assertion after checking that the input is what we expect.
"""
assert b'private_key=fake_private_key_here' in _request.body
assert b'user_id=myself' in _request.body
assert b'token_url=http%3A%2F%2Fsuccessfactors.com%2Foauth%2Ftoken' in _request.body
assert b'client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB' in _request.body
return (200, headers, 'fake_saml_assertion')
assert b"private_key=fake_private_key_here" in _request.body
assert b"user_id=myself" in _request.body
assert b"token_url=http%3A%2F%2Fsuccessfactors.com%2Foauth%2Ftoken" in _request.body
assert b"client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB" in _request.body
return (200, headers, "fake_saml_assertion")
httpretty.register_uri(httpretty.POST, SAPSF_ASSERTION_URL, content_type='text/plain', body=assertion_callback)
httpretty.register_uri(httpretty.POST, SAPSF_ASSERTION_URL, content_type="text/plain", body=assertion_callback)
SAPSF_BAD_ASSERTION_URL = 'http://successfactors.com/oauth-fake/idp'
SAPSF_BAD_ASSERTION_URL = "http://successfactors.com/oauth-fake/idp"
def bad_callback(_request, _uri, headers):
"""
Return a 404 error when someone tries to call the URL.
"""
return (404, headers, 'NOT AN ASSERTION')
return (404, headers, "NOT AN ASSERTION")
httpretty.register_uri(httpretty.POST, SAPSF_BAD_ASSERTION_URL, content_type='text/plain', body=bad_callback)
httpretty.register_uri(httpretty.POST, SAPSF_BAD_ASSERTION_URL, content_type="text/plain", body=bad_callback)
# Mock the call to the SAP SuccessFactors token endpoint
SAPSF_TOKEN_URL = 'http://successfactors.com/oauth/token'
SAPSF_TOKEN_URL = "http://successfactors.com/oauth/token"
def token_callback(_request, _uri, headers):
"""
Return a fake assertion after checking that the input is what we expect.
"""
assert b'assertion=fake_saml_assertion' in _request.body
assert b'company_id=NCC1701D' in _request.body
assert b'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Asaml2-bearer' in _request.body
assert b'client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB' in _request.body
assert b"assertion=fake_saml_assertion" in _request.body
assert b"company_id=NCC1701D" in _request.body
assert b"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Asaml2-bearer" in _request.body
assert b"client_id=TatVotSEiCMteSNWtSOnLanCtBGwNhGB" in _request.body
return (200, headers, '{"access_token": "faketoken"}')
httpretty.register_uri(httpretty.POST, SAPSF_TOKEN_URL, content_type='application/json', body=token_callback)
httpretty.register_uri(httpretty.POST, SAPSF_TOKEN_URL, content_type="application/json", body=token_callback)
# Mock the call to the SAP SuccessFactors OData user endpoint
ODATA_USER_URL = (
'http://api.successfactors.com/odata/v2/User(userId=\'myself\')'
'?$select=firstName,lastName,defaultFullName,email'
"http://api.successfactors.com/odata/v2/User(userId='myself')"
"?$select=firstName,lastName,defaultFullName,email"
)
def user_callback(request, _uri, headers):
auth_header = request.headers.get('Authorization')
assert auth_header == 'Bearer faketoken'
auth_header = request.headers.get("Authorization")
assert auth_header == "Bearer faketoken"
return (
200,
headers,
json.dumps({
'd': {
'username': 'jsmith',
'firstName': 'John',
'lastName': 'Smith',
'defaultFullName': 'John Smith',
'email': 'john@smith.com',
'country': 'United States',
json.dumps(
{
"d": {
"username": "jsmith",
"firstName": "John",
"lastName": "Smith",
"defaultFullName": "John Smith",
"email": "john@smith.com",
"country": "United States",
}
}
})
),
)
httpretty.register_uri(httpretty.GET, ODATA_USER_URL, content_type='application/json', body=user_callback)
httpretty.register_uri(httpretty.GET, ODATA_USER_URL, content_type="application/json", body=user_callback)
def _mock_odata_api_for_error(self, odata_api_root_url, username):
"""
@@ -550,17 +557,17 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
"""
Return a 500 error when someone tries to call the URL.
"""
headers['CorrelationId'] = 'aefd38b7-c92c-445a-8c7a-487a3f0c7a9d'
headers['RequestNo'] = '[787177]' # This is the format SAPSF returns for the transaction request number
return 500, headers, 'Failure!'
headers["CorrelationId"] = "aefd38b7-c92c-445a-8c7a-487a3f0c7a9d"
headers["RequestNo"] = "[787177]" # This is the format SAPSF returns for the transaction request number
return 500, headers, "Failure!"
fields = ','.join(SapSuccessFactorsIdentityProvider.default_field_mapping.copy())
url = '{root_url}User(userId=\'{user_id}\')?$select={fields}'.format(
fields = ",".join(SapSuccessFactorsIdentityProvider.default_field_mapping.copy())
url = "{root_url}User(userId='{user_id}')?$select={fields}".format(
root_url=odata_api_root_url,
user_id=username,
fields=fields,
)
httpretty.register_uri(httpretty.GET, url, body=callback, content_type='application/json')
httpretty.register_uri(httpretty.GET, url, body=callback, content_type="application/json")
return url
def test_register_insufficient_sapsf_metadata(self):
@@ -569,7 +576,7 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
SuccessFactors API, and test that it falls back to the data it receives from the SAML assertion.
"""
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings='{"key_i_dont_need":"value_i_also_dont_need"}',
)
@@ -579,7 +586,7 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
self.USER_USERNAME = "myself"
self._test_register()
@patch.dict('django.conf.settings.REGISTRATION_EXTRA_FIELDS', country='optional')
@patch.dict("django.conf.settings.REGISTRATION_EXTRA_FIELDS", country="optional")
def test_register_sapsf_metadata_present(self):
"""
Configure the provider such that it can talk to a mocked-out version of the SAP SuccessFactors
@@ -589,19 +596,19 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
what we're looking for, and when an empty override is provided (expected behavior is that
existing value maps will be left alone).
"""
expected_country = 'US'
expected_country = "US"
provider_settings = {
'sapsf_oauth_root_url': 'http://successfactors.com/oauth/',
'sapsf_private_key': 'fake_private_key_here',
'odata_api_root_url': 'http://api.successfactors.com/odata/v2/',
'odata_company_id': 'NCC1701D',
'odata_client_id': 'TatVotSEiCMteSNWtSOnLanCtBGwNhGB',
"sapsf_oauth_root_url": "http://successfactors.com/oauth/",
"sapsf_private_key": "fake_private_key_here",
"odata_api_root_url": "http://api.successfactors.com/odata/v2/",
"odata_company_id": "NCC1701D",
"odata_client_id": "TatVotSEiCMteSNWtSOnLanCtBGwNhGB",
}
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings=json.dumps(provider_settings)
other_settings=json.dumps(provider_settings),
)
self._test_register(country=expected_country)
@@ -616,47 +623,49 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
"""
# Mock the call to the SAP SuccessFactors OData user endpoint
ODATA_USER_URL = (
'http://api.successfactors.com/odata/v2/User(userId=\'myself\')'
'?$select=firstName,country,lastName,defaultFullName,email'
"http://api.successfactors.com/odata/v2/User(userId='myself')"
"?$select=firstName,country,lastName,defaultFullName,email"
)
def user_callback(request, _uri, headers):
auth_header = request.headers.get('Authorization')
assert auth_header == 'Bearer faketoken'
auth_header = request.headers.get("Authorization")
assert auth_header == "Bearer faketoken"
return (
200,
headers,
json.dumps({
'd': {
'username': 'jsmith',
'firstName': 'John',
'lastName': 'Smith',
'defaultFullName': 'John Smith',
'country': 'United States'
json.dumps(
{
"d": {
"username": "jsmith",
"firstName": "John",
"lastName": "Smith",
"defaultFullName": "John Smith",
"country": "United States",
}
}
})
),
)
httpretty.register_uri(httpretty.GET, ODATA_USER_URL, content_type='application/json', body=user_callback)
httpretty.register_uri(httpretty.GET, ODATA_USER_URL, content_type="application/json", body=user_callback)
provider_settings = {
'sapsf_oauth_root_url': 'http://successfactors.com/oauth/',
'sapsf_private_key': 'fake_private_key_here',
'odata_api_root_url': 'http://api.successfactors.com/odata/v2/',
'odata_company_id': 'NCC1701D',
'odata_client_id': 'TatVotSEiCMteSNWtSOnLanCtBGwNhGB',
"sapsf_oauth_root_url": "http://successfactors.com/oauth/",
"sapsf_private_key": "fake_private_key_here",
"odata_api_root_url": "http://api.successfactors.com/odata/v2/",
"odata_company_id": "NCC1701D",
"odata_client_id": "TatVotSEiCMteSNWtSOnLanCtBGwNhGB",
}
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings=json.dumps(provider_settings),
default_email='default@testshib.org'
default_email="default@testshib.org",
)
self.USER_EMAIL = 'default@testshib.org'
self.USER_EMAIL = "default@testshib.org"
self._test_register()
@patch.dict('django.conf.settings.REGISTRATION_EXTRA_FIELDS', country='optional')
@patch.dict("django.conf.settings.REGISTRATION_EXTRA_FIELDS", country="optional")
def test_register_sapsf_metadata_present_override_relevant_value(self):
"""
Configure the provider such that it can talk to a mocked-out version of the SAP SuccessFactors
@@ -666,26 +675,26 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
what we're looking for, and when an empty override is provided (expected behavior is that
existing value maps will be left alone).
"""
value_map = {'country': {'United States': 'NZ'}}
expected_country = 'NZ'
value_map = {"country": {"United States": "NZ"}}
expected_country = "NZ"
provider_settings = {
'sapsf_oauth_root_url': 'http://successfactors.com/oauth/',
'sapsf_private_key': 'fake_private_key_here',
'odata_api_root_url': 'http://api.successfactors.com/odata/v2/',
'odata_company_id': 'NCC1701D',
'odata_client_id': 'TatVotSEiCMteSNWtSOnLanCtBGwNhGB',
"sapsf_oauth_root_url": "http://successfactors.com/oauth/",
"sapsf_private_key": "fake_private_key_here",
"odata_api_root_url": "http://api.successfactors.com/odata/v2/",
"odata_company_id": "NCC1701D",
"odata_client_id": "TatVotSEiCMteSNWtSOnLanCtBGwNhGB",
}
if value_map:
provider_settings['sapsf_value_mappings'] = value_map
provider_settings["sapsf_value_mappings"] = value_map
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings=json.dumps(provider_settings)
other_settings=json.dumps(provider_settings),
)
self._test_register(country=expected_country)
@patch.dict('django.conf.settings.REGISTRATION_EXTRA_FIELDS', country='optional')
@patch.dict("django.conf.settings.REGISTRATION_EXTRA_FIELDS", country="optional")
def test_register_sapsf_metadata_present_override_other_value(self):
"""
Configure the provider such that it can talk to a mocked-out version of the SAP SuccessFactors
@@ -695,26 +704,26 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
what we're looking for, and when an empty override is provided (expected behavior is that
existing value maps will be left alone).
"""
value_map = {'country': {'Australia': 'blahfake'}}
expected_country = 'US'
value_map = {"country": {"Australia": "blahfake"}}
expected_country = "US"
provider_settings = {
'sapsf_oauth_root_url': 'http://successfactors.com/oauth/',
'sapsf_private_key': 'fake_private_key_here',
'odata_api_root_url': 'http://api.successfactors.com/odata/v2/',
'odata_company_id': 'NCC1701D',
'odata_client_id': 'TatVotSEiCMteSNWtSOnLanCtBGwNhGB',
"sapsf_oauth_root_url": "http://successfactors.com/oauth/",
"sapsf_private_key": "fake_private_key_here",
"odata_api_root_url": "http://api.successfactors.com/odata/v2/",
"odata_company_id": "NCC1701D",
"odata_client_id": "TatVotSEiCMteSNWtSOnLanCtBGwNhGB",
}
if value_map:
provider_settings['sapsf_value_mappings'] = value_map
provider_settings["sapsf_value_mappings"] = value_map
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings=json.dumps(provider_settings)
other_settings=json.dumps(provider_settings),
)
self._test_register(country=expected_country)
@patch.dict('django.conf.settings.REGISTRATION_EXTRA_FIELDS', country='optional')
@patch.dict("django.conf.settings.REGISTRATION_EXTRA_FIELDS", country="optional")
def test_register_sapsf_metadata_present_empty_value_override(self):
"""
Configure the provider such that it can talk to a mocked-out version of the SAP SuccessFactors
@@ -725,22 +734,22 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
existing value maps will be left alone).
"""
value_map = {'country': {}}
expected_country = 'US'
value_map = {"country": {}}
expected_country = "US"
provider_settings = {
'sapsf_oauth_root_url': 'http://successfactors.com/oauth/',
'sapsf_private_key': 'fake_private_key_here',
'odata_api_root_url': 'http://api.successfactors.com/odata/v2/',
'odata_company_id': 'NCC1701D',
'odata_client_id': 'TatVotSEiCMteSNWtSOnLanCtBGwNhGB',
"sapsf_oauth_root_url": "http://successfactors.com/oauth/",
"sapsf_private_key": "fake_private_key_here",
"odata_api_root_url": "http://api.successfactors.com/odata/v2/",
"odata_company_id": "NCC1701D",
"odata_client_id": "TatVotSEiCMteSNWtSOnLanCtBGwNhGB",
}
if value_map:
provider_settings['sapsf_value_mappings'] = value_map
provider_settings["sapsf_value_mappings"] = value_map
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings=json.dumps(provider_settings)
other_settings=json.dumps(provider_settings),
)
self._test_register(country=expected_country)
@@ -750,15 +759,17 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
metadata from the SAML assertion.
"""
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings=json.dumps({
'sapsf_oauth_root_url': 'http://successfactors.com/oauth-fake/',
'sapsf_private_key': 'fake_private_key_here',
'odata_api_root_url': 'http://api.successfactors.com/odata/v2/',
'odata_company_id': 'NCC1701D',
'odata_client_id': 'TatVotSEiCMteSNWtSOnLanCtBGwNhGB',
})
other_settings=json.dumps(
{
"sapsf_oauth_root_url": "http://successfactors.com/oauth-fake/",
"sapsf_private_key": "fake_private_key_here",
"odata_api_root_url": "http://api.successfactors.com/odata/v2/",
"odata_company_id": "NCC1701D",
"odata_client_id": "TatVotSEiCMteSNWtSOnLanCtBGwNhGB",
}
),
)
# Because we're getting details from the assertion, fall back to the initial set of details.
self.USER_EMAIL = "myself@testshib.org"
@@ -776,39 +787,41 @@ class SuccessFactorsIntegrationTest(SamlIntegrationTestUtilities, IntegrationTes
self.USER_NAME = "Me Myself And I"
self.USER_USERNAME = "myself"
odata_company_id = 'NCC1701D'
odata_api_root_url = 'http://api.successfactors.com/odata/v2/'
odata_company_id = "NCC1701D"
odata_api_root_url = "http://api.successfactors.com/odata/v2/"
mocked_odata_api_url = self._mock_odata_api_for_error(odata_api_root_url, self.USER_USERNAME)
self._configure_testshib_provider(
identity_provider_type='sap_success_factors',
identity_provider_type="sap_success_factors",
metadata_source=TESTSHIB_METADATA_URL,
other_settings=json.dumps({
'sapsf_oauth_root_url': 'http://successfactors.com/oauth/',
'sapsf_private_key': 'fake_private_key_here',
'odata_api_root_url': odata_api_root_url,
'odata_company_id': odata_company_id,
'odata_client_id': 'TatVotSEiCMteSNWtSOnLanCtBGwNhGB',
})
other_settings=json.dumps(
{
"sapsf_oauth_root_url": "http://successfactors.com/oauth/",
"sapsf_private_key": "fake_private_key_here",
"odata_api_root_url": odata_api_root_url,
"odata_company_id": odata_company_id,
"odata_client_id": "TatVotSEiCMteSNWtSOnLanCtBGwNhGB",
}
),
)
with LogCapture(level=logging.WARNING) as log_capture:
self._test_register()
logging_messages = str([log_msg.getMessage() for log_msg in log_capture.records]).replace('\\', '')
logging_messages = str([log_msg.getMessage() for log_msg in log_capture.records]).replace("\\", "")
assert odata_company_id in logging_messages
assert mocked_odata_api_url in logging_messages
assert self.USER_USERNAME in logging_messages
assert 'SAPSuccessFactors' in logging_messages
assert 'Error message' in logging_messages
assert 'System message' in logging_messages
assert 'Headers' in logging_messages
assert "SAPSuccessFactors" in logging_messages
assert "Error message" in logging_messages
assert "System message" in logging_messages
assert "Headers" in logging_messages
@skip('Test not necessary for this subclass')
@skip("Test not necessary for this subclass")
def test_get_saml_idp_class_with_fake_identifier(self):
pass
@skip('Test not necessary for this subclass')
@skip("Test not necessary for this subclass")
def test_login(self):
pass
@skip('Test not necessary for this subclass')
@skip("Test not necessary for this subclass")
def test_register(self):
pass

View File

@@ -53,3 +53,42 @@ ignore_dirs:
third_party:
- wiki
- edx_proctoring_proctortrack
# How should .po files be segmented? See i18n/segment.py for details. Strings
# that are only found in a particular segment are segregated into that .po file
# so that translators can focus on separate parts of the product.
#
# We segregate Studio so we can provide new languages for LMS without having to
# also translate the Studio strings. LMS needs the strings from lms/* and
# common/*, so those will stay in the main .po file.
segment:
django-partial.po: # This .po file..
django-studio.po: # produces this .po file..
- cms/* # by segregating strings from these files.
# Anything that doesn't match a pattern stays in the original file.
djangojs-partial.po:
djangojs-studio.po:
- cms/*
mako.po:
mako-studio.po:
- cms/*
underscore.po:
underscore-studio.po:
- cms/*
# How should the generate step merge files?
generate_merge:
django.po:
- django-partial.po
- django-studio.po
- mako.po
- mako-studio.po
- wiki.po
- edx_proctoring_proctortrack.po
djangojs.po:
- djangojs-partial.po
- djangojs-studio.po
- djangojs-account-settings-view.po
- underscore.po
- underscore-studio.po

View File

@@ -111,7 +111,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': '{}{}'.format(lms_root_url, reverse('account_settings')),
'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL,
'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),

View File

@@ -743,7 +743,7 @@ class TestCourseEmailContext(SharedModuleStoreTestCase):
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'] == f'{scheme}://edx.org/account/settings'
assert email_context['account_settings_url'] == settings.ACCOUNT_MICROFRONTEND_URL
@override_settings(LMS_ROOT_URL="http://edx.org")
def test_insecure_email_context(self):

View File

@@ -2201,7 +2201,7 @@ def financial_assistance_form(request, course_id=None):
'header_text': _get_fa_header(FINANCIAL_ASSISTANCE_HEADER),
'course_id': course_id,
'dashboard_url': reverse('dashboard'),
'account_settings_url': reverse('account_settings'),
'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL,
'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
'user_details': {
'email': user.email,

View File

@@ -4,6 +4,7 @@ Views handling read (GET) requests for the Discussion tab and inline discussions
import logging
from functools import wraps
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.auth import get_user_model
@@ -629,7 +630,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': reverse('learner_profile', kwargs={'username': django_user.username}),
'learner_profile_page_url': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{django_user.username}'),
})
return context

View File

@@ -3307,7 +3307,6 @@ INSTALLED_APPS = [
'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',

View File

@@ -385,6 +385,7 @@ EDXNOTES_CLIENT_NAME = 'edx_notes_api-backend-service'
############## 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'

View File

@@ -564,7 +564,7 @@ PDF_RECEIPT_BILLING_ADDRESS = 'add your own billing address here with appropriat
PDF_RECEIPT_TERMS_AND_CONDITIONS = 'add your own terms and conditions'
PDF_RECEIPT_TAX_ID_LABEL = 'Tax ID'
PROFILE_MICROFRONTEND_URL = "http://profile-mfe/abc/"
PROFILE_MICROFRONTEND_URL = "http://profile-mfe"
ORDER_HISTORY_MICROFRONTEND_URL = "http://order-history-mfe/"
ACCOUNT_MICROFRONTEND_URL = "http://account-mfe"
AUTHN_MICROFRONTEND_URL = "http://authn-mfe"

View File

@@ -1,334 +0,0 @@
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('<div class="wrapper-account-settings"></div>');
});
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('<div class="wrapper-account-settings"></div>');
});
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);
});
});
});
});

View File

@@ -1,34 +0,0 @@
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
};
});

View File

@@ -1,216 +0,0 @@
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);
});
});
});

View File

@@ -1,91 +0,0 @@
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 <a href="https://support.edx.org/">edX Support</a> 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('<div class="wrapper-account-settings"></div>');
});
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);
});
});
});

View File

@@ -1,48 +0,0 @@
// 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);

View File

@@ -1,495 +0,0 @@
// 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(
'<a href="{edx_support_url}">', {
edx_support_url: edxSupportUrl
}
)
),
link_end: HtmlUtils.HTML('</a>')
}
);
}
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);

View File

@@ -1,466 +0,0 @@
// 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(
'<a href="{passwordResetSupportUrl}">', {
passwordResetSupportUrl: this.options.passwordResetSupportUrl
}
)
),
anchorEnd: HtmlUtils.HTML('</a>')
}
)
);
}
}),
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);

View File

@@ -1,157 +0,0 @@
// 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);

View File

@@ -1 +0,0 @@
../../openedx/features/learner_profile/static/learner_profile

View File

@@ -33,10 +33,8 @@
'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',

View File

@@ -761,9 +761,6 @@
'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',
@@ -787,10 +784,6 @@
'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/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',
'support/js/spec/collections/enrollment_spec.js',
'support/js/spec/models/enrollment_spec.js',
'support/js/spec/views/certificates_spec.js',

View File

@@ -52,7 +52,6 @@
@import 'multicourse/survey-page';
// base - specific views
@import 'views/account-settings';
@import 'views/course-entitlements';
@import 'views/login-register';
@import 'views/verification';
@@ -68,7 +67,6 @@
// features
@import 'features/bookmarks-v1';
@import 'features/learner-profile';
@import 'features/_unsupported-browser-alert';
@import 'features/content-type-gating';
@import 'features/course-duration-limits';

View File

@@ -1,875 +0,0 @@
// 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%;
}
}

View File

@@ -527,9 +527,6 @@ $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;

View File

@@ -1,683 +0,0 @@
// 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;
}
}
}
}

View File

@@ -1,14 +0,0 @@
<%page expression_filter="h"/>
<%! from django.utils.translation import gettext as _ %>
<div class="wrapper-msg urgency-high">
<div class="msg">
<div class="msg-content">
<h2 class="sr">${_("Could Not Link Accounts")}</h2>
<div class="copy">
## 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.
<p>${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name=duplicate_provider, platform_name=platform_name)}</p>
</div>
</div>
</div>
</div>

View File

@@ -4,13 +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.toggles import should_redirect_to_order_history_microfrontend
from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name, get_enterprise_learner_portal
%>
@@ -23,7 +23,7 @@ displayname = get_enterprise_learner_generic_name(request) or username
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 = should_redirect_to_order_history_microfrontend() and not enterprise_customer_portal
should_show_order_history = not enterprise_customer_portal
%>
<div class="nav-item hidden-mobile">
@@ -44,8 +44,8 @@ should_show_order_history = should_redirect_to_order_history_microfrontend() and
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL}/${enterprise_customer_portal.get('slug')}" role="menuitem">${_("Dashboard")}</a></div>
% endif
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${reverse('learner_profile', kwargs={'username': username})}" role="menuitem">${_("Profile")}</a></div>
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${reverse('account_settings')}" role="menuitem">${_("Account")}</a></div>
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}')}" role="menuitem">${_("Profile")}</a></div>
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${settings.ACCOUNT_MICROFRONTEND_URL}" role="menuitem">${_("Account")}</a></div>
% if should_show_order_history:
<div class="mobile-nav-item dropdown-item dropdown-nav-item"><a href="${settings.ORDER_HISTORY_MICROFRONTEND_URL}" role="menuitem">${_("Order History")}</a></div>
% endif

View File

@@ -1,91 +0,0 @@
<%page expression_filter="h"/>
<%!
import json
from django.urls import reverse
from django.conf import settings
from django.utils.translation import gettext as _
from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string
from openedx.core.djangolib.markup import HTML
from webpack_loader.templatetags.webpack_loader import render_bundle
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
%>
<%inherit file="/main.html" />
<%def name="online_help_token()"><% return "learneraccountsettings" %></%def>
<%namespace name='static' file='/static_content.html'/>
<%block name="pagetitle">${_("Account Settings")}</%block>
% if duplicate_provider:
<section>
<%include file='/dashboard/_dashboard_third_party_error.html' />
</section>
% endif
<div class="wrapper-account-settings"></div>
<%block name="headextra">
<%static:css group='style-course'/>
<link type="text/css" rel="stylesheet" href="${STATIC_URL}paragon/static/paragon.min.css">
</%block>
<%block name="js_extra">
<%static:require_module module_name="js/student_account/views/account_settings_factory" class_name="AccountSettingsFactory">
var fieldsData = ${ fields | n, dump_js_escaped_json },
ordersHistoryData = ${ order_history | n, dump_js_escaped_json },
authData = ${ auth | n, dump_js_escaped_json },
platformName = '${ static.get_platform_name() | n, js_escaped_string }',
contactEmail = '${ static.get_contact_email_address() | n, js_escaped_string }',
allowEmailChange = ${ bool(settings.FEATURES['ALLOW_EMAIL_ADDRESS_CHANGE']) | n, dump_js_escaped_json },
socialPlatforms = ${ settings.SOCIAL_PLATFORMS | n, dump_js_escaped_json },
syncLearnerProfileData = ${ bool(sync_learner_profile_data) | n, dump_js_escaped_json },
enterpriseName = '${ enterprise_name | n, js_escaped_string }',
enterpriseReadonlyAccountFields = ${ enterprise_readonly_account_fields | n, dump_js_escaped_json },
edxSupportUrl = '${ edx_support_url | n, js_escaped_string }',
extendedProfileFields = ${ extended_profile_fields | n, dump_js_escaped_json },
displayAccountDeletion = ${ enable_account_deletion | n, dump_js_escaped_json};
isSecondaryEmailFeatureEnabled = ${ bool(is_secondary_email_feature_enabled()) | n, dump_js_escaped_json },
enableCoppaCompliance = ${ bool(enable_coppa_compliance) | n, dump_js_escaped_json },
AccountSettingsFactory(
fieldsData,
${ disable_order_history_tab | n, dump_js_escaped_json },
ordersHistoryData,
authData,
'${ password_reset_support_link | n, js_escaped_string }',
'${ user_accounts_api_url | n, js_escaped_string }',
'${ user_preferences_api_url | n, js_escaped_string }',
${ user.id | n, dump_js_escaped_json },
platformName,
contactEmail,
allowEmailChange,
enableCoppaCompliance,
socialPlatforms,
syncLearnerProfileData,
enterpriseName,
enterpriseReadonlyAccountFields,
edxSupportUrl,
extendedProfileFields,
displayAccountDeletion,
isSecondaryEmailFeatureEnabled,
${ beta_language | n, dump_js_escaped_json },
);
</%static:require_module>
<script type="text/javascript">
window.auth = ${ auth | n, dump_js_escaped_json };
window.isActive = ${ user.is_active | n, dump_js_escaped_json };
window.additionalSiteSpecificDeletionText = "${ static.get_value('SITE_SPECIFIC_DELETION_TEXT', _(' and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School')) | n, js_escaped_string }";
window.mktgRootLink = "${ static.marketing_link('ROOT') | n, js_escaped_string }";
window.platformName = "${ platform_name | n, js_escaped_string }";
window.siteName = "${ static.get_value('SITE_NAME', settings.SITE_NAME) | n, js_escaped_string }";
window.mktgEmailOptIn = ${ settings.MARKETING_EMAILS_OPT_IN | n, dump_js_escaped_json };;
</script>
<%static:webpack entry="StudentAccountDeletionInitializer">
</%static:webpack>
</%block>

View File

@@ -1,30 +0,0 @@
<main id="main" aria-label="Content" tabindex="-1">
<div class="account-settings-container">
<% if (message) { %>
<div class="account-settings-message">
<div id="beta-language-message" class="alert-message warning" aria-live="assertive" role="alert">
<span><%= HtmlUtils.ensureHtml(message) %></span>
<div class="alert-actions">
<button class="btn-alert-primary" data-old-lang-code="<%- oldLangCode %>"><%- gettext('Switch Language Back') %></button>
<a href="<%- helpTranslateLink %>" rel="noopener" target="_blank" class="btn-alert-secondary"><%= HtmlUtils.ensureHtml(helpTranslateText) %></a>
</div>
</div>
</div>
<% } %>
<div class="wrapper-header">
<h2 class="header-title"><%- gettext("Account Settings") %></h2>
<div class="left list-inline account-nav" role="tablist">
<% _.each(accountSettingsTabs, function(tab) { %>
<button id="<%- tab.id %>" aria-controls="<%- tab.name %>-tabpanel" tabindex="<%- tab.tabindex %>" aria-selected="<%- tab.selected %>" aria-expanded="<%- tab.expanded %>" data-name="<%- tab.name %>" aria-describedby="header-subtitle-<%- tab.name %>" class="tab account-nav-link <%- tab.class %>" role="tab">
<%- tab.label %>
</button>
<% }); %>
</div>
</div>
<div class="account-settings-sections">
<% _.each(accountSettingsTabs, function(tab) { %>
<div id="<%- tab.name %>-tabpanel" class="account-settings-tabpanels <% if (!tab.class) { %> hidden <% } %>" aria-label="<%- tab.label %>" role="tabpanel"></div>
<% }); %>
</div>
</div>
</main>

View File

@@ -1,31 +0,0 @@
<h2 class="sr" id="header-subtitle-<%- tabName %>">
<%- tabLabel %>
</h2>
<% _.each(sections, function(section) { %>
<div class="section">
<h3 class="section-header"><%- gettext(section.title) %></h3>
<% if (section.subtitle && _.isUndefined(section.message)) { %>
<p class="account-settings-header-subtitle"><%- section.subtitle %></p>
<% } %>
<% if (section.message) { %>
<div class="account-settings-section-message">
<div class="alert-message <%- section.messageType%>" aria-live="polite">
<i class="fa fa-info-circle message-icon <%- section.messageType %>" aria-hidden="true"></i>
<span><%= HtmlUtils.ensureHtml(section.message) %></span>
</div>
</div>
<% } %>
<% if (section.domHookId) { %>
<div id="<%- section.domHookId %>"></div>
<% } %>
<div class="account-settings-section-body <%- tabName %>-section-body">
<div class="ui-loading-error is-hidden">
<span class="fa fa-exclamation-triangle message-error" aria-hidden="true"></span>
<span class="copy"><%- gettext("An error occurred. Please reload the page.") %></span>
</div>
</div>
</div>
<% }); %>

View File

@@ -28,7 +28,7 @@ profile_image_url = get_profile_image_urls_for_user(self.real_user)['medium']
<a data-hj-suppress class="dropdown-toggle" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">${username}</a>
<ul role="menu" class="dropdown-menu dropdown-menu-right" id="${_("Usermenu")}" aria-labelledby="dropdownMenuLink" tabindex="-1">
<li role="presentation"><a role="menuitem" class="dropdown-item" href="${reverse('dashboard')}">${_("Dashboard")}</a></li>
<li role="presentation"><a role="menuitem" class="dropdown-item" href="${reverse('account_settings')}">${_("Account")}</a></li>
<li role="presentation"><a role="menuitem" class="dropdown-item" href="${settings.ACCOUNT_MICROFRONTEND_URL}">${_("Account")}</a></li>
<li role="presentation"><a role="menuitem" class="dropdown-item" href="${reverse('logout')}">${_("Sign Out")}</a></li>
</ul>
</div>
@@ -36,7 +36,7 @@ profile_image_url = get_profile_image_urls_for_user(self.real_user)['medium']
</div>
<ul role="menu" class="nav flex-column align-items-center">
<li role="presentation" class="nav-item nav-item-open-collapsed-only collapse"><a role="menuitem" href="${reverse('dashboard')}">${_("Dashboard")}</a></li>
<li role="presentation" class="nav-item nav-item-open-collapsed-only"><a role="menuitem" href="${reverse('account_settings')}">${_("Account")}</a></li>
<li role="presentation" class="nav-item nav-item-open-collapsed-only"><a role="menuitem" href="${settings.ACCOUNT_MICROFRONTEND_URL}">${_("Account")}</a></li>
<li role="presentation" class="nav-item nav-item-open-collapsed-only"><a role="menuitem" href="${reverse('logout')}">${_("Sign Out")}</a></li>
</ul>
% else:

View File

@@ -658,12 +658,6 @@ urlpatterns += [
include('openedx.features.calendar_sync.urls'),
),
# Learner profile
path(
'u/',
include('openedx.features.learner_profile.urls'),
),
# Survey Report
re_path(
fr'^survey_report/',

View File

@@ -44,21 +44,6 @@ class TestComprehensiveThemeLMS(TestCase):
# 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):
"""

View File

@@ -1,300 +0,0 @@
""" 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"),
"work_experience": _("Work experience")
}
extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', [])
for field_to_exclude in fields_already_showing:
if field_to_exclude in extended_profile_field_names:
extended_profile_field_names.remove(field_to_exclude)
extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', [])
extended_profile_field_option_tuples = {}
for field in extended_profile_field_options.keys():
field_options = extended_profile_field_options[field]
extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options]
for field in extended_profile_field_names:
field_dict = {
"field_name": field,
"field_label": field_labels_map.get(field, field),
}
field_options = extended_profile_field_option_tuples.get(field)
if field_options:
field_dict["field_type"] = "ListField"
field_dict["field_options"] = field_options
else:
field_dict["field_type"] = "TextField"
extended_profile_fields.append(field_dict)
return extended_profile_fields

View File

@@ -1,241 +0,0 @@
"""
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 <em>My site</em> 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.")

View File

@@ -1,287 +0,0 @@
""" 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 requests 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.site_configuration.tests.test_util import with_site_configuration
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
@with_site_configuration(
configuration={
'extended_profile_fields': ['work_experience']
}
)
def test_context_extended_profile(self):
"""
Test that if the field is available in extended_profile configuration then the field
will be sent in response.
"""
context = account_settings_context(self.request)
extended_pofile_field = context['extended_profile_fields'][0]
assert extended_pofile_field['field_name'] == 'work_experience'
assert extended_pofile_field['field_label'] == 'Work experience'
@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.HTTPError):
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)

View File

@@ -1,44 +0,0 @@
"""
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())

View File

@@ -5,7 +5,6 @@ from django.urls import path, re_path, include
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()
@@ -13,7 +12,6 @@ USER_API_ROUTER.register(r'users', user_api_views.UserViewSet)
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<pref_key>{UserPreference.KEY_REGEX})/users/$',

View File

@@ -6,6 +6,7 @@ Utility functions for setting "logged in" cookies used by subdomains.
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
@@ -244,8 +245,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'] = reverse('account_settings')
header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username})
header_urls['account_settings'] = settings.ACCOUNT_MICROFRONTEND_URL
header_urls['learner_profile'] = urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}')
except NoReverseMatch:
pass

View File

@@ -4,6 +4,7 @@
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
@@ -57,8 +58,8 @@ class CookieTests(TestCase):
def _get_expected_header_urls(self):
expected_header_urls = {
'logout': reverse('logout'),
'account_settings': reverse('account_settings'),
'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}),
'account_settings': settings.ACCOUNT_MICROFRONTEND_URL,
'learner_profile': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{self.user.username}'),
}
block_url = retrieve_last_sitewide_block_completed(self.user)
if block_url:

View File

@@ -496,7 +496,7 @@ class LoginTest(SiteMixin, CacheIsolationTestCase, OpenEdxEventsTestMixin):
# Check that the URLs are absolute
for url in user_info["header_urls"].values():
assert 'http://testserver/' in url
assert 'http://' in url
def test_logout_deletes_mktg_cookies(self):
response, _ = self._login_response(self.user_email, self.password)

View File

@@ -1,8 +0,0 @@
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

View File

@@ -1,40 +0,0 @@
<div class="message-banner" aria-live="polite"></div>
<div class="wrapper-profile">
<div class="profile profile-other">
<div class="wrapper-profile-field-account-privacy"></div>
<div class="wrapper-profile-sections account-settings-container">
<div class="wrapper-profile-section-container-one">
<div class="wrapper-profile-section-one">
<div class="profile-image-field">
</div>
<div class="profile-section-one-fields">
</div>
</div>
<div class="ui-loading-error is-hidden">
<span class="fa fa-exclamation-triangle message-error" aria-hidden="true"></span>
<span class="copy">An error occurred. Try loading the page again.</span>
</div>
</div>
<div class="wrapper-profile-section-container-two">
<div class="wrapper-profile-bio">
</div>
</div>
</div>
</div>
<div class="ui-loading-indicator">
<p>
<span class="spin">
<span class="icon fa fa-refresh" aria-hidden="true"></span>
</span>
<span class="copy">
Loading
</span>
</p>
</div>
<div class="ui-loading-error is-hidden">
<span class="fa fa-exclamation-triangle message-error" aria-hidden="true"></span>
<span class="copy">
An error occurred. Please reload the page.
</span>
</div>
</div>

View File

@@ -1,219 +0,0 @@
(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',
'js/student_account/views/account_settings_fields',
'js/views/message_banner',
'string_utils'
], function(gettext, $, _, Backbone, Logger, StringUtils, PagingCollection, AccountSettingsModel,
AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView,
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,
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
})
];
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,
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,
};
};
});
}).call(this, define || RequireJS.define);

View File

@@ -1,79 +0,0 @@
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,
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('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);
});
});
});

View File

@@ -1,381 +0,0 @@
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);
}
});
});
});
});

View File

@@ -1,218 +0,0 @@
/* 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',
'js/student_account/views/account_settings_fields',
'js/views/message_banner'
],
function(gettext, Backbone, $, _, PagingCollection, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers,
FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView,
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'
})
];
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,
});
};
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);
});
});
});

View File

@@ -1,113 +0,0 @@
/* 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');
});
});
}
);

View File

@@ -1,133 +0,0 @@
define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'], function(_, URI, AjaxHelpers) {
'use strict';
var expectProfileElementContainsField = function(element, view) {
var titleElement, fieldTitle;
var $element = $(element);
// Avoid testing for elements without titles
titleElement = $element.find('.u-field-title');
if (titleElement.length === 0) {
return;
}
fieldTitle = titleElement.text().trim();
if (!_.isUndefined(view.options.title) && !_.isUndefined(fieldTitle)) {
expect(fieldTitle).toBe(view.options.title);
}
if ('fieldValue' in view || 'imageUrl' in view) {
if ('imageUrl' in view) {
expect($($element.find('.image-frame')[0]).attr('src')).toBe(view.imageUrl());
} else if (view.fieldType === 'date') {
expect(view.fieldValue()).toBe(view.timezoneFormattedDate());
} else if (view.fieldValue()) {
expect(view.fieldValue()).toBe(view.modelValue());
} else if ('optionForValue' in view) {
expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(
view.displayValue(view.modelValue())
);
} else {
expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(view.modelValue());
}
} else {
throw new Error('Unexpected field type: ' + view.fieldType);
}
};
var expectProfilePrivacyFieldTobeRendered = function(learnerProfileView, othersProfile) {
var $accountPrivacyElement = $('.wrapper-profile-field-account-privacy');
var $privacyFieldElement = $($accountPrivacyElement).find('.u-field');
if (othersProfile) {
expect($privacyFieldElement.length).toBe(0);
} else {
expect($privacyFieldElement.length).toBe(1);
expectProfileElementContainsField($privacyFieldElement, learnerProfileView.options.accountPrivacyFieldView);
}
};
var expectSectionOneTobeRendered = function(learnerProfileView) {
var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one'))
.find('.u-field, .social-links');
expect(sectionOneFieldElements.length).toBe(7);
expectProfileElementContainsField(sectionOneFieldElements[0], learnerProfileView.options.profileImageFieldView);
expectProfileElementContainsField(sectionOneFieldElements[1], learnerProfileView.options.usernameFieldView);
expectProfileElementContainsField(sectionOneFieldElements[2], learnerProfileView.options.nameFieldView);
_.each(_.rest(sectionOneFieldElements, 3), function(sectionFieldElement, fieldIndex) {
expectProfileElementContainsField(
sectionFieldElement,
learnerProfileView.options.sectionOneFieldViews[fieldIndex]
);
});
};
var expectSectionTwoTobeRendered = function(learnerProfileView) {
var $sectionTwoElement = $('.wrapper-profile-section-two');
var $sectionTwoFieldElements = $($sectionTwoElement).find('.u-field');
expect($sectionTwoFieldElements.length).toBe(learnerProfileView.options.sectionTwoFieldViews.length);
_.each($sectionTwoFieldElements, function(sectionFieldElement, fieldIndex) {
expectProfileElementContainsField(
sectionFieldElement,
learnerProfileView.options.sectionTwoFieldViews[fieldIndex]
);
});
};
var expectProfileSectionsAndFieldsToBeRendered = function(learnerProfileView, othersProfile) {
expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile);
expectSectionOneTobeRendered(learnerProfileView);
expectSectionTwoTobeRendered(learnerProfileView);
};
var expectLimitedProfileSectionsAndFieldsToBeRendered = function(learnerProfileView, othersProfile) {
var sectionOneFieldElements = $('.wrapper-profile-section-one').find('.u-field');
expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile);
expect(sectionOneFieldElements.length).toBe(2);
expectProfileElementContainsField(
sectionOneFieldElements[0],
learnerProfileView.options.profileImageFieldView
);
expectProfileElementContainsField(
sectionOneFieldElements[1],
learnerProfileView.options.usernameFieldView
);
if (othersProfile) {
expect($('.profile-private-message').text())
.toBe('This learner is currently sharing a limited profile.');
} else {
expect($('.profile-private-message').text()).toBe('You are currently sharing a limited profile.');
}
};
var expectProfileSectionsNotToBeRendered = function() {
expect($('.wrapper-profile-field-account-privacy').length).toBe(0);
expect($('.wrapper-profile-section-one').length).toBe(0);
expect($('.wrapper-profile-section-two').length).toBe(0);
};
var expectTabbedViewToBeUndefined = function(requests, tabbedViewView) {
// Unrelated initial request, no badge request
expect(requests.length).toBe(1);
expect(tabbedViewView).toBe(undefined);
};
var expectTabbedViewToBeShown = function(tabbedViewView) {
expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true);
};
return {
expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered,
expectTabbedViewToBeUndefined: expectTabbedViewToBeUndefined,
expectTabbedViewToBeShown: expectTabbedViewToBeShown
};
});

View File

@@ -1,169 +0,0 @@
/* eslint-disable no-underscore-dangle */
(function(define) {
'use strict';
define([
'gettext',
'jquery',
'underscore',
'backbone',
'edx-ui-toolkit/js/utils/string-utils',
'edx-ui-toolkit/js/utils/html-utils',
'js/views/fields',
'js/views/image_field',
'text!learner_profile/templates/social_icons.underscore',
'backbone-super'
], function(gettext, $, _, Backbone, StringUtils, HtmlUtils, FieldViews, ImageFieldView, socialIconsTemplate) {
var LearnerProfileFieldViews = {};
LearnerProfileFieldViews.AccountPrivacyFieldView = FieldViews.DropdownFieldView.extend({
events: {
'click button.btn-change-privacy': 'finishEditing',
'change select': 'showSaveButton'
},
render: function() {
this._super();
this.showNotificationMessage();
this.updateFieldValue();
return this;
},
showNotificationMessage: function() {
var accountSettingsLink = HtmlUtils.joinHtml(
HtmlUtils.interpolateHtml(
HtmlUtils.HTML('<a href="{settings_url}">'), {settings_url: this.options.accountSettingsPageUrl}
),
gettext('Account Settings page.'),
HtmlUtils.HTML('</a>')
);
if (this.profileIsPrivate) {
this._super(
HtmlUtils.interpolateHtml(
gettext('You must specify your birth year before you can share your full profile. To specify your birth year, go to the {account_settings_page_link}'), // eslint-disable-line max-len
{account_settings_page_link: accountSettingsLink}
)
);
} else if (this.requiresParentalConsent) {
this._super(
HtmlUtils.interpolateHtml(
gettext('You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}'), // eslint-disable-line max-len
{account_settings_page_link: accountSettingsLink}
)
);
} else {
this._super('');
}
},
updateFieldValue: function() {
if (!this.isAboveMinimumAge) {
this.$('.u-field-value select').val('private');
this.disableField(true);
}
},
showSaveButton: function() {
$('.btn-change-privacy').removeClass('hidden');
}
});
LearnerProfileFieldViews.ProfileImageFieldView = ImageFieldView.extend({
screenReaderTitle: gettext('Profile Image'),
imageUrl: function() {
return this.model.profileImageUrl();
},
imageAltText: function() {
return StringUtils.interpolate(
gettext('Profile image for {username}'),
{username: this.model.get('username')}
);
},
imageChangeSucceeded: function() {
var view = this;
// Update model to get the latest urls of profile image.
this.model.fetch().done(function() {
view.setCurrentStatus('');
view.render();
view.$('.u-field-upload-button').focus();
}).fail(function() {
view.setCurrentStatus('');
view.showErrorMessage(view.errorMessage);
});
},
imageChangeFailed: function(e, data) {
this.setCurrentStatus('');
this.showImageChangeFailedMessage(data.jqXHR.status, data.jqXHR.responseText);
},
showImageChangeFailedMessage: function(status, responseText) {
var errors;
if (_.contains([400, 404], status)) {
try {
errors = JSON.parse(responseText);
this.showErrorMessage(errors.user_message);
} catch (error) {
this.showErrorMessage(this.errorMessage);
}
} else {
this.showErrorMessage(this.errorMessage);
}
},
showErrorMessage: function(message) {
this.options.messageView.showMessage(message);
},
isEditingAllowed: function() {
return this.model.isAboveMinimumAge();
},
isShowingPlaceholder: function() {
return !this.model.hasProfileImage();
},
clickedRemoveButton: function(e, data) {
this.options.messageView.hideMessage();
this._super(e, data);
},
fileSelected: function(e, data) {
this.options.messageView.hideMessage();
this._super(e, data);
}
});
LearnerProfileFieldViews.SocialLinkIconsView = Backbone.View.extend({
initialize: function(options) {
this.options = _.extend({}, options);
},
render: function() {
var socialLinks = {};
for (var platformName in this.options.socialPlatforms) { // eslint-disable-line no-restricted-syntax, guard-for-in, vars-on-top, max-len
socialLinks[platformName] = null;
for (var link in this.model.get('social_links')) { // eslint-disable-line no-restricted-syntax, vars-on-top, max-len
if (platformName === this.model.get('social_links')[link].platform) {
socialLinks[platformName] = this.model.get('social_links')[link].social_link;
}
}
}
HtmlUtils.setHtml(this.$el, HtmlUtils.template(socialIconsTemplate)({
socialLinks: socialLinks,
ownProfile: this.options.ownProfile
}));
return this;
}
});
return LearnerProfileFieldViews;
});
}).call(this, define || RequireJS.define);

View File

@@ -1,150 +0,0 @@
(function(define) {
'use strict';
define(
[
'gettext', 'jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/html-utils',
'common/js/components/views/tabbed_view',
'learner_profile/js/views/section_two_tab'
],
function(gettext, $, _, Backbone, HtmlUtils, TabbedView, SectionTwoTab) {
var LearnerProfileView = Backbone.View.extend({
initialize: function(options) {
var Router;
this.options = _.extend({}, options);
_.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError');
this.listenTo(this.options.preferencesModel, 'change:account_privacy', this.render);
Router = Backbone.Router.extend({
routes: {':about_me': 'loadTab', ':accomplishments': 'loadTab'}
});
this.router = new Router();
this.firstRender = true;
},
showFullProfile: function() {
var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge();
if (this.options.ownProfile) {
return isAboveMinimumAge
&& this.options.preferencesModel.get('account_privacy') === 'all_users';
} else {
return this.options.accountSettingsModel.get('profile_is_public');
}
},
setActiveTab: function(tab) {
// This tab may not actually exist.
if (this.tabbedView.getTabMeta(tab).tab) {
this.tabbedView.setActiveTab(tab);
}
},
render: function() {
var tabs,
$tabbedViewElement,
$wrapperProfileBioElement = this.$el.find('.wrapper-profile-bio'),
self = this;
this.sectionTwoView = new SectionTwoTab({
viewList: this.options.sectionTwoFieldViews,
showFullProfile: this.showFullProfile,
ownProfile: this.options.ownProfile
});
this.renderFields();
// Reveal the profile and hide the loading indicator
$('.ui-loading-indicator').addClass('is-hidden');
$('.wrapper-profile-section-container-one').removeClass('is-hidden');
$('.wrapper-profile-section-container-two').removeClass('is-hidden');
if (this.showFullProfile()) {
tabs = [
{view: this.sectionTwoView, title: gettext('About Me'), url: 'about_me'}
];
this.tabbedView = new TabbedView({
tabs: tabs,
router: this.router,
viewLabel: gettext('Profile')
});
$tabbedViewElement = this.tabbedView.render().el;
HtmlUtils.setHtml(
$wrapperProfileBioElement,
HtmlUtils.HTML($tabbedViewElement)
);
if (this.firstRender) {
this.router.on('route:loadTab', _.bind(this.setActiveTab, this));
Backbone.history.start();
this.firstRender = false;
// Load from history.
this.router.navigate((Backbone.history.getFragment() || 'about_me'), {trigger: true});
} else {
// Restart the router so the tab will be brought up anew.
Backbone.history.stop();
Backbone.history.start();
}
} else {
if (this.isCoppaCompliant()) {
// xss-lint: disable=javascript-jquery-html
$wrapperProfileBioElement.html(this.sectionTwoView.render().el);
}
}
return this;
},
isCoppaCompliant: function() {
var enableCoppaCompliance = this.options.accountSettingsModel.get('enable_coppa_compliance'),
isAboveAge = this.options.accountSettingsModel.isAboveMinimumAge();
return !enableCoppaCompliance || (enableCoppaCompliance && isAboveAge);
},
renderFields: function() {
var view = this,
fieldView,
imageView,
settings;
if (this.options.ownProfile && this.isCoppaCompliant()) {
fieldView = this.options.accountPrivacyFieldView;
settings = this.options.accountSettingsModel;
fieldView.profileIsPrivate = !settings.get('year_of_birth');
fieldView.requiresParentalConsent = settings.get('requires_parental_consent');
fieldView.isAboveMinimumAge = settings.isAboveMinimumAge();
fieldView.undelegateEvents();
this.$('.wrapper-profile-field-account-privacy').prepend(fieldView.render().el);
fieldView.delegateEvents();
}
// Clear existing content in user profile card
this.$('.profile-section-one-fields').html('');
// Do not show name when in limited mode or no name has been set
if (this.showFullProfile() && this.options.accountSettingsModel.get('name')) {
this.$('.profile-section-one-fields').append(this.options.nameFieldView.render().el);
}
this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el);
imageView = this.options.profileImageFieldView;
this.$('.profile-image-field').append(imageView.render().el);
if (this.showFullProfile()) {
_.each(this.options.sectionOneFieldViews, function(childFieldView) {
view.$('.profile-section-one-fields').append(childFieldView.render().el);
});
}
},
showLoadingError: function() {
this.$('.ui-loading-indicator').addClass('is-hidden');
this.$('.ui-loading-error').removeClass('is-hidden');
}
});
return LearnerProfileView;
});
}).call(this, define || RequireJS.define);

View File

@@ -1,33 +0,0 @@
(function(define) {
'use strict';
define(
[
'gettext', 'jquery', 'underscore', 'backbone', 'text!learner_profile/templates/section_two.underscore',
'edx-ui-toolkit/js/utils/html-utils'
],
function(gettext, $, _, Backbone, sectionTwoTemplate, HtmlUtils) {
var SectionTwoTab = Backbone.View.extend({
attributes: {
class: 'wrapper-profile-section-two'
},
template: _.template(sectionTwoTemplate),
initialize: function(options) {
this.options = _.extend({}, options);
},
render: function() {
var self = this;
var showFullProfile = this.options.showFullProfile();
this.$el.html(HtmlUtils.HTML(this.template({ownProfile: self.options.ownProfile, showFullProfile: showFullProfile})).toString()); // eslint-disable-line max-len
if (showFullProfile) {
_.each(this.options.viewList, function(fieldView) {
self.$el.find('.field-container').append(fieldView.render().el);
});
}
return this;
}
});
return SectionTwoTab;
});
}).call(this, define || RequireJS.define);

View File

@@ -1,10 +0,0 @@
<div class="profile-section-two-fields">
<div class="field-container"></div>
<% if (!showFullProfile) { %>
<% if(ownProfile) { %>
<span class="profile-private-message"><%- gettext("You are currently sharing a limited profile.") %></span>
<% } else { %>
<span class="profile-private-message"><%- gettext("This learner is currently sharing a limited profile.") %></span>
<% } %>
<% } %>
</div>

View File

@@ -1,9 +0,0 @@
<div class="social-links">
<% for (var platform in socialLinks) { %>
<% if (socialLinks[platform]) { %>
<a rel="noopener" target="_blank" href= <%-socialLinks[platform]%>>
<span class="icon fa fa-<%-platform%>-square" data-platform=<%-platform%> aria-hidden="true"></span>
</a>
<% } %>
<% } %>
</div>

View File

@@ -1,47 +0,0 @@
<%page expression_filter="h"/>
<%!
from django.utils.translation import gettext as _
from common.djangoapps.third_party_auth import pipeline
%>
<li class="controls--account">
<span class="title">
## 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")}
</span>
<span class="data">
<span class="third-party-auth">
% for state in provider_user_states:
<div class="auth-provider">
<div class="status">
% if state.has_account:
<span class="icon fa fa-link" aria-hidden="true"></span> <span class="copy">${_('Linked')}</span>
% else:
<span class="icon fa fa-unlink" aria-hidden="true"></span><span class="copy">${_('Not Linked')}</span>
% endif
</div>
<span class="provider">${state.provider.name}</span>
<span class="control">
<form
action="${pipeline.get_disconnect_url(state.provider.provider_id, state.association_id)}"
method="post"
name="${state.get_unlink_form_name()}">
% if state.has_account:
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
<button type="button" onclick="document.${state.get_unlink_form_name()}.submit()">
## Translators: clicking on this removes the link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
${_("Unlink")}
</button>
% elif state.provider.display_for_login:
<a href="${pipeline.get_login_url(state.provider.provider_id, pipeline.AUTH_ENTRY_PROFILE)}">
## 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")}
</a>
% endif
</form>
</span>
</div>
% endfor
</span>
</li>

View File

@@ -1,69 +0,0 @@
## 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
%>
<div class="learner-achievements">
% if course_certificates or own_profile:
<h3 class="u-field-title">Course Certificates</h3>
% 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(
'<span'
' class="localized-datetime start-date"'
' data-datetime="{completion_date}"'
' data-format="shortDate"'
' data-timezone="{user_timezone}"'
' data-language="{user_language}"'
'></span>'
).format(
completion_date=certificate['created'],
user_timezone=user_timezone,
user_language=user_language,
),
)
%>
<div class="card certificate-card mode-${certificate['type']}">
<div class="card-logo">
<h4 class="sr-only">
${_('{course_mode} certificate').format(
course_mode=certificate['type'],
)}
</h4>
</div>
<div class="card-content">
<div class="card-supertitle">${course.display_org_with_default}</div>
<div class="card-title">${course.display_name_with_default}</div>
<p class="card-text">${completion_date_message_html}</p>
</div>
</div>
% endfor
% elif own_profile:
<div class="learner-message">
<h4 class="message-header">${_("You haven't earned any certificates yet.")}</h4>
% if settings.FEATURES.get('COURSES_ARE_BROWSABLE'):
<p class="message-actions">
<a class="btn btn-brand" href="${marketing_link('COURSES')}">
<span class="icon fa fa-search" aria-hidden="true"></span>
${_('Explore New Courses')}
</a>
</p>
% endif
</div>
% endif
% endif
</div>
<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
</%static:require_module_async>

View File

@@ -1,79 +0,0 @@
## mako
<%page expression_filter="h"/>
<%inherit file="/main.html" />
<%def name="online_help_token()"><% return "profile" %></%def>
<%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>
<%block name="bodyclass">view-profile</%block>
<%block name="headextra">
<%static:css group='style-course'/>
</%block>
<div class="message-banner" aria-live="polite"></div>
<main id="main" aria-label="Content" tabindex="-1">
<div class="wrapper-profile">
<div class="profile ${'profile-self' if own_profile else 'profile-other'}">
<div class="wrapper-profile-field-account-privacy">
% if own_profile and records_url:
<div class="wrapper-profile-records">
<a href="${records_url}">
<button class="btn profile-records-button">${_("View My Records")}</button>
</a>
</div>
% endif
</div>
% if own_profile:
<div class="profile-header">
<h2 class="header">${_("My Profile")}</h2>
<div class="subheader">
${_('Build out your profile to personalize your identity on {platform_name}.').format(
platform_name=platform_name,
)}
</div>
</div>
% endif
<div class="wrapper-profile-sections account-settings-container">
<div class="ui-loading-indicator">
<p><span class="spin"><span class="icon fa fa-refresh" aria-hidden="true"></span></span> <span class="copy">${_("Loading")}</span></p>
</div>
<div class="wrapper-profile-section-container-one is-hidden">
<div class="wrapper-profile-section-one">
<div class="profile-image-field">
</div>
<div class="profile-section-one-fields">
</div>
</div>
<div class="ui-loading-error is-hidden">
<span class="fa fa-exclamation-triangle message-error" aria-hidden="true"></span>
<span class="copy">${_("An error occurred. Try loading the page again.")}</span>
</div>
</div>
<div class="wrapper-profile-section-container-two is-hidden">
<div class="wrapper-profile-bio"></div>
% if achievements_fragment:
${HTML(achievements_fragment.body_html())}
% endif
</div>
</div>
</div>
</div>
</main>
<%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);
</%static:require_module>
</%block>

View File

@@ -1,281 +0,0 @@
""" 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'<a href="{CREDENTIALS_PUBLIC_SERVICE_URL}/records/">')
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&#39;t earned any certificates yet.')
else:
self.assertNotContains(response, 'You haven&#39;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}'
)

View File

@@ -1,29 +0,0 @@
"""
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()
)

View File

@@ -1,24 +0,0 @@
"""
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',
),
]

View File

@@ -1,58 +0,0 @@
"""
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

View File

@@ -1,128 +0,0 @@
""" 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 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,
'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
return context

View File

@@ -79,9 +79,6 @@ 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'),