diff --git a/common/djangoapps/pipeline_mako/tests/test_render.py b/common/djangoapps/pipeline_mako/tests/test_render.py index f49f0500d0..e3a1d784ec 100644 --- a/common/djangoapps/pipeline_mako/tests/test_render.py +++ b/common/djangoapps/pipeline_mako/tests/test_render.py @@ -39,7 +39,7 @@ class RequireJSPathOverridesTest(TestCase): def test_requirejs_path_overrides(self): result = render_require_js_path_overrides(self.OVERRIDES) # To make the string comparision easy remove the whitespaces - self.assertEqual(list(map(str.strip, result.splitlines())), self.OVERRIDES_JS) + self.assertCountEqual(list(map(str.strip, result.splitlines())), self.OVERRIDES_JS) @skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in LMS') diff --git a/common/djangoapps/student/management/commands/bulk_unenroll.py b/common/djangoapps/student/management/commands/bulk_unenroll.py index e9932afe85..1412a0ba63 100644 --- a/common/djangoapps/student/management/commands/bulk_unenroll.py +++ b/common/djangoapps/student/management/commands/bulk_unenroll.py @@ -32,7 +32,7 @@ class Command(BaseCommand): csv_path = options['csv_path'] if csv_path: - with open(csv_path) as csv_file: + with open(csv_path, 'rb') as csv_file: self.unenroll_users(csv_file) else: csv_file = BulkUnenrollConfiguration.current().csv_file diff --git a/common/djangoapps/student/management/tests/test_bulk_unenroll.py b/common/djangoapps/student/management/tests/test_bulk_unenroll.py index 3417b7c1a2..02ecc0da25 100644 --- a/common/djangoapps/student/management/tests/test_bulk_unenroll.py +++ b/common/djangoapps/student/management/tests/test_bulk_unenroll.py @@ -96,7 +96,7 @@ class BulkUnenrollTests(SharedModuleStoreTestCase): lines += str(enrollment.user.id) + "," + enrollment.user.username + "," + \ enrollment.user.email + "," + str(enrollment.course.id) + "\n" - csv_file = SimpleUploadedFile(name='test.csv', content=lines, content_type='text/csv') + csv_file = SimpleUploadedFile(name='test.csv', content=lines.encode('utf-8'), content_type='text/csv') BulkUnenrollConfiguration.objects.create(enabled=True, csv_file=csv_file) call_command("bulk_unenroll") @@ -110,14 +110,14 @@ class BulkUnenrollTests(SharedModuleStoreTestCase): for enrollment in self.enrollments: username = enrollment.user.username if username in users_unenrolled: - users_unenrolled[username].append(str(enrollment.course.id)) + users_unenrolled[username].append(str(enrollment.course.id).encode('utf-8')) else: - users_unenrolled[username] = [str(enrollment.course.id)] + users_unenrolled[username] = [str(enrollment.course.id).encode('utf-8')] lines += str(enrollment.user.id) + "," + username + "," + \ enrollment.user.email + "," + str(enrollment.course.id) + "\n" - csv_file = SimpleUploadedFile(name='test.csv', content=lines, content_type='text/csv') + csv_file = SimpleUploadedFile(name='test.csv', content=lines.encode('utf-8'), content_type='text/csv') BulkUnenrollConfiguration.objects.create(enabled=True, csv_file=csv_file) with LogCapture(LOGGER_NAME) as log: diff --git a/common/lib/capa/capa/tests/test_targeted_feedback.py b/common/lib/capa/capa/tests/test_targeted_feedback.py index 24d05124ff..d3268ee2b2 100644 --- a/common/lib/capa/capa/tests/test_targeted_feedback.py +++ b/common/lib/capa/capa/tests/test_targeted_feedback.py @@ -100,7 +100,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase): problem.student_answers = {'1_2_1': 'choice_3'} the_html = problem.get_html() - without_new_lines = the_html.replace("\n", "") + without_new_lines = the_html.replace("\\n", "").replace("\n", "") # pylint: disable=line-too-long self.assertRegexpMatches(without_new_lines, r"\s*Incorrect.*3rd WRONG solution") self.assertNotRegexpMatches(without_new_lines, r"feedback1|feedback2|feedbackC") @@ -114,7 +114,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase): problem.student_answers = {'1_2_1': 'choice_0'} the_html = problem.get_html() - without_new_lines = the_html.replace("\n", "") + without_new_lines = the_html.replace("\\n", "").replace("\n", "") # pylint: disable=line-too-long self.assertRegexpMatches(without_new_lines, r"\s*Incorrect.*1st WRONG solution") self.assertRegexpMatches(without_new_lines, r"
\{.*'1_solution_1'.*\}
") @@ -127,7 +127,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase): problem.student_answers = {'1_2_1': 'choice_2'} the_html = problem.get_html() - without_new_lines = the_html.replace("\n", "") + without_new_lines = the_html.replace("\\n", "").replace("\n", "") # pylint: disable=line-too-long self.assertRegexpMatches(without_new_lines, r"\s*Correct.*Feedback on your correct solution...") diff --git a/common/lib/xmodule/xmodule/tests/test_sequence.py b/common/lib/xmodule/xmodule/tests/test_sequence.py index c7ee64376b..3dc8eda361 100644 --- a/common/lib/xmodule/xmodule/tests/test_sequence.py +++ b/common/lib/xmodule/xmodule/tests/test_sequence.py @@ -4,6 +4,7 @@ Tests for sequence module. # pylint: disable=no-member from __future__ import absolute_import +import ast import json from datetime import timedelta @@ -195,10 +196,10 @@ class SequenceBlockTestCase(XModuleXmlImportTest): extra_context=dict(specific_masquerade=True), ) self.assertIn("seq_module.html", html) - self.assertIn( - "'banner_text': u'Because the due date has passed, " - "this assignment is hidden from the learner.'", - html + html = self.get_context_dict_from_string(html) + self.assertEqual( + 'Because the due date has passed, this assignment is hidden from the learner.', + html['banner_text'] ) def test_hidden_content_self_paced_past_due_before_end(self): @@ -223,31 +224,35 @@ class SequenceBlockTestCase(XModuleXmlImportTest): Assert sequence content is gated """ self.assertIn("seq_module.html", html) - self.assertIn("'banner_text': None", html) - self.assertIn("'items': []", html) - self.assertIn("'gated': True", html) - self.assertIn("'prereq_url': 'PrereqUrl'", html) - self.assertIn("'prereq_section_name': 'PrereqSectionName'", html) - self.assertIn("'gated_section_name': u'{}'".format(six.text_type(sequence.display_name)), html) - self.assertIn("'next_url': 'NextSequential'", html) - self.assertIn("'prev_url': 'PrevSequential'", html) + html = self.get_context_dict_from_string(html) + self.assertIsNone(html['banner_text']) + self.assertEqual([], html['items']) + self.assertTrue(html['gated_content']['gated']) + self.assertEqual('PrereqUrl', html['gated_content']['prereq_url']) + self.assertEqual('PrereqSectionName', html['gated_content']['prereq_section_name']) + self.assertIn( + six.text_type(sequence.display_name), + html['gated_content']['gated_section_name'] + ) + self.assertEqual('NextSequential', html['next_url']) + self.assertEqual('PrevSequential', html['prev_url']) def _assert_prereq(self, html, sequence): """ Assert sequence is a prerequisite with unfulfilled gates """ self.assertIn("seq_module.html", html) - self.assertIn( - "'banner_text': u'This section is a prerequisite. " - "You must complete this section in order to unlock additional content.'", - html + html = self.get_context_dict_from_string(html) + self.assertEqual( + "This section is a prerequisite. You must complete this section in order to unlock additional content.", + html['banner_text'] ) - self.assertIn("'gated': False", html) - self.assertIn(six.text_type(sequence.location), html) - self.assertIn("'prereq_url': None", html) - self.assertIn("'prereq_section_name': None", html) - self.assertIn("'next_url': 'NextSequential'", html) - self.assertIn("'prev_url': 'PrevSequential'", html) + self.assertFalse(html['gated_content']['gated']) + self.assertEqual(six.text_type(sequence.location), html['item_id']) + self.assertIsNone(html['gated_content']['prereq_url']) + self.assertIsNone(html['gated_content']['prereq_section_name']) + self.assertEqual('NextSequential', html['next_url']) + self.assertEqual('PrevSequential', html['prev_url']) def _assert_ungated(self, html, sequence): """ @@ -295,7 +300,6 @@ class SequenceBlockTestCase(XModuleXmlImportTest): self.sequence_1_2, extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'), ) - # assert that content and preq banner is shown self._assert_prereq(html, self.sequence_1_2) @@ -338,3 +342,11 @@ class SequenceBlockTestCase(XModuleXmlImportTest): {'usage_key': usage_key} ) self.assertIs(completion_return, None) + + def get_context_dict_from_string(self, data): + """ + Retrieve dictionary from string. + """ + # Replace tuple and un-necessary info from inside string and get the dictionary. + cleaned_data = data.replace("(('seq_module.html',\n", '').replace("),\n {})", '').strip() + return ast.literal_eval(cleaned_data) diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py index 575173ccb0..5b8457f68a 100644 --- a/common/lib/xmodule/xmodule/tests/test_video.py +++ b/common/lib/xmodule/xmodule/tests/test_video.py @@ -1171,7 +1171,9 @@ class VideoBlockIndexingTestCase(unittest.TestCase): self.assertFalse(validation.empty) # Validation contains some warning/message self.assertTrue(validation.summary) self.assertEqual(StudioValidationMessage.WARNING, validation.summary.type) - self.assertIn(expected_msg, validation.summary.text) + self.assertIn( + expected_msg, validation.summary.text.replace('Urdu, Esperanto', 'Esperanto, Urdu') + ) @ddt.data( ( diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 67e965f757..cd654453f8 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/lms/static/js/apiClient/.eslintrc.js b/lms/static/js/apiClient/.eslintrc.js new file mode 100644 index 0000000000..838b853a82 --- /dev/null +++ b/lms/static/js/apiClient/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: 'eslint-config-edx', + root: true, + settings: { + 'import/resolver': { + webpack: { + config: 'webpack.dev.config.js', + }, + }, + }, +}; diff --git a/lms/static/js/apiClient/index.js b/lms/static/js/apiClient/index.js new file mode 100644 index 0000000000..444dbdfe83 --- /dev/null +++ b/lms/static/js/apiClient/index.js @@ -0,0 +1,16 @@ +import { getAuthenticatedAPIClient } from '@edx/frontend-auth'; +import { NewRelicLoggingService } from '@edx/frontend-logging'; + +const apiClient = getAuthenticatedAPIClient({ + appBaseUrl: process.env.LMS_ROOT_URL, + authBaseUrl: process.env.LMS_ROOT_URL, + loginUrl: `${process.env.LMS_ROOT_URL}/login`, + logoutUrl: `${process.env.LMS_ROOT_URL}/logout`, + csrfTokenApiPath: '/csrf/api/v1/token', + refreshAccessTokenEndpoint: `${process.env.LMS_ROOT_URL}/login_refresh`, + accessTokenCookieName: process.env.JWT_AUTH_COOKIE_HEADER_PAYLOAD, + userInfoCookieName: process.env.EDXMKTG_USER_INFO_COOKIE_NAME, + loggingService: NewRelicLoggingService, +}); + +export default apiClient; diff --git a/lms/static/js/custom_user_menu_links/.eslintrc.js b/lms/static/js/custom_user_menu_links/.eslintrc.js new file mode 100644 index 0000000000..838b853a82 --- /dev/null +++ b/lms/static/js/custom_user_menu_links/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + extends: 'eslint-config-edx', + root: true, + settings: { + 'import/resolver': { + webpack: { + config: 'webpack.dev.config.js', + }, + }, + }, +}; diff --git a/lms/static/js/custom_user_menu_links/CustomUserMenuLinks.js b/lms/static/js/custom_user_menu_links/CustomUserMenuLinks.js new file mode 100644 index 0000000000..a2afad55f1 --- /dev/null +++ b/lms/static/js/custom_user_menu_links/CustomUserMenuLinks.js @@ -0,0 +1,20 @@ +import { getLearnerPortalLinks } from '@edx/frontend-enterprise'; + +import apiClient from '../apiClient'; + +function CustomUserMenuLinks() { + // Inject enterprise learner portal links + getLearnerPortalLinks(apiClient).then((learnerPortalLinks) => { + const $dashboardLink = $('#user-menu .dashboard'); + const classNames = 'mobile-nav-item dropdown-item dropdown-nav-item'; + for (let i = 0; i < learnerPortalLinks.length; i += 1) { + const link = learnerPortalLinks[i]; + + $dashboardLink.after( // xss-lint: disable=javascript-jquery-insertion + ``, + ); + } + }); +} + +export { CustomUserMenuLinks }; // eslint-disable-line import/prefer-default-export diff --git a/lms/static/js/learner_dashboard/EnterpriseLearnerPortalBanner.jsx b/lms/static/js/learner_dashboard/EnterpriseLearnerPortalBanner.jsx new file mode 100644 index 0000000000..daed1759d7 --- /dev/null +++ b/lms/static/js/learner_dashboard/EnterpriseLearnerPortalBanner.jsx @@ -0,0 +1,78 @@ +import React, { Component } from 'react'; +import { getLearnerPortalLinks } from '@edx/frontend-enterprise'; +import { StatusAlert } from '@edx/paragon'; + +import apiClient from '../apiClient'; + +const LOCAL_STORAGE_KEY = 'has-viewed-enterprise-learner-portal-banner'; + +function getAlertHtml(learnerPortalLinks) { + let html = ''; + for (let i = 0; i < learnerPortalLinks.length; i += 1) { + const link = learnerPortalLinks[i]; + html += `
+ ${link.title} has a dedicated page where you can see all of your sponsored courses. + Go to your learner portal. +
`; + } + return html; +} + +function setViewedBanner() { + window.localStorage.setItem(LOCAL_STORAGE_KEY, true); +} + +function hasViewedBanner() { + window.localStorage.getItem(LOCAL_STORAGE_KEY); +} + +class EnterpriseLearnerPortalBanner extends Component { + constructor(props) { + super(props); + + this.onClose = this.onClose.bind(this); + + this.state = { + open: false, + alertHtml: '', + }; + } + + componentDidMount() { + if (!hasViewedBanner()) { + getLearnerPortalLinks(apiClient).then((learnerPortalLinks) => { + this.setState({ + open: true, + alertHtml: getAlertHtml(learnerPortalLinks), + }); + }); + } + } + + onClose() { + this.setState({ open: false }); + setViewedBanner(); + } + + render() { + const { alertHtml, open } = this.state; + + if (open) { + return ( +
+ )} + onClose={this.onClose} + /> +
+ ); + } + + return null; + } +} + +export { EnterpriseLearnerPortalBanner }; // eslint-disable-line import/prefer-default-export diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 53143f9266..ed3e8ad2d1 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -73,6 +73,7 @@ @import 'features/_unsupported-browser-alert'; @import 'features/content-type-gating'; @import 'features/course-duration-limits'; +@import 'features/enterprise-learner-portal-banner'; // search @import 'search/search'; diff --git a/lms/static/sass/_build-lms-v2.scss b/lms/static/sass/_build-lms-v2.scss index 124f3a8544..1dc6e8ed4b 100644 --- a/lms/static/sass/_build-lms-v2.scss +++ b/lms/static/sass/_build-lms-v2.scss @@ -33,6 +33,7 @@ @import 'features/course-sock'; @import 'features/course-upgrade-message'; @import 'features/content-type-gating'; +@import 'features/enterprise-learner-portal-banner'; // Responsive Design diff --git a/lms/static/sass/bootstrap/lms-main.scss b/lms/static/sass/bootstrap/lms-main.scss index e78b42db2c..b1a066465b 100644 --- a/lms/static/sass/bootstrap/lms-main.scss +++ b/lms/static/sass/bootstrap/lms-main.scss @@ -25,6 +25,7 @@ $static-path: '../..'; @import 'features/course-sock'; @import 'features/course-upgrade-message'; @import 'features/course-duration-limits'; +@import 'features/enterprise-learner-portal-banner'; // Individual Pages diff --git a/lms/static/sass/features/_enterprise-learner-portal-banner.scss b/lms/static/sass/features/_enterprise-learner-portal-banner.scss new file mode 100644 index 0000000000..d6543505d5 --- /dev/null +++ b/lms/static/sass/features/_enterprise-learner-portal-banner.scss @@ -0,0 +1,89 @@ +$enterprise-learner-portal-banner-background-color: #d9edf7 !default; +$enterprise-learner-portal-banner-text-color: #4e4e4e !default; +$enterprise-learner-portal-banner-cta-base: #0075b4 !default; +$enterprise-learner-portal-banner-cta-hover: #075683 !default; + +.edx-enterprise-learner-portal-banner-wrapper { + background: $enterprise-learner-portal-banner-background-color; + box-sizing: border-box; + + /** Base Styles - start **/ + text-align: left; + line-height: 1.5; + font: { + family: 'Open Sans', "Helvetica Neue", Helvetica, Arial, sans-serif; + size: 1rem; + weight: 400; + } + + .alert { + position: relative; + padding: 0.75rem 1.25rem; + } + + .alert-dismissible { + .close { + position: absolute; + top: 0; + right: 0; + padding: 0.75rem 1.25rem; + background: transparent; + border: 0; + text-shadow: 0 1px 0 #fff; + opacity: 0.5; + float: right; + line-height: 1; + font: { + size: 1.5rem; + weight: 700; + } + } + + .btn { + display: inline-block; + text-align: center; + white-space: nowrap; + vertical-align: middle; + box-shadow: none; + } + } + /** Base Styles - end **/ + + + .edx-enterprise-learner-portal-banner { + box-sizing: border-box; + display: flex; + justify-content: space-between; + max-width: 1200px; + min-width: 0; + margin: 0 auto; + background: inherit; + border: none; + + .policy-link { + color: $enterprise-learner-portal-banner-cta-base; + text-decoration: underline; + + &:focus, + &:hover { + color: $enterprise-learner-portal-banner-cta-hover; + border: none; + } + } + + .alert-dialog { + margin-right: 30px; + color: $enterprise-learner-portal-banner-text-color; + } + + .btn.close { + color: $enterprise-learner-portal-banner-cta-base; + + &:focus, + &:hover { + color: $enterprise-learner-portal-banner-cta-hover; + cursor: pointer; + } + } + } +} \ No newline at end of file diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html index 29288047c8..0f18b836bc 100644 --- a/lms/templates/header/user_dropdown.html +++ b/lms/templates/header/user_dropdown.html @@ -1,6 +1,6 @@ ## mako <%page expression_filter="h"/> -<%namespace name='static' file='static_content.html'/> +<%namespace name='static' file='../static_content.html'/> <%! from django.conf import settings @@ -22,6 +22,12 @@ resume_block = retrieve_last_sitewide_block_completed(self.real_user) displayname = get_enterprise_learner_generic_name(request) or username %> +<%static:webpack entry="CustomUserMenuLinks"> + $(document).ready(function() { + CustomUserMenuLinks(); + }); + + %endif + ${static.renderReact( + component="EnterpriseLearnerPortalBanner", + id="enterprise-learner-portal-banner", + props={} + )}
diff --git a/webpack.common.config.js b/webpack.common.config.js index 890ed2eb81..5e8602b06a 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -91,6 +91,8 @@ module.exports = Merge.smart({ StudentAccountDeletion: './lms/static/js/student_account/components/StudentAccountDeletion.jsx', StudentAccountDeletionInitializer: './lms/static/js/student_account/StudentAccountDeletionInitializer.js', ProblemBrowser: './lms/djangoapps/instructor/static/instructor/ProblemBrowser/index.jsx', + CustomUserMenuLinks: './lms/static/js/custom_user_menu_links/CustomUserMenuLinks.js', + EnterpriseLearnerPortalBanner: './lms/static/js/learner_dashboard/EnterpriseLearnerPortalBanner.jsx', // Learner Dashboard EntitlementFactory: './lms/static/js/learner_dashboard/course_entitlement_factory.js', diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 913d773cac..c560486dd3 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -20,7 +20,10 @@ module.exports = _.values(Merge.smart(commonConfig, { debug: true }), new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('development') + 'process.env.NODE_ENV': JSON.stringify('development'), + 'process.env.LMS_ROOT_URL': JSON.stringify('https://localhost:18000'), + 'process.env.JWT_AUTH_COOKIE_HEADER_PAYLOAD': JSON.stringify('edx-jwt-cookie-header-payload'), + 'process.env.EDXMKTG_USER_INFO_COOKIE_NAME': JSON.stringify('edx-user-info') }) ], module: { diff --git a/webpack.prod.config.js b/webpack.prod.config.js index 360ab56d4d..23c87f8ecd 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -17,7 +17,10 @@ var optimizedConfig = Merge.smart(commonConfig, { devtool: false, plugins: [ new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('production') + 'process.env.NODE_ENV': JSON.stringify('production'), + 'process.env.LMS_ROOT_URL': JSON.stringify(process.env.LMS_ROOT_URL), + 'process.env.JWT_AUTH_COOKIE_HEADER_PAYLOAD': JSON.stringify(process.env.JWT_AUTH_COOKIE_HEADER_PAYLOAD), + 'process.env.EDXMKTG_USER_INFO_COOKIE_NAME': JSON.stringify(process.env.EDXMKTG_USER_INFO_COOKIE_NAME) }), new webpack.LoaderOptionsPlugin({ // This may not be needed; legacy option for loaders written for webpack 1 minimize: true