diff --git a/cms/envs/common.py b/cms/envs/common.py index e9f01a72bd51..df1d783aa639 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1020,6 +1020,11 @@ ##### EMBARGO ##### EMBARGO_SITE_REDIRECT_URL = None +##### custom vendor plugin variables ##### +# JavaScript code can access this data using `process.env.JS_ENV_EXTRA_CONFIG` +# One of the current use cases for this is enabling custom TinyMCE plugins +JS_ENV_EXTRA_CONFIG = {} + ############################### PIPELINE ####################################### PIPELINE = { @@ -1509,6 +1514,9 @@ 'openedx.core.djangoapps.schedules', 'rest_framework_jwt', + # GDPR user retirement + 'lms.djangoapps.gdpr_user_retirement', + # Learning Sequence Navigation 'openedx.core.djangoapps.content.learning_sequences.apps.LearningSequencesConfig', diff --git a/cms/urls.py b/cms/urls.py index 806889f9c9e1..fb8f2067e0de 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -178,6 +178,7 @@ url(r'^api/val/v0/', include('edxval.urls')), url(r'^api/tasks/v0/', include('user_tasks.urls')), url(r'^accessibility$', contentstore_views.accessibility, name='accessibility'), + url(r'', include('lms.djangoapps.gdpr_user_retirement.urls')), ] if not settings.DISABLE_DEPRECATED_SIGNIN_URL: diff --git a/common/djangoapps/third_party_auth/config/__init__.py b/common/djangoapps/third_party_auth/config/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/djangoapps/third_party_auth/config/waffle.py b/common/djangoapps/third_party_auth/config/waffle.py new file mode 100644 index 000000000000..86c344c779d2 --- /dev/null +++ b/common/djangoapps/third_party_auth/config/waffle.py @@ -0,0 +1,24 @@ +""" +This module contains various configuration settings via +waffle switches for third party authentication. +""" + + +from edx_toggles.toggles.__future__ import WaffleSwitch + + +WAFFLE_NAMESPACE = 'third_party_auth' + +# .. toggle_name: ALWAYS_ASSOCIATE_USER_BY_EMAIL +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: Always associates current social auth user with +# the user with the same email address in the database, which verifies +# that only a single database user is associated with the email. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2020-12-23 +# .. toggle_tickets: https://openedx.atlassian.net/browse/OSPR-5312 +ALWAYS_ASSOCIATE_USER_BY_EMAIL = WaffleSwitch( + f'{WAFFLE_NAMESPACE}.always_associate_user_by_email', + module_name=__name__, +) diff --git a/common/djangoapps/third_party_auth/decorators.py b/common/djangoapps/third_party_auth/decorators.py index b2deff2a2b08..3f79bb4dae1f 100644 --- a/common/djangoapps/third_party_auth/decorators.py +++ b/common/djangoapps/third_party_auth/decorators.py @@ -17,13 +17,13 @@ def xframe_allow_whitelisted(view_func): """ Modifies a view function so that its response has the X-Frame-Options HTTP header - set to 'DENY' if the request HTTP referrer is not from a whitelisted hostname. + set to `settings.X_FRAME_OPTIONS` if the request HTTP referrer is not from a whitelisted hostname. """ def wrapped_view(request, *args, **kwargs): """ Modify the response with the correct X-Frame-Options. """ resp = view_func(request, *args, **kwargs) - x_frame_option = 'DENY' + x_frame_option = settings.X_FRAME_OPTIONS if settings.FEATURES['ENABLE_THIRD_PARTY_AUTH']: referer = request.META.get('HTTP_REFERER') if referer is not None: diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index fd96aa88e749..271494a551b5 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -77,7 +77,7 @@ def B(*args, **kwargs): from django.urls import reverse from social_core.exceptions import AuthException from social_core.pipeline import partial -from social_core.pipeline.social_auth import associate_by_email +from social_core.pipeline.social_auth import associate_by_email as _associate_by_email from social_core.utils import module_member, slugify from common.djangoapps import third_party_auth @@ -87,6 +87,7 @@ def B(*args, **kwargs): from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import accounts from openedx.core.djangoapps.user_authn import cookies as user_authn_cookies +from common.djangoapps.third_party_auth.config.waffle import ALWAYS_ASSOCIATE_USER_BY_EMAIL from common.djangoapps.third_party_auth.utils import user_exists from common.djangoapps.track import segment from common.djangoapps.util.json_request import JsonResponse @@ -712,16 +713,19 @@ def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs) @partial.partial -def associate_by_email_if_login_api(auth_entry, backend, details, user, current_partial=None, *args, **kwargs): +def associate_user_by_email(auth_entry, backend, details, user, *args, current_partial=None, **kwargs): """ This pipeline step associates the current social auth with the user with the same email address in the database. It defers to the social library's associate_by_email implementation, which verifies that only a single database user is associated with the email. - This association is done ONLY if the user entered the pipeline through a LOGIN API. + This association is done ONLY if: + the user entered the pipeline through a LOGIN API. + OR + the `third_party_auth.always_associate_user_by_email` Waffle Switch is Active """ - if auth_entry == AUTH_ENTRY_LOGIN_API: - association_response = associate_by_email(backend, details, user, *args, **kwargs) + if auth_entry == AUTH_ENTRY_LOGIN_API or ALWAYS_ASSOCIATE_USER_BY_EMAIL.is_enabled(): + association_response = _associate_by_email(backend, details, user, *args, **kwargs) if ( association_response and association_response.get('user') and diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index c22da772b733..37fc0ce8c98c 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -53,7 +53,7 @@ def apply_settings(django_settings): 'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.auth_allowed', 'social_core.pipeline.social_auth.social_user', - 'common.djangoapps.third_party_auth.pipeline.associate_by_email_if_login_api', + 'common.djangoapps.third_party_auth.pipeline.associate_user_by_email', 'common.djangoapps.third_party_auth.pipeline.get_username', 'common.djangoapps.third_party_auth.pipeline.set_pipeline_timeout', 'common.djangoapps.third_party_auth.pipeline.ensure_user_information', diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline.py b/common/djangoapps/third_party_auth/tests/test_pipeline.py index 876ae969f91e..2136a6f4cb54 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline.py @@ -5,9 +5,11 @@ import unittest import ddt +from edx_toggles.toggles.testutils import override_waffle_switch import mock from common.djangoapps.third_party_auth import pipeline +from common.djangoapps.third_party_auth.pipeline import ALWAYS_ASSOCIATE_USER_BY_EMAIL from common.djangoapps.third_party_auth.tests import testutil from common.djangoapps.third_party_auth.tests.specs.base import IntegrationTestMixin from common.djangoapps.third_party_auth.tests.specs.test_testshib import SamlIntegrationTestUtilities @@ -99,3 +101,57 @@ def test_get_username_in_pipeline(self, idp_username, expected_username, already mock_uuid.return_value = uuid4 final_username = pipeline.get_username(strategy, details, self.provider.backend_class()) self.assertEqual(expected_username, final_username['username']) + + @ddt.data( + ('login', False), + ('login_api', True), + ) + @ddt.unpack + @mock.patch('common.djangoapps.third_party_auth.pipeline._associate_by_email') + def test_associate_user_by_email_in_pipeline_auth_entry( + self, + auth_entry, + called_expected, + mock_associate_by_email, + ): + """ + Tests associate_user_by_email method of running pipeline + """ + pipeline.associate_user_by_email( + strategy=mock.MagicMock(), + pipeline_index=0, + auth_entry=auth_entry, + backend=self.provider.backend_class(), + details=None, + user=None, + ) + + self.assertEqual(mock_associate_by_email.called, called_expected) + + @ddt.data( + (ALWAYS_ASSOCIATE_USER_BY_EMAIL, True, True), + (ALWAYS_ASSOCIATE_USER_BY_EMAIL, False, False), + ) + @ddt.unpack + @mock.patch('common.djangoapps.third_party_auth.pipeline._associate_by_email') + def test_associate_user_by_email_in_pipeline_waffle_switches( + self, + waffle_switch, + switch_is_active, + called_expected, + mock_associate_by_email, + ): + """ + Tests associate_user_by_email method of running pipeline + """ + with override_waffle_switch(waffle_switch, switch_is_active): + pipeline.associate_user_by_email( + strategy=mock.MagicMock(), + pipeline_index=0, + auth_entry='login', + backend=self.provider.backend_class(), + details=None, + user=None, + ) + + self.assertEqual(mock_associate_by_email.called, called_expected) diff --git a/common/lib/xmodule/xmodule/js/spec/video/completion_spec.js b/common/lib/xmodule/xmodule/js/spec/video/completion_spec.js index c4994caae47c..be78dbf071d3 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/completion_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/completion_spec.js @@ -1,7 +1,7 @@ (function() { 'use strict'; describe('VideoPlayer completion', function() { - var state, oldOTBD, completionAjaxCall; + var state, oldOTBD, completionAjaxCall, time; beforeEach(function() { oldOTBD = window.onTouchBasedDevice; @@ -67,5 +67,35 @@ state.el.trigger('ended'); expect(state.completionHandler.markCompletion).toHaveBeenCalled(); }); + + it('triggers progress', function(done) { + var duration = 0; + jasmine.waitUntil(function() { + duration = state.videoPlayer.duration(); + return duration > 0; + }).then(function() { + spyOn(state.completionHandler, 'computeProgress').and.callThrough(); + spyOn(state.completionHandler, 'triggerProgress').and.callThrough(); + // 4 percents should be equivalent to 0 + time = 4 * duration / 100; + state.el.trigger('timeupdate', time); + expect(state.completionHandler.computeProgress).toHaveBeenCalled(); + expect(state.completionHandler.triggerProgress).toHaveBeenCalled(); + state.completionHandler.computeProgress.calls.reset(); + state.completionHandler.triggerProgress.calls.reset(); + // 8 percents should be equivalent to 5 + time = 8 * duration / 100; + state.el.trigger('timeupdate', time); + expect(state.completionHandler.computeProgress).toHaveBeenCalled(); + expect(state.completionHandler.triggerProgress).toHaveBeenCalled(); + state.completionHandler.computeProgress.calls.reset(); + state.completionHandler.triggerProgress.calls.reset(); + // Another timeupdate in the same 5-range should not trigger "triggerProgress" + time = 9 * duration / 100; + state.el.trigger('timeupdate', time); + expect(state.completionHandler.computeProgress).toHaveBeenCalled(); + expect(state.completionHandler.triggerProgress).not.toHaveBeenCalled(); + }).always(done); + }); }); }).call(this); diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js index b3b19689f59c..ea2c2a968181 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_events_plugin_spec.js @@ -59,6 +59,16 @@ import '../helper.js' expect(state.videoEventsPlugin.emitPlayVideoEvent).toBeTruthy(); }); + it('can emit "progress_video" event', function() { + state.el.trigger('progress', [10]); + expect(Logger.log).toHaveBeenCalledWith('progress_video', { + id: 'id', + code: this.code, + percentage: 10, + duration: this.duration + }); + }); + it('can emit "speed_change_video" event', function() { state.el.trigger('speedchange', ['2.0', '1.0']); expect(Logger.log).toHaveBeenCalledWith('speed_change_video', { @@ -204,6 +214,7 @@ import '../helper.js' skip: plugin.onSkip, speedchange: plugin.onSpeedChange, autoadvancechange: plugin.onAutoAdvanceChange, + progress: plugin.onProgress, 'language_menu:show': plugin.onShowLanguageMenu, 'language_menu:hide': plugin.onHideLanguageMenu, 'transcript:show': plugin.onShowTranscript, diff --git a/common/lib/xmodule/xmodule/js/src/html/edit.js b/common/lib/xmodule/xmodule/js/src/html/edit.js index a86cf21cf811..33accebc07c3 100644 --- a/common/lib/xmodule/xmodule/js/src/html/edit.js +++ b/common/lib/xmodule/xmodule/js/src/html/edit.js @@ -95,7 +95,8 @@ tinyMCE incorrectly decides that the suffix should be "", which means it fails to load files. */ tinyMCE.suffix = ".min"; - this.tiny_mce_textarea = $(".tiny-mce", this.element).tinymce({ + + var tinyMceConfig = { script_url: baseUrl + "js/vendor/tinymce/js/tinymce/tinymce.full.min.js", font_formats: _getFonts(), theme: "modern", @@ -171,7 +172,41 @@ */ init_instance_callback: this.initInstanceCallback, browser_spellcheck: true - }); + }; + + if (typeof process != "undefined" && process.env.JS_ENV_EXTRA_CONFIG) { + var tinyMceAdditionalPlugins = process.env.JS_ENV_EXTRA_CONFIG.TINYMCE_ADDITIONAL_PLUGINS; + // check if we have any additional plugins passed + if (tinyMceAdditionalPlugins) { + // go over each plugin + tinyMceAdditionalPlugins.forEach(function (tinyMcePlugin) { + // check if plugins is not empty (ie there are existing plugins) + if (tinyMceConfig.plugins.trim()) { + tinyMceConfig.plugins += ', '; + } + + // add the plugin to the list of plugins + tinyMceConfig.plugins += tinyMcePlugin.name; + + // check if the plugin should be included in the toolbar + if (tinyMcePlugin.toolbar) { + // check if toolbar is not empty (ie there are already items in the toolbar) + if (tinyMceConfig.toolbar.trim()) { + tinyMceConfig.toolbar += ' | '; + } + + tinyMceConfig.toolbar += tinyMcePlugin.name; + } + + // add the additional settings for each plugin (if there is any) + if (tinyMcePlugin.extra_settings) { + tinyMceConfig[tinyMcePlugin.name] = tinyMcePlugin.extra_settings; + } + }); + } + } + + this.tiny_mce_textarea = $(".tiny-mce", this.element).tinymce(tinyMceConfig); tinymce.addI18n('en', { /* diff --git a/common/lib/xmodule/xmodule/js/src/video/09_completion.js b/common/lib/xmodule/xmodule/js/src/video/09_completion.js index 97378e92ef17..9879a39b8b1b 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_completion.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_completion.js @@ -42,6 +42,7 @@ // the beginning of the video, except for lastSentTime, which refers to a // timestamp in seconds since the Unix epoch. this.lastSentTime = undefined; + this.lastProgressPercentage = undefined; this.isComplete = false; this.completionPercentage = this.state.config.completionPercentage; this.startTime = this.state.config.startTime; @@ -102,7 +103,7 @@ /** Handler to call when a timeupdate event is triggered */ handleTimeUpdate: function(currentTime) { - var duration; + var duration = this.state.videoPlayer.duration(); if (this.isComplete) { return; } @@ -110,6 +111,12 @@ // Throttle attempts to submit in case of network issues return; } + + // Duration may not be available at initialization time + if (duration) { + this.computeProgress(currentTime, duration); + } + if (this.completeAfterTime === undefined) { // Duration is not available at initialization time duration = this.state.videoPlayer.duration(); @@ -126,6 +133,25 @@ } }, + /** Compute current video progression and trigger event if needed */ + computeProgress: function(currentTime, duration) { + // Compute current progress percentage + var currentProgressPercentage = Math.floor(currentTime * 100 / duration / 5) * 5; + // Check if last "lastProgressPercentage" and current percentage are in the same 5-range + var newRange = currentProgressPercentage > this.lastProgressPercentage; + // If no previous "lastProgressPercentage" or different 5-range, trigger the event + if (this.lastProgressPercentage === undefined || newRange) { + this.triggerProgress(Math.floor(currentProgressPercentage)); + // Save the lastProgressPercentage value + this.lastProgressPercentage = currentProgressPercentage; + } + }, + + /** Trigger progress event */ + triggerProgress: function(percentage) { + this.state.el.trigger('progress', [percentage]); + }, + /** Submit completion to the LMS */ markCompletion: function(currentTime) { var self = this; diff --git a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js index 3ed7aba4c19a..f69ff438d883 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_events_plugin.js @@ -15,7 +15,8 @@ return new EventsPlugin(state, i18n, options); } - _.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onEnded', 'onSeek', + // eslint-disable-next-line no-undef + _.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onEnded', 'onSeek', 'onProgress', 'onSpeedChange', 'onAutoAdvanceChange', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip', 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions', 'destroy'); @@ -46,6 +47,7 @@ skip: this.onSkip, speedchange: this.onSpeedChange, autoadvancechange: this.onAutoAdvanceChange, + progress: this.onProgress, 'language_menu:show': this.onShowLanguageMenu, 'language_menu:hide': this.onHideLanguageMenu, 'transcript:show': this.onShowTranscript, @@ -136,6 +138,10 @@ this.log('edx.video.closed_captions.hidden', {current_time: this.getCurrentTime()}); }, + onProgress: function(event, percentage) { + this.log('progress_video', {percentage: percentage}); + }, + getCurrentTime: function() { var player = this.state.videoPlayer; return player ? player.currentTime : 0; diff --git a/common/lib/xmodule/xmodule/static_content.py b/common/lib/xmodule/xmodule/static_content.py index 44d8fa40332c..0fd58fa09a1c 100755 --- a/common/lib/xmodule/xmodule/static_content.py +++ b/common/lib/xmodule/xmodule/static_content.py @@ -98,20 +98,24 @@ def write_descriptor_js(output_root): def _list_descriptors(): """Return a list of all registered XModuleDescriptor classes.""" - return [ - desc for desc in [ + return sorted( + [ desc for (_, desc) in XModuleDescriptor.load_classes() - ] - ] + XBLOCK_CLASSES + ] + XBLOCK_CLASSES, + key=str + ) def _list_modules(): """Return a list of all registered XModule classes.""" - return [ - desc.module_class for desc in [ - desc for (_, desc) in XModuleDescriptor.load_classes() - ] - ] + XBLOCK_CLASSES + return sorted( + [ + desc.module_class for desc in [ + desc for (_, desc) in XModuleDescriptor.load_classes() + ] + ] + XBLOCK_CLASSES, + key=str + ) def _ensure_dir(directory): @@ -156,7 +160,8 @@ def _write_styles(selector, output_root, classes, css_attribute): "@import 'bourbon/bourbon';", "@import 'lms/theme/variables';", ] - for class_, fragment_names in css_imports.items(): + for class_, fragment_names in sorted(css_imports.items()): + fragment_names = sorted(fragment_names) module_styles_lines.append("""{selector}.xmodule_{class_} {{""".format( class_=class_, selector=selector )) @@ -272,7 +277,13 @@ def write_webpack(output_file, module_files, descriptor_files): outfile.write( textwrap.dedent(u"""\ module.exports = {config_json}; - """).format(config_json=json.dumps(config, indent=4)) + """).format( + config_json=json.dumps( + config, + indent=4, + sort_keys=True, + ) + ) ) diff --git a/common/static/js/vendor/tinymce/BUILD_README.txt b/common/static/js/vendor/tinymce/BUILD_README.txt index 9c99f375259a..e5ae62162cc2 100644 --- a/common/static/js/vendor/tinymce/BUILD_README.txt +++ b/common/static/js/vendor/tinymce/BUILD_README.txt @@ -3,9 +3,13 @@ Instructions for creating js/tinymce.full.min.js 1. Ensure that the dependencies (NodeJS, Jake, and other dependencies) are installed. If necessary, install them per the directions on https://github.com/tinymce/tinymce/tree/4.0.20. 2. Unzip edx-platform/vendor_extra/tinymce/JakePackage.zip into this directory (so that Jakefile.js resides in this directory). -3. Run the following command in the tinymce directory: - jake minify bundle[themes:modern,plugins:advlist,anchor,autolink,charmap,code,codemirror,contextmenu,image,insertdatetime,link,lists,media,paste,print,save,searchreplace,table,textcolor,visualblocks] -4. Cleanup by deleting the Unversioned files that were created from unzipping jake_package.zip. +3. Clean install the dependencies that were unzipped + npm ci +4. Run the following command in the tinymce directory: + npx jake clean-js +5. Run the following command in the tinymce directory: + npx jake minify bundle[themes:*,plugins:*] +6. Cleanup by deleting the Unversioned files that were created from unzipping jake_package.zip. Instructions for updating tinymce to a newer version: diff --git a/docs/guides/extension_points.rst b/docs/guides/extension_points.rst index d2fd62beff9c..9b4b5f4e18db 100644 --- a/docs/guides/extension_points.rst +++ b/docs/guides/extension_points.rst @@ -63,6 +63,9 @@ If you want to provide learners with new content experiences within courses, opt * - **External Graders** - Hold, Stable - An external grader is a service that receives learner responses to a problem, processes those responses, and returns feedback and a problem grade to the edX platform. You build and deploy an external grader separately from the edX platform. An external grader is particularly useful for software programming courses where learners are asked to submit complex code. See the `external grader documentation`_ for details. + * - **TinyMCE (Visual Text/HTML Editor) Plugins** + - Trial, Limited + - TinyMCE's functionality can be extended with so-called Plugins. Custom TinyMCE plugins can be particularly useful for serving certain content in courses that isn't available yet; they can also be used to facilitate the educator's work. `You can follow this guide to install and enable custom TinyMCE plugins`_. For a more detailed comparison of content integration options, see `Options for Extending the edX Platform`_ in the *Open edX Developer's Guide*. @@ -72,6 +75,7 @@ For a more detailed comparison of content integration options, see `Options for .. _Options for Extending the edX Platform: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/extending_platform/extending.html .. _custom JavaScript application: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/extending_platform/javascript.html .. _external grader documentation: https://edx.readthedocs.io/projects/open-edx-ca/en/latest/exercises_tools/external_graders.html +.. _You can follow this guide to install and enable custom TinyMCE plugins: extensions/tinymce_plugins.rst diff --git a/docs/guides/extensions/tinymce_plugins.rst b/docs/guides/extensions/tinymce_plugins.rst new file mode 100644 index 000000000000..f73ea5452f72 --- /dev/null +++ b/docs/guides/extensions/tinymce_plugins.rst @@ -0,0 +1,65 @@ +TinyMCE (Visual Text/HTML Editor) Plugins +----------------------------------------- + +The flexibility of the TinyMCE Visual Text and HTML editor makes it possible to configure and extend the editor using different plugins. In order to make use of that modularity in Studio, you'll need to follow two different steps. + +Installing Plugins +================== + +Initially, we'll need to specify which plugins need to install so that they can be bundled with the static assets. + +There's a decent `guide on installing the plugins through the edX configuration`_, specifically using the ``TINYMCE_ADDITIONAL_PLUGINS_LIST`` configuration variable. + +Enabling Plugins +================ + +Enabling the plugins requires adding a Studio environment setting which the JavaScript code can access, ``JS_ENV_EXTRA_CONFIG``. It is simply a dictionary which would contain different extra JavaScript configurations. + +The extra JavaScript configuration that's responsible for enabling TinyMCE plugins is ``TINYMCE_ADDITIONAL_PLUGINS``. This is a list of different TinyMCE plugins which you would want to enable. + +Each TinyMCE plugin has the following attributes. + +.. list-table:: + :header-rows: 1 + :widths: 15 10 75 + + * - attribute + - type + - description + * - ``name`` + - string + - The name of the TinyMCE plugin which would be included in the editor's list of plugins. + * - ``toolbar`` + - boolean + - Indicates whether this plugin should be displayed in the toolbar or not. + * - ``extra_settings`` + - object + - Specifies the extra plugin settings that need to be added to the TinyMCE editor's configuration. + +Here's an example: + +.. code:: yaml + + EDXAPP_CMS_ENV_EXTRA: + JS_ENV_EXTRA_CONFIG: + TINYMCE_ADDITIONAL_PLUGINS: + - name: adsklink + toolbar: true + extra_settings: + linktypes: + - Download + - Offer + filetypes: + - PDF + - ZIP + - Video + - Design + orientations: + - Vertical + - Horizontal + styles: + - Primary + - Normal + - Secondary + +.. _guide on installing the plugins through the edX configuration: https://github.com/edx/configuration/blob/master/playbooks/roles/tinymce_plugins/README.rst diff --git a/lms/djangoapps/course_api/serializers.py b/lms/djangoapps/course_api/serializers.py index d2e28184ae51..1f5a8ab4c6a7 100644 --- a/lms/djangoapps/course_api/serializers.py +++ b/lms/djangoapps/course_api/serializers.py @@ -11,6 +11,7 @@ from rest_framework import serializers from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.fields import AbsoluteURLField @@ -32,6 +33,40 @@ def get_uri(self, course_overview): return getattr(course_overview, self.uri_attribute) +class _AbsolutMediaSerializer(_MediaSerializer): # pylint: disable=abstract-method + """ + Nested serializer to represent a media object and its absolute path. + """ + requires_context = True + + def __call__(self, serializer_field): + self.context = serializer_field.context + return super(self).__call__(serializer_field) + + uri_absolute = serializers.SerializerMethodField(source="*") + + def get_uri_absolute(self, course_overview): + """ + Convert the media resource's URI to an absolute URI. + """ + uri = getattr(course_overview, self.uri_attribute) + + if not uri: + # Return empty string here, to keep the same + # response type in case uri is empty as well. + return "" + + cdn_applied_uri = course_overview.apply_cdn_to_url(uri) + field = AbsoluteURLField() + + # In order to use the AbsoluteURLField to have the same + # behaviour what ImageSerializer provides, we need to set + # the request for the field + field._context = {"request": self.context.get("request")} + + return field.to_representation(cdn_applied_uri) + + class ImageSerializer(serializers.Serializer): # pylint: disable=abstract-method """ Collection of URLs pointing to images of various sizes. @@ -48,6 +83,7 @@ class _CourseApiMediaCollectionSerializer(serializers.Serializer): # pylint: di """ Nested serializer to represent a collection of media objects """ + banner_image = _AbsolutMediaSerializer(source='*', uri_attribute='banner_image_url') course_image = _MediaSerializer(source='*', uri_attribute='course_image_url') course_video = _MediaSerializer(source='*', uri_attribute='course_video_url') image = ImageSerializer(source='image_urls') diff --git a/lms/djangoapps/course_api/tests/test_serializers.py b/lms/djangoapps/course_api/tests/test_serializers.py index 587c42043d6f..d6ea499998ea 100644 --- a/lms/djangoapps/course_api/tests/test_serializers.py +++ b/lms/djangoapps/course_api/tests/test_serializers.py @@ -39,15 +39,22 @@ def setUp(self): self.honor_user = self.create_user('honor', is_staff=False) self.request_factory = APIRequestFactory() + course_id = u'edX/toy/2012_Fall' + banner_image_uri = u'/c4x/edX/toy/asset/images_course_image.jpg' + banner_image_absolute_uri = u'http://testserver' + banner_image_uri image_path = u'/c4x/edX/toy/asset/just_a_test.jpg' image_url = u'http://testserver' + image_path self.expected_data = { - 'id': u'edX/toy/2012_Fall', + 'id': course_id, 'name': u'Toy Course', 'number': u'toy', 'org': u'edX', 'short_description': u'A course about toys.', 'media': { + 'banner_image': { + 'uri': banner_image_uri, + 'uri_absolute': banner_image_absolute_uri, + }, 'course_image': { 'uri': image_path, }, @@ -74,7 +81,7 @@ def setUp(self): 'invitation_only': False, # 'course_id' is a deprecated field, please use 'id' instead. - 'course_id': u'edX/toy/2012_Fall', + 'course_id': course_id, } def _get_request(self, user=None): diff --git a/lms/djangoapps/gdpr_user_retirement/__init__.py b/lms/djangoapps/gdpr_user_retirement/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/gdpr_user_retirement/tests/__init__.py b/lms/djangoapps/gdpr_user_retirement/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/gdpr_user_retirement/tests/test_views.py b/lms/djangoapps/gdpr_user_retirement/tests/test_views.py new file mode 100644 index 000000000000..6ec11a4863bb --- /dev/null +++ b/lms/djangoapps/gdpr_user_retirement/tests/test_views.py @@ -0,0 +1,84 @@ +""" +Test cases for GDPR User Retirement Views +""" +from django.urls import reverse +from rest_framework.test import APIClient, APITestCase +from openedx.core.djangoapps.user_api.models import RetirementState, UserRetirementStatus +from student.tests.factories import UserFactory + + +class GDPRUserRetirementViewTests(APITestCase): + """ + Tests the GDPR user retirement api + """ + def setUp(self): + super().setUp() + self.client = APIClient() + self.user1 = UserFactory.create( + username='testuser1', + email='test1@example.com', + password='test1_password', + profile__name="Test User1" + ) + self.client.login(username=self.user1.username, password='test1_password') + self.user2 = UserFactory.create( + username='testuser2', + email='test2@example.com', + password='test2_password', + profile__name="Test User2" + ) + self.client.login(username=self.user2.username, password='test2_password') + self.user3 = UserFactory.create( + username='testuser3', + email='test3@example.com', + password='test3_password', + profile__name="Test User3" + ) + self.user4 = UserFactory.create( + username='testuser4', + email='test4@example.com', + password='test4_password', + profile__name="Test User4" + ) + RetirementState.objects.create( + state_name='PENDING', + state_execution_order=1, + is_dead_end_state=False, + required=True + ) + self.pending_state = RetirementState.objects.get(state_name='PENDING') + self.client.force_authenticate(user=self.user1) + + def test_gdpr_user_retirement_api(self): + user_retirement_url = reverse('gdpr_retirement_api') + with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username): + response = self.client.post(user_retirement_url, {"usernames": self.user2.username}) + assert response.status_code == 204 + + retirement_status = UserRetirementStatus.objects.get(user__username=self.user2.username) + assert retirement_status.current_state == self.pending_state + + def test_retirement_for_non_existing_users(self): + user_retirement_url = reverse('gdpr_retirement_api') + with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username): + response = self.client.post(user_retirement_url, {"usernames": "non_existing_user"}) + assert response.status_code == 404 + + def test_retirement_for_multiple_users(self): + user_retirement_url = reverse('gdpr_retirement_api') + with self.settings(RETIREMENT_SERVICE_WORKER_USERNAME=self.user1.username): + response = self.client.post(user_retirement_url, { + "usernames": '{user1},{user2}'.format(user1=self.user3.username, user2=self.user4.username) + }) + assert response.status_code == 204 + + retirement_status_1 = UserRetirementStatus.objects.get(user__username=self.user3.username) + assert retirement_status_1.current_state == self.pending_state + + retirement_status_2 = UserRetirementStatus.objects.get(user__username=self.user4.username) + assert retirement_status_2.current_state == self.pending_state + + def test_retirement_for_unauthorized_users(self): + user_retirement_url = reverse('gdpr_retirement_api') + response = self.client.post(user_retirement_url, {"usernames": self.user2.username}) + assert response.status_code == 403 diff --git a/lms/djangoapps/gdpr_user_retirement/urls.py b/lms/djangoapps/gdpr_user_retirement/urls.py new file mode 100644 index 000000000000..f632e5aa9d8a --- /dev/null +++ b/lms/djangoapps/gdpr_user_retirement/urls.py @@ -0,0 +1,16 @@ +""" +Defines the URL route for this app. +""" + +from django.conf.urls import url + +from .views import GDPRUsersRetirementView + + +urlpatterns = [ + url( + r'v1/accounts/gdpr_retire_users$', + GDPRUsersRetirementView.as_view(), + name='gdpr_retirement_api' + ), +] diff --git a/lms/djangoapps/gdpr_user_retirement/views.py b/lms/djangoapps/gdpr_user_retirement/views.py new file mode 100644 index 000000000000..6dbf77474200 --- /dev/null +++ b/lms/djangoapps/gdpr_user_retirement/views.py @@ -0,0 +1,82 @@ +""" +An API for retiring user accounts. +""" +import logging + +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView +from social_django.models import UserSocialAuth +from student.models import AccountRecovery, Registration, get_retired_email_by_email +from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models +from openedx.core.djangoapps.user_api.models import UserRetirementStatus +from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser + +log = logging.getLogger(__name__) + + +class GDPRUsersRetirementView(APIView): + """ + **Use Case** + + Implementation for GDPR User Retirement API. Creates a retirement request + for one or more users. + + **Example Request** + + POST /v1/accounts/gdpr_retire_users { + "usernames": "test_user1, test_user2" + } + + **POST Parameters** + + A POST request can include the following parameter. + + * usernames: Comma separated strings of usernames that should be retired. + """ + authentication_classes = (JwtAuthentication, ) + permission_classes = (permissions.IsAuthenticated, CanRetireUser) + + def post(self, request, **kwargs): # pylint: disable=unused-argument + """ + Initiates the GDPR retirement process for the given users. + """ + request_usernames = request.data.get('usernames') + if request_usernames: + usernames_to_retire = [each_username.strip() for each_username in request_usernames.split(',')] + else: + usernames_to_retire = [] + User = get_user_model() + for username in usernames_to_retire: + try: + user_to_retire = User.objects.get(username=username) + with transaction.atomic(): + # Add user to retirement queue. + UserRetirementStatus.create_retirement(user_to_retire) + # Unlink LMS social auth accounts + UserSocialAuth.objects.filter(user_id=request.user.id).delete() + # Change LMS password & email + user_to_retire.email = get_retired_email_by_email(user_to_retire.email) + user_to_retire.set_unusable_password() + user_to_retire.save() + + # Remove the activation keys sent by email to the user for account activation. + Registration.objects.filter(user=user_to_retire).delete() + + # Delete OAuth tokens associated with the user. + retire_dot_oauth2_models(user_to_retire) + AccountRecovery.retire_recovery_email(request.user.id) + + except User.DoesNotExist: + log.exception('The user "{}" does not exist.'.format(username)) + return Response( + u'The user "{}" does not exist.'.format(username), status=status.HTTP_404_NOT_FOUND + ) + except Exception as exc: # pylint: disable=broad-except + log.exception('500 error retiring account {}'.format(exc)) + return Response(str(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py index c94098844e73..ea60ff44c533 100644 --- a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py @@ -279,6 +279,7 @@ def get(self, request, course_key): with self.get_course(request, course_key) as course: results = { + 'grade_cutoffs': course.grade_cutoffs, 'assignment_types': self._get_assignment_types(course), 'subsections': self._get_subsections(course, graded_only), 'grades_frozen': are_grades_frozen(course_key), diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py index d1cfe7e025ce..672b66790cb7 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/test_gradebook_views.py @@ -68,6 +68,9 @@ def initialize_course(cls, course): """ Sets up the structure of the test course. """ + course.grade_cutoffs = { + "Pass": 0.5, + } cls.section = ItemFactory.create( parent_location=course.location, category="chapter", @@ -135,6 +138,9 @@ def get_url(self, course_id): def _get_expected_data(self): return { + "grade_cutoffs": { + "Pass": 0.5, + }, 'assignment_types': { 'Final Exam': { 'drop_count': 0, diff --git a/lms/envs/common.py b/lms/envs/common.py index cd471f4443f1..0e470507ec25 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2743,6 +2743,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx.core.djangoapps.content.learning_sequences.apps.LearningSequencesConfig', 'ratelimitbackend', + + # GDPR user retirement + 'lms.djangoapps.gdpr_user_retirement', ] ######################### CSRF ######################################### diff --git a/lms/urls.py b/lms/urls.py index 085e604ad7fc..120b873133a1 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -991,3 +991,8 @@ urlpatterns += [ url(r'^api/course_experience/', include('openedx.features.course_experience.api.v1.urls')), ] + +# GDPR Deletion API urls +urlpatterns += [ + url(r'', include('lms.djangoapps.gdpr_user_retirement.urls')), +] diff --git a/openedx/core/djangoapps/content/course_overviews/migrations/0023_courseoverview_banner_image_url.py b/openedx/core/djangoapps/content/course_overviews/migrations/0023_courseoverview_banner_image_url.py new file mode 100644 index 000000000000..1862eb090a20 --- /dev/null +++ b/openedx/core/djangoapps/content/course_overviews/migrations/0023_courseoverview_banner_image_url.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.16 on 2020-09-22 12:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_overviews', '0022_courseoverviewtab_is_hidden'), + ] + + operations = [ + migrations.AddField( + model_name='courseoverview', + name='banner_image_url', + field=models.TextField(), + ), + migrations.AddField( + model_name='historicalcourseoverview', + name='banner_image_url', + field=models.TextField(), + ), + ] diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index 5c0f4323b4de..416c86ed6e9b 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -63,7 +63,7 @@ class Meta(object): app_label = 'course_overviews' # IMPORTANT: Bump this whenever you modify this model and/or add a migration. - VERSION = 11 # this one goes to eleven + VERSION = 12 # this one goes to thirteen # Cache entry versioning. version = IntegerField() @@ -86,6 +86,8 @@ class Meta(object): announcement = DateTimeField(null=True) # URLs + # Not allowing null per django convention; not sure why many TextFields in this model do allow null + banner_image_url = TextField() course_image_url = TextField() social_sharing_url = TextField(null=True) end_of_course_survey_url = TextField(null=True) @@ -196,6 +198,7 @@ def _create_or_update(cls, course): course_overview.advertised_start = course.advertised_start course_overview.announcement = course.announcement + course_overview.banner_image_url = course_image_url(course, 'banner_image') course_overview.course_image_url = course_image_url(course) course_overview.social_sharing_url = course.social_sharing_url @@ -728,6 +731,22 @@ def closest_released_language(self): """ return get_closest_released_language(self.language) if self.language else None + def apply_cdn_to_url(self, image_url): + """ + Applies a new CDN/base URL to the given URLs if CDN configuration is + enabled. + + If CDN does not exist or is disabled, just returns the original. The + URL that we store in CourseOverviewImageSet is already top level path, + so we don't need to go through the /static remapping magic that happens + with other course assets. We just need to add the CDN server if appropriate. + """ + cdn_config = AssetBaseUrlConfig.current() + if not cdn_config.enabled: + return image_url + + return self._apply_cdn_to_url(image_url, cdn_config.base_url) + def apply_cdn_to_urls(self, image_urls): """ Given a dict of resolutions -> urls, return a copy with CDN applied. @@ -738,14 +757,8 @@ def apply_cdn_to_urls(self, image_urls): happens with other course assets. We just need to add the CDN server if appropriate. """ - cdn_config = AssetBaseUrlConfig.current() - if not cdn_config.enabled: - return image_urls - - base_url = cdn_config.base_url - return { - resolution: self._apply_cdn_to_url(url, base_url) + resolution: self.apply_cdn_to_url(url) for resolution, url in image_urls.items() } diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py index 5b140cc65b7b..6db935d35d7f 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py @@ -382,7 +382,7 @@ def test_malformed_grading_policy(self): course_overview = CourseOverview._create_or_update(course) # pylint: disable=protected-access self.assertEqual(course_overview.lowest_passing_grade, None) - @ddt.data((ModuleStoreEnum.Type.mongo, 4, 4), (ModuleStoreEnum.Type.split, 3, 4)) + @ddt.data((ModuleStoreEnum.Type.mongo, 4, 4), (ModuleStoreEnum.Type.split, 3, 3)) @ddt.unpack def test_versioning(self, modulestore_type, min_mongo_calls, max_mongo_calls): """ @@ -789,6 +789,28 @@ def test_cdn_with_external_image(self, modulestore_type): self.assertTrue(modified_urls['small'].startswith(expected_cdn_url)) self.assertEqual(modified_urls['large'], start_urls['large']) + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) + def test_cdn_with_a_single_external_image(self, modulestore_type): + """ + Test CDN is applied for a URL when apply_cdn_to_url called directly. + + Apply CDN/base URL to the given URL if CDN configuration is enabled + and the URL is not absolute. + """ + with self.store.default_store(modulestore_type): + course = CourseFactory.create(default_store=modulestore_type) + overview = CourseOverview.get_from_id(course.id) + + # Now enable the CDN... + AssetBaseUrlConfig.objects.create(enabled=True, base_url='fakecdn.edx.org') + expected_cdn_url = "//fakecdn.edx.org" + + start_url = "/static/overview.png" + modified_url = overview.apply_cdn_to_url(start_url) + + self.assertNotEqual(start_url, modified_url) + self.assertTrue(modified_url.startswith(expected_cdn_url)) + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_error_generating_thumbnails(self, modulestore_type): """ diff --git a/openedx/core/djangoapps/theming/storage.py b/openedx/core/djangoapps/theming/storage.py index 7d560819489a..61254c9d7952 100644 --- a/openedx/core/djangoapps/theming/storage.py +++ b/openedx/core/djangoapps/theming/storage.py @@ -10,7 +10,7 @@ from django.conf import settings from django.contrib.staticfiles.finders import find -from django.contrib.staticfiles.storage import CachedFilesMixin, StaticFilesStorage +from django.contrib.staticfiles.storage import ManifestFilesMixin, StaticFilesStorage from django.utils._os import safe_join from django.utils.six.moves.urllib.parse import ( # pylint: disable=no-name-in-module, import-error unquote, @@ -110,10 +110,10 @@ class ThemeStorage(ThemeMixin, StaticFilesStorage): pass -class ThemeCachedFilesMixin(CachedFilesMixin): +class ThemeManifestFilesMixin(ManifestFilesMixin): """ - Comprehensive theme aware CachedFilesMixin. - Main purpose of subclassing CachedFilesMixin is to override the following methods. + Comprehensive theme aware ManifestFilesMixin. + Main purpose of subclassing ManifestFilesMixin is to override the following methods. 1 - _url 2 - url_converter @@ -177,11 +177,11 @@ def _url(self, hashed_name_func, name, force=False, hashed_files=None): See the class docstring for more info. """ processed_asset_name = self._processed_asset_name(name) - return super(ThemeCachedFilesMixin, self)._url(hashed_name_func, processed_asset_name, force, hashed_files) + return super()._url(hashed_name_func, processed_asset_name, force, hashed_files) def url_converter(self, name, hashed_files, template=None): """ - This is an override of url_converter from CachedFilesMixin. + This is an override of url_converter from ManifestFilesMixin. It changes one line near the end of the method (see the NOTE) in order to return absolute urls instead of relative urls. This behavior is necessary for theme overrides, as we get 404 on assets with relative diff --git a/openedx/core/djangoapps/util/management/commands/print_setting.py b/openedx/core/djangoapps/util/management/commands/print_setting.py index 523a303874f5..7d33e0cf60c8 100644 --- a/openedx/core/djangoapps/util/management/commands/print_setting.py +++ b/openedx/core/djangoapps/util/management/commands/print_setting.py @@ -11,6 +11,8 @@ """ +import json + from django.conf import settings from django.core.management.base import BaseCommand, CommandError @@ -27,10 +29,23 @@ def add_arguments(self, parser): help='Specifies the list of settings to be printed.' ) + parser.add_argument( + '--json', + action='store_true', + help='Returns setting as JSON string instead.', + ) + def handle(self, *args, **options): settings_to_print = options.get('settings_to_print') + dump_as_json = options.get('json') for setting in settings_to_print: if not hasattr(settings, setting): raise CommandError('%s not found in settings.' % setting) - print(getattr(settings, setting)) + + setting_value = getattr(settings, setting) + + if dump_as_json: + setting_value = json.dumps(setting_value, sort_keys=True) + + print(setting_value) diff --git a/openedx/core/storage.py b/openedx/core/storage.py index fefad2ee4a31..b5b61698e2ff 100644 --- a/openedx/core/storage.py +++ b/openedx/core/storage.py @@ -12,7 +12,7 @@ from require.storage import OptimizedFilesMixin from storages.backends.s3boto3 import S3Boto3Storage -from openedx.core.djangoapps.theming.storage import ThemeCachedFilesMixin, ThemePipelineMixin, ThemeMixin +from openedx.core.djangoapps.theming.storage import ThemeManifestFilesMixin, ThemePipelineMixin, ThemeMixin class PipelineForgivingMixin(object): @@ -44,7 +44,7 @@ class ProductionMixin( PipelineForgivingMixin, OptimizedFilesMixin, ThemePipelineMixin, - ThemeCachedFilesMixin, + ThemeManifestFilesMixin, ThemeMixin, ): """ diff --git a/pavelib/assets.py b/pavelib/assets.py index 4014c4f2a288..9d2e3f12f1cc 100644 --- a/pavelib/assets.py +++ b/pavelib/assets.py @@ -5,6 +5,7 @@ import argparse import glob +import json import os import traceback from datetime import datetime @@ -778,10 +779,16 @@ def webpack(options): result = Env.get_django_settings(['STATIC_ROOT', 'WEBPACK_CONFIG_PATH'], "lms", settings=settings) static_root_lms, config_path = result static_root_cms, = Env.get_django_settings(["STATIC_ROOT"], "cms", settings=settings) - environment = 'NODE_ENV={node_env} STATIC_ROOT_LMS={static_root_lms} STATIC_ROOT_CMS={static_root_cms}'.format( + js_env_extra_config_setting, = Env.get_django_json_settings(["JS_ENV_EXTRA_CONFIG"], "cms", settings=settings) + js_env_extra_config = json.dumps(js_env_extra_config_setting or "{}") + environment = ( + "NODE_ENV={node_env} STATIC_ROOT_LMS={static_root_lms} STATIC_ROOT_CMS={static_root_cms} " + "JS_ENV_EXTRA_CONFIG={js_env_extra_config}" + ).format( node_env="development" if config_path == 'webpack.dev.config.js' else "production", static_root_lms=static_root_lms, - static_root_cms=static_root_cms + static_root_cms=static_root_cms, + js_env_extra_config=js_env_extra_config, ) sh( cmd( diff --git a/pavelib/paver_tests/test_servers.py b/pavelib/paver_tests/test_servers.py index 3bbff733e310..ae6e96154737 100644 --- a/pavelib/paver_tests/test_servers.py +++ b/pavelib/paver_tests/test_servers.py @@ -1,6 +1,8 @@ """Unit tests for the Paver server tasks.""" +import json + import ddt from paver.easy import call_task @@ -45,10 +47,12 @@ ) EXPECTED_PRINT_SETTINGS_COMMAND = [ "python manage.py lms --settings={settings} print_setting STATIC_ROOT WEBPACK_CONFIG_PATH 2>{log_file}", - "python manage.py cms --settings={settings} print_setting STATIC_ROOT 2>{log_file}" + "python manage.py cms --settings={settings} print_setting STATIC_ROOT 2>{log_file}", + "python manage.py cms --settings={settings} print_setting JS_ENV_EXTRA_CONFIG 2>{log_file} --json", ] EXPECTED_WEBPACK_COMMAND = ( "NODE_ENV={node_env} STATIC_ROOT_LMS={static_root_lms} STATIC_ROOT_CMS={static_root_cms} " + "JS_ENV_EXTRA_CONFIG={js_env_extra_config} " "$(npm bin)/webpack --config={webpack_config_path}" ) @@ -251,6 +255,7 @@ def verify_server_task(self, task_name, options, contracts_default=False): node_env="production", static_root_lms=None, static_root_cms=None, + js_env_extra_config=json.dumps("{}"), webpack_config_path=None )) expected_messages.extend(self.expected_sass_commands(system=system, asset_settings=expected_asset_settings)) @@ -297,6 +302,7 @@ def verify_run_all_servers_task(self, options): node_env="production", static_root_lms=None, static_root_cms=None, + js_env_extra_config=json.dumps("{}"), webpack_config_path=None )) expected_messages.extend(self.expected_sass_commands(asset_settings=expected_asset_settings)) diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py index 5baae419d60e..c295fd43d672 100644 --- a/pavelib/utils/envs.py +++ b/pavelib/utils/envs.py @@ -236,12 +236,13 @@ class Env: SERVICE_VARIANT = 'lms' @classmethod - def get_django_settings(cls, django_settings, system, settings=None): + def get_django_settings(cls, django_settings, system, settings=None, print_setting_args=None): """ Interrogate Django environment for specific settings values :param django_settings: list of django settings values to get :param system: the django app to use when asking for the setting (lms | cms) :param settings: the settings file to use when asking for the value + :param print_setting_args: the additional arguments to send to print_settings :return: unicode value of the django setting """ if not settings: @@ -251,15 +252,17 @@ def get_django_settings(cls, django_settings, system, settings=None): os.makedirs(log_dir) settings_length = len(django_settings) django_settings = ' '.join(django_settings) # parse_known_args makes a list again + print_setting_args = ' '.join(print_setting_args or []) try: value = sh( django_cmd( system, settings, - "print_setting {django_settings} 2>{log_file}".format( + "print_setting {django_settings} 2>{log_file} {print_setting_args}".format( django_settings=django_settings, + print_setting_args=print_setting_args, log_file=cls.PRINT_SETTINGS_LOG_FILE - ) + ).strip() ), capture=True ) @@ -271,6 +274,22 @@ def get_django_settings(cls, django_settings, system, settings=None): print(f.read()) sys.exit(1) + @classmethod + def get_django_json_settings(cls, django_settings, system, settings=None): + """ + Interrogate Django environment for specific settings value + :param django_settings: list of django settings values to get + :param system: the django app to use when asking for the setting (lms | cms) + :param settings: the settings file to use when asking for the value + :return: json string value of the django setting + """ + return cls.get_django_settings( + django_settings, + system, + settings=settings, + print_setting_args=["--json"], + ) + @classmethod def covered_modules(cls): """ diff --git a/vendor_extra/tinymce/JakePackage.zip b/vendor_extra/tinymce/JakePackage.zip index af9cd3b4e769..ae3b4e917787 100644 Binary files a/vendor_extra/tinymce/JakePackage.zip and b/vendor_extra/tinymce/JakePackage.zip differ diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 68906fcab1d3..3987e82fdf7d 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -20,7 +20,8 @@ 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.JS_ENV_EXTRA_CONFIG': process.env.JS_ENV_EXTRA_CONFIG }) ], module: { diff --git a/webpack.prod.config.js b/webpack.prod.config.js index 360ab56d4d01..dc85e4efae3b 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -17,7 +17,8 @@ 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.JS_ENV_EXTRA_CONFIG': process.env.JS_ENV_EXTRA_CONFIG }), new webpack.LoaderOptionsPlugin({ // This may not be needed; legacy option for loaders written for webpack 1 minimize: true