From d26e7d93c9e3e195b7ccdb06167dd5192693eac3 Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 16 Apr 2025 10:59:50 +0500 Subject: [PATCH 01/23] chore: Initial setup of JS files and webpack config --- webpack.common.config.js | 1 + xmodule/assets/video/public/js/video_block_main.js | 9 +++++++++ xmodule/assets/video/public/js/video_storage.js | 5 +++++ xmodule/video_block/video_block.py | 4 ++-- 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 xmodule/assets/video/public/js/video_block_main.js create mode 100644 xmodule/assets/video/public/js/video_storage.js diff --git a/webpack.common.config.js b/webpack.common.config.js index 4eea3b5da9b6..06efdb5c6151 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -140,6 +140,7 @@ module.exports = Merge.smart({ ReactRenderer: './common/static/js/src/ReactRenderer.jsx', XModuleShim: './xmodule/js/src/xmodule.js', VerticalStudentView: './xmodule/assets/vertical/public/js/vertical_student_view.js', + VideoBlockMain: './xmodule/assets/video/public/js/video_block_main.js', commons: 'babel-polyfill' }, diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js new file mode 100644 index 000000000000..53c212d4e3c2 --- /dev/null +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -0,0 +1,9 @@ +import {test} from './video_storage'; + +console.log('In video_block_main.js file'); + +window.Video = function (runtime, element) { + 'use strict'; + console.log('In Video initialize method'); + test(); +} diff --git a/xmodule/assets/video/public/js/video_storage.js b/xmodule/assets/video/public/js/video_storage.js new file mode 100644 index 000000000000..38e4d2c812fd --- /dev/null +++ b/xmodule/assets/video/public/js/video_storage.js @@ -0,0 +1,5 @@ +console.log('In video_storage.js file'); + +export function test() { + console.log('In test'); +} diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index b0e01068fbf3..73ed4579ab83 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -246,8 +246,8 @@ def student_view(self, context): """ fragment = Fragment(self.get_html(context=context)) add_css_to_fragment(fragment, 'VideoBlockDisplay.css') - add_webpack_js_to_fragment(fragment, 'VideoBlockDisplay') - shim_xmodule_js(fragment, 'Video') + add_webpack_js_to_fragment(fragment, 'VideoBlockMain') + fragment.initialize_js('Video') return fragment def author_view(self, context): From 99e118caecfef45a9812f6f2322814ef1a276b0d Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 16 Apr 2025 16:08:25 +0500 Subject: [PATCH 02/23] chore: Add more refactored code in the main.js --- .../video/public/js/video_block_main.js | 183 +++++++++++++++++- .../assets/video/public/js/video_storage.js | 78 +++++++- 2 files changed, 253 insertions(+), 8 deletions(-) diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index 53c212d4e3c2..ba711287b8a0 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -1,9 +1,180 @@ -import {test} from './video_storage'; +// Import required modules and dependencies +import {VideoStorage} from './video_storage'; +// TODO: Uncomment the imports +// import { initialize } from './initialize'; // Assuming this function is imported +// import { +// FocusGrabber, +// VideoControl, +// VideoPlayPlaceholder, +// VideoPlayPauseControl, +// VideoProgressSlider, +// VideoSpeedControl, +// VideoVolumeControl, +// VideoQualityControl, +// VideoFullScreen, +// VideoCaption, +// VideoCommands, +// VideoContextMenu, +// VideoSaveStatePlugin, +// VideoEventsPlugin, +// VideoCompletionHandler, +// VideoTranscriptFeedback, +// VideoAutoAdvanceControl, +// VideoPlaySkipControl, +// VideoSkipControl, +// VideoEventsBumperPlugin, +// VideoPoster, +// VideoSocialSharing, +// VideoAccessibleMenu, +// VideoBumper, +// } from './video_modules'; // Assuming all necessary modules are grouped here + +'use strict'; console.log('In video_block_main.js file'); -window.Video = function (runtime, element) { - 'use strict'; - console.log('In Video initialize method'); - test(); -} +(function (require, $) { + // TODO: Following code needs to be reviewed, why we are not getting $ + if (!$) { + $ = window.jQuery; + } + var youtubeXhr = null; + var oldVideo = window.Video; + + window.Video = function (runtime, element) { + console.log('In Video initialize method'); + + const el = $(element).find('.video'); + const id = el.attr('id').replace(/video_/, ''); + const storage = new VideoStorage('VideoState', id); + const bumperMetadata = el.data('bumper-metadata'); + const autoAdvanceEnabled = el.data('autoadvance-enabled') === 'True'; + + const mainVideoModules = [] + // TODO: Uncomment the code + // const mainVideoModules = [ + // FocusGrabber, + // VideoControl, + // VideoPlayPlaceholder, + // VideoPlayPauseControl, + // VideoProgressSlider, + // VideoSpeedControl, + // VideoVolumeControl, + // VideoQualityControl, + // VideoFullScreen, + // VideoCaption, + // VideoCommands, + // VideoContextMenu, + // VideoSaveStatePlugin, + // VideoEventsPlugin, + // VideoCompletionHandler, + // VideoTranscriptFeedback, + // ].concat(autoAdvanceEnabled ? [VideoAutoAdvanceControl] : []); + + const bumperVideoModules = [ + // VideoControl, + // VideoPlaySkipControl, + // VideoSkipControl, + // VideoVolumeControl, + // VideoCaption, + // VideoCommands, + // VideoSaveStatePlugin, + // VideoTranscriptFeedback, + // VideoEventsBumperPlugin, + // VideoCompletionHandler, + ]; + + const state = { + el: el, + id: id, + metadata: el.data('metadata'), + storage: storage, + options: {}, + youtubeXhr: youtubeXhr, + modules: mainVideoModules, + }; + + const getBumperState = (metadata) => { + return $.extend(true, { + el: el, + id: id, + storage: storage, + options: {SaveStatePlugin: {events: ['language_menu:change']}}, + youtubeXhr: youtubeXhr, + modules: bumperVideoModules, + }, {metadata: metadata}); + }; + + const player = (innerState) => { + return () => { + _.extend(innerState.metadata, {autoplay: true, focusFirstControl: true}); + // TODO: Uncomment following initialize method calling + // initialize(innerState, element); + }; + }; + + // VideoAccessibleMenu(el, { + // storage: storage, + // saveStateUrl: state.metadata.saveStateUrl, + // }); + + // VideoSocialSharing(el); + + if (bumperMetadata) { + // VideoPoster(el, { + // poster: el.data('poster'), + // onClick: _.once(function () { + // const mainVideoPlayer = player(state); + // + // if (storage.getItem('isBumperShown')) { + // mainVideoPlayer(); + // } else { + // const bumperState = getBumperState(bumperMetadata); + // const bumper = new VideoBumper(player(bumperState), bumperState); + // + // state.bumperState = bumperState; + // + // bumper.getPromise().then(() => { + // delete state.bumperState; + // mainVideoPlayer(); + // }); + // } + // }), + // }); + } else { + // TODO: Uncomment following initialize method calling + // initialize(state, element); + } + + if (!youtubeXhr) { + youtubeXhr = state.youtubeXhr; + } + + el.data('video-player-state', state); + const onSequenceChange = () => { + if (state && state.videoPlayer) { + state.videoPlayer.destroy(); + } + $('.sequence').off('sequence:change', onSequenceChange); + }; + $('.sequence').on('sequence:change', onSequenceChange); + + // Because the 'state' object is only available inside this closure, we will also make it available to + // the caller by returning it. This is necessary so that we can test Video with Jasmine. + return state; + }; + + window.Video.clearYoutubeXhr = function () { + youtubeXhr = null; + }; + + // TODO: Uncomment following initialize related code + // window.Video.loadYouTubeIFrameAPI = initialize.prototype.loadYouTubeIFrameAPI; + + // Invoke the mock Video constructor so that the elements stored within it can be processed by the real + // `window.Video` constructor. + // TODO: Un comment following + // oldVideo(null, true); + +}()); + diff --git a/xmodule/assets/video/public/js/video_storage.js b/xmodule/assets/video/public/js/video_storage.js index 38e4d2c812fd..5262041b8778 100644 --- a/xmodule/assets/video/public/js/video_storage.js +++ b/xmodule/assets/video/public/js/video_storage.js @@ -1,5 +1,79 @@ +'use strict'; + console.log('In video_storage.js file'); -export function test() { - console.log('In test'); +/** + * Provides a convenient way to store key-value pairs. + * + * @param {string} namespace Namespace that is used to store data. + * @param {string} id Identifier for instance-specific storage. + * @return {object} VideoStorage API. + */ +export function VideoStorage(namespace = 'VideoStorage', id = Math.random().toString(36).slice(2)) { + /** + * Adds new value to the storage or rewrites existent. + * + * @param {string} name Identifier of the data. + * @param {any} value Data to store. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + */ + const setItem = (name, value, instanceSpecific) => { + if (name) { + if (instanceSpecific) { + window[namespace][id][name] = value; + } else { + window[namespace][name] = value; + } + } + }; + + /** + * Returns the current value associated with the given name. + * + * @param {string} name Identifier of the data. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + * @return {any} The current value associated with the given name. + * If the given key does not exist in the list + * associated with the object then this method must return null. + */ + const getItem = (name, instanceSpecific) => { + if (instanceSpecific) { + return window[namespace][id][name]; + } else { + return window[namespace][name]; + } + }; + + /** + * Removes the current value associated with the given name. + * + * @param {string} name Identifier of the data. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + */ + const removeItem = (name, instanceSpecific) => { + if (instanceSpecific) { + delete window[namespace][id][name]; + } else { + delete window[namespace][name]; + } + }; + + /** Clear the storage. */ + const clear = () => { + window[namespace] = {}; + window[namespace][id] = {}; + }; + + window[namespace] = window[namespace] || {}; + window[namespace][id] = window[namespace][id] || {}; + + return { + clear: clear, + getItem: getItem, + removeItem: removeItem, + setItem: setItem + }; } From df82bb2de710f812522048fee7def4b04be949fd Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 16 Apr 2025 17:04:05 +0500 Subject: [PATCH 03/23] chore: Adds poster.js --- xmodule/assets/video/public/js/poster.js | 67 +++++++++++++++++++ .../video/public/js/video_block_main.js | 58 +++++++++------- 2 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 xmodule/assets/video/public/js/poster.js diff --git a/xmodule/assets/video/public/js/poster.js b/xmodule/assets/video/public/js/poster.js new file mode 100644 index 000000000000..857497c97619 --- /dev/null +++ b/xmodule/assets/video/public/js/poster.js @@ -0,0 +1,67 @@ +import $ from 'jquery'; // jQuery import +import _ from 'underscore'; + +'use strict'; + +/** + * VideoPoster function + * @param {jQuery} element + * @param {Object} options + */ +function VideoPoster(element, options) { + if (!(this instanceof VideoPoster)) { + return new VideoPoster(element, options); + } + + _.bindAll(this, 'onClick', 'destroy'); + this.element = element; + this.container = element.find('.video-player'); + this.options = options || {}; + this.initialize(); +} + +VideoPoster.moduleName = 'Poster'; +VideoPoster.prototype = { + template: _.template([ + '
', + '', + '
' + ].join('')), + + initialize: function () { + this.el = $(this.template({ + url: this.options.poster.url, + type: this.options.poster.type + })); + this.element.addClass('is-pre-roll'); + this.render(); + this.bindHandlers(); + }, + + bindHandlers: function () { + this.el.on('click', this.onClick); + this.element.on('destroy', this.destroy); + }, + + render: function () { + this.container.append(this.el); + }, + + onClick: function () { + if (_.isFunction(this.options.onClick)) { + this.options.onClick(); + } + this.destroy(); + }, + + destroy: function () { + this.element.off('destroy', this.destroy).removeClass('is-pre-roll'); + this.el.remove(); + }, +}; + +export {VideoPoster}; \ No newline at end of file diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index ba711287b8a0..35cf53f7b825 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -1,5 +1,8 @@ // Import required modules and dependencies +import $ from 'jquery'; +import _ from 'underscore'; import {VideoStorage} from './video_storage'; +import {VideoPoster} from './poster'; // TODO: Uncomment the imports // import { initialize } from './initialize'; // Assuming this function is imported // import { @@ -23,21 +26,24 @@ import {VideoStorage} from './video_storage'; // VideoPlaySkipControl, // VideoSkipControl, // VideoEventsBumperPlugin, -// VideoPoster, // VideoSocialSharing, // VideoAccessibleMenu, // VideoBumper, // } from './video_modules'; // Assuming all necessary modules are grouped here +// Stub gettext if the runtime doesn't provide it +if (typeof window.gettext === 'undefined') { + window.gettext = function (text) { + return text; + }; +} + + 'use strict'; console.log('In video_block_main.js file'); -(function (require, $) { - // TODO: Following code needs to be reviewed, why we are not getting $ - if (!$) { - $ = window.jQuery; - } +(function () { var youtubeXhr = null; var oldVideo = window.Video; @@ -121,26 +127,26 @@ console.log('In video_block_main.js file'); // VideoSocialSharing(el); if (bumperMetadata) { - // VideoPoster(el, { - // poster: el.data('poster'), - // onClick: _.once(function () { - // const mainVideoPlayer = player(state); - // - // if (storage.getItem('isBumperShown')) { - // mainVideoPlayer(); - // } else { - // const bumperState = getBumperState(bumperMetadata); - // const bumper = new VideoBumper(player(bumperState), bumperState); - // - // state.bumperState = bumperState; - // - // bumper.getPromise().then(() => { - // delete state.bumperState; - // mainVideoPlayer(); - // }); - // } - // }), - // }); + VideoPoster(el, { + poster: el.data('poster'), + onClick: _.once(function () { + const mainVideoPlayer = player(state); + + if (storage.getItem('isBumperShown')) { + mainVideoPlayer(); + } else { + const bumperState = getBumperState(bumperMetadata); + const bumper = new VideoBumper(player(bumperState), bumperState); + + state.bumperState = bumperState; + + bumper.getPromise().then(() => { + delete state.bumperState; + mainVideoPlayer(); + }); + } + }), + }); } else { // TODO: Uncomment following initialize method calling // initialize(state, element); From 1bb6642d0b4e2c18a2ab99aefccefb90447e5b5e Mon Sep 17 00:00:00 2001 From: farhan Date: Wed, 16 Apr 2025 17:32:51 +0500 Subject: [PATCH 04/23] chore: Adds video_accessible_menu --- xmodule/assets/video/public/js/poster.js | 2 + .../video/public/js/video_accessible_menu.js | 56 +++++++++++++++++++ .../video/public/js/video_block_main.js | 11 ++-- 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 xmodule/assets/video/public/js/video_accessible_menu.js diff --git a/xmodule/assets/video/public/js/poster.js b/xmodule/assets/video/public/js/poster.js index 857497c97619..ef059b81fa08 100644 --- a/xmodule/assets/video/public/js/poster.js +++ b/xmodule/assets/video/public/js/poster.js @@ -5,6 +5,8 @@ import _ from 'underscore'; /** * VideoPoster function + * + * @constructor * @param {jQuery} element * @param {Object} options */ diff --git a/xmodule/assets/video/public/js/video_accessible_menu.js b/xmodule/assets/video/public/js/video_accessible_menu.js new file mode 100644 index 000000000000..efcbff1cf63f --- /dev/null +++ b/xmodule/assets/video/public/js/video_accessible_menu.js @@ -0,0 +1,56 @@ +import _ from 'underscore'; + +/** + * Video Download Transcript control module. + * + * @constructor + * @param {jQuery} element + * @param {Object} options + */ +function VideoTranscriptDownloadHandler(element, options = {}) { + if (!(this instanceof VideoTranscriptDownloadHandler)) { + return new VideoTranscriptDownloadHandler(element, options); + } + + _.bindAll(this, 'clickHandler'); + + this.container = element; + this.options = options; + + if (this.container.find('.wrapper-downloads .wrapper-download-transcripts')) { + this.initialize(); + } +} + +VideoTranscriptDownloadHandler.prototype = { + // Initializes the module. + initialize() { + this.value = this.options.storage.getItem('transcript_download_format'); + this.el = this.container.find('.list-download-transcripts'); + this.el.on('click', '.btn-link', this.clickHandler); + }, + + // Event handler. We delay link clicks until the file type is set + clickHandler(event) { + event.preventDefault(); + + const fileType = $(event.target).data('value'); + const data = {transcript_download_format: fileType}; + const downloadUrl = $(event.target).attr('href'); + + $.ajax({ + url: this.options.saveStateUrl, + type: 'POST', + dataType: 'json', + data: data, + success: () => { + this.options.storage.setItem('transcript_download_format', fileType); + }, + complete: () => { + document.location.href = downloadUrl; + }, + }); + }, +}; + +export {VideoTranscriptDownloadHandler}; diff --git a/xmodule/assets/video/public/js/video_block_main.js b/xmodule/assets/video/public/js/video_block_main.js index 35cf53f7b825..902c6e704f21 100644 --- a/xmodule/assets/video/public/js/video_block_main.js +++ b/xmodule/assets/video/public/js/video_block_main.js @@ -3,6 +3,8 @@ import $ from 'jquery'; import _ from 'underscore'; import {VideoStorage} from './video_storage'; import {VideoPoster} from './poster'; +import {VideoTranscriptDownloadHandler} from './video_accessible_menu'; + // TODO: Uncomment the imports // import { initialize } from './initialize'; // Assuming this function is imported // import { @@ -27,7 +29,6 @@ import {VideoPoster} from './poster'; // VideoSkipControl, // VideoEventsBumperPlugin, // VideoSocialSharing, -// VideoAccessibleMenu, // VideoBumper, // } from './video_modules'; // Assuming all necessary modules are grouped here @@ -119,10 +120,10 @@ console.log('In video_block_main.js file'); }; }; - // VideoAccessibleMenu(el, { - // storage: storage, - // saveStateUrl: state.metadata.saveStateUrl, - // }); + VideoTranscriptDownloadHandler(el, { + storage: storage, + saveStateUrl: state.metadata.saveStateUrl, + }); // VideoSocialSharing(el); From 1b9671d2c83c78d66eacbca329a1313d064b0234 Mon Sep 17 00:00:00 2001 From: Hassan Raza Date: Wed, 9 Apr 2025 14:34:07 +0500 Subject: [PATCH 05/23] feat: Group ora staff notifications (#36440) * feat: Group ora staff notifications * fix: Revert default change for ora staff notifications * fix: update edx-ora2 version --- .../djangoapps/notifications/base_notification.py | 4 +++- .../notifications/grouping_notifications.py | 15 +++++++++++++++ requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 6 files changed, 22 insertions(+), 5 deletions(-) diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index ff2cc38c84a3..17ec8cda45d7 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -201,8 +201,10 @@ 'push': False, 'email_cadence': EmailCadence.DAILY, 'non_editable': [], - 'content_template': _('<{p}>You have a new open response submission awaiting for review for ' + 'content_template': _('<{p}>You have a new open response submission awaiting review for ' '<{strong}>{ora_name}'), + 'grouped_content_template': _('<{p}>You have multiple submissions awaiting review for ' + '<{strong}>{ora_name}'), 'content_context': { 'ora_name': 'Name of ORA in course', }, diff --git a/openedx/core/djangoapps/notifications/grouping_notifications.py b/openedx/core/djangoapps/notifications/grouping_notifications.py index 0e84ea3f109a..a1da88683fa0 100644 --- a/openedx/core/djangoapps/notifications/grouping_notifications.py +++ b/openedx/core/djangoapps/notifications/grouping_notifications.py @@ -106,6 +106,21 @@ def group(self, new_notification, old_notification): } +@NotificationRegistry.register('ora_staff_notification') +class OraStaffGrouper(BaseNotificationGrouper): + """ + Grouper for new ora staff notifications. + """ + + def group(self, new_notification, old_notification): + """ + Groups new ora staff notifications based on the xblock ID. + """ + content_context = old_notification.content_context + content_context.setdefault("grouped", True) + return content_context + + def group_user_notifications(new_notification: Notification, old_notification: Notification): """ Groups user notification based on notification type and group_id diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 3663296b289f..13f8da5438cc 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -828,7 +828,7 @@ openedx-mongodbproxy==0.2.2 # via -r requirements/edx/kernel.in optimizely-sdk==5.2.0 # via -r requirements/edx/bundled.in -ora2==6.14.7 +ora2==6.15.0 # via -r requirements/edx/bundled.in packaging==24.2 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 2ceb572fbbf7..76d63f0cb0d8 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1396,7 +1396,7 @@ optimizely-sdk==5.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==6.14.7 +ora2==6.15.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 43d2cb5aba2a..099fafb7bd67 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1000,7 +1000,7 @@ openedx-mongodbproxy==0.2.2 # via -r requirements/edx/base.txt optimizely-sdk==5.2.0 # via -r requirements/edx/base.txt -ora2==6.14.7 +ora2==6.15.0 # via -r requirements/edx/base.txt packaging==24.2 # via diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index d61ffb530edb..4edc93f95fee 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1058,7 +1058,7 @@ openedx-mongodbproxy==0.2.2 # via -r requirements/edx/base.txt optimizely-sdk==5.2.0 # via -r requirements/edx/base.txt -ora2==6.14.7 +ora2==6.15.0 # via -r requirements/edx/base.txt packaging==24.2 # via From e15274d0ed446601b347c6119ebb1e2806336849 Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Wed, 9 Apr 2025 16:29:44 +0500 Subject: [PATCH 06/23] feat: prevent sending goal reminder email to disabled users (#36505) --- .../commands/goal_reminder_email.py | 3 +++ .../tests/test_goal_reminder_email.py | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py index b6840ca2c0b0..7b3f3f598bfe 100644 --- a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py @@ -49,6 +49,9 @@ def send_ace_message(goal, session_id): Returns true if sent, false if it absorbed an exception and did not send """ user = goal.user + if not user.has_usable_password(): + log.info(f'Goal Reminder User is disabled {user.username} course {goal.course_key}') + return False try: course = CourseOverview.get_from_id(goal.course_key) except CourseOverview.DoesNotExist: diff --git a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py index 214fb58219ea..46d46832a6d9 100644 --- a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py @@ -1,5 +1,5 @@ """Tests for the goal_reminder_email command""" - +import uuid from datetime import datetime from botocore.exceptions import NoCredentialsError @@ -18,7 +18,7 @@ from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory -from lms.djangoapps.course_goals.management.commands.goal_reminder_email import send_email_using_ses +from lms.djangoapps.course_goals.management.commands.goal_reminder_email import send_email_using_ses, send_ace_message from lms.djangoapps.course_goals.models import CourseGoalReminderStatus from lms.djangoapps.course_goals.tests.factories import ( CourseGoalFactory, CourseGoalReminderStatusFactory, UserActivityFactory, @@ -220,6 +220,21 @@ def test_params_without_ses(self, mock_ace): assert 'override_default_channel' not in msg.options assert 'from_address' not in msg.options + @ddt.data(True, False) + @mock.patch('lms.djangoapps.course_goals.management.commands.goal_reminder_email.ace.send') + def test_goal_reminder_email_sent_to_disable_user(self, value, mock_ace): + """ + Test that the goal reminder email is not sent to disabled users. + """ + goal = self.make_valid_goal() + if value: + goal.user.set_password("12345678") + else: + goal.user.set_unusable_password() + goal.user.save() + send_ace_message(goal, str(uuid.uuid4())) + assert mock_ace.called is value + class TestGoalReminderEmailSES(TestCase): """ From 97b7d8c789e939ee2b64ad8250a440d2a540d876 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 7 Apr 2025 14:54:50 -0400 Subject: [PATCH 07/23] docs: Make the graph render correctly. I'm not sure how this worked before but I corrected to use the `graphviz` directive and then full graphviz graph syntax which seemed to fix whatever issues it was having. --- scripts/user_retirement/docs/special_cases.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/user_retirement/docs/special_cases.rst b/scripts/user_retirement/docs/special_cases.rst index ae544c3208c6..bded2d76bcd5 100644 --- a/scripts/user_retirement/docs/special_cases.rst +++ b/scripts/user_retirement/docs/special_cases.rst @@ -19,9 +19,10 @@ re-tried. You can do this using the Django admin. In this example, a user retirement errored during forums retirement, so we manually reset their state from ``ERRORED`` to ``ENROLLMENTS_COMPLETE``. -.. digraph:: retirement_states_example +.. graphviz:: :align: center + digraph G { //rankdir=LR; // Rank Direction Left to Right ranksep = "0.3"; @@ -49,6 +50,7 @@ from ``ERRORED`` to ``ENROLLMENTS_COMPLETE``. } ERRORED -> ENROLLMENTS_COMPLETE[style="bold,dashed",color=black,label=" via django\nadmin"] + } Now, the user retirement driver scripts will automatically resume this user's retirement the next time they are executed. From b7b1f31491bc2ab7b581aeee029a71c80e80767b Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 24 Mar 2025 10:35:13 -0400 Subject: [PATCH 08/23] feat!: Remove the course sock and related APIs. DEPR: https://github.com/openedx/edx-platform/issues/36429 This change removes the course_sock and related API data. The UI it removes is on the Legacy Courseware pages which have also been replaced and have their own [deprecation ticket](https://github.com/openedx/edx-platform/issues/35803) Before this can be merged, we will need to update the frontend-app-learning MFE to no longer consume the `can_show_upgrade_sock` attribute. BREAKING CHANGE: CourseHomeMetadata, ProgressTab, OutlineTab and VerifiedMode APIs will no longer have a `can_show_upgrade_sock` attribute. --- .../outline/tests/test_view.py | 7 - lms/djangoapps/course_home_api/serializers.py | 6 - lms/djangoapps/courseware/tests/test_views.py | 2 +- lms/djangoapps/courseware/views/index.py | 4 - lms/static/sass/_build-course.scss | 3 - lms/static/sass/bootstrap/lms-main.scss | 1 - lms/static/sass/features/_course-sock.scss | 170 ------------------ lms/templates/courseware/courseware.html | 34 ---- .../djangoapps/courseware_api/serializers.py | 1 - .../core/djangoapps/courseware_api/views.py | 5 - .../features/course_experience/__init__.py | 3 - .../static/course_experience/js/CourseSock.js | 90 ---------- .../course-sock-fragment.html | 76 -------- .../tests/views/test_course_sock.py | 117 ------------ .../tests/views/test_masquerade.py | 44 +---- .../course_experience/views/course_sock.py | 47 ----- webpack.common.config.js | 11 +- 17 files changed, 7 insertions(+), 614 deletions(-) delete mode 100644 lms/static/sass/features/_course-sock.scss delete mode 100644 openedx/features/course_experience/static/course_experience/js/CourseSock.js delete mode 100644 openedx/features/course_experience/templates/course_experience/course-sock-fragment.html delete mode 100644 openedx/features/course_experience/tests/views/test_course_sock.py delete mode 100644 openedx/features/course_experience/views/course_sock.py diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index 76928846f080..fd1680ba533d 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -31,7 +31,6 @@ from openedx.features.course_duration_limits.models import CourseDurationLimitConfig from openedx.features.course_experience import ( COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, - DISPLAY_COURSE_SOCK_FLAG, ENABLE_COURSE_GOALS ) from openedx.features.discounts.applicability import ( @@ -362,12 +361,6 @@ def test_visibility(self, is_enrolled, is_staff, course_visibility): assert (data['access_expiration'] is not None) == show_enrolled assert (data['resume_course']['url'] is not None) == show_enrolled - @ddt.data(True, False) - def test_can_show_upgrade_sock(self, sock_enabled): - with override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=sock_enabled): - response = self.client.get(self.url) - assert response.data['can_show_upgrade_sock'] == sock_enabled - def test_verified_mode(self): enrollment = CourseEnrollment.enroll(self.user, self.course.id) CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) diff --git a/lms/djangoapps/course_home_api/serializers.py b/lms/djangoapps/course_home_api/serializers.py index d3aa5617331e..44023c9d50db 100644 --- a/lms/djangoapps/course_home_api/serializers.py +++ b/lms/djangoapps/course_home_api/serializers.py @@ -9,7 +9,6 @@ from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link from openedx.core.djangoapps.courseware_api.utils import serialize_upgrade_info from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG from openedx.features.course_experience.utils import dates_banner_should_display @@ -59,13 +58,8 @@ class VerifiedModeSerializer(ReadOnlySerializer): Requires 'course_overview', 'enrollment', and 'request' from self.context. """ - can_show_upgrade_sock = serializers.SerializerMethodField() verified_mode = serializers.SerializerMethodField() - def get_can_show_upgrade_sock(self, _): - course_overview = self.context['course_overview'] - return DISPLAY_COURSE_SOCK_FLAG.is_enabled(course_overview.id) - def get_verified_mode(self, _): """Return verified mode information, or None.""" course_overview = self.context['course_overview'] diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index da31ebccce45..6303f07cf351 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -341,7 +341,7 @@ def test_index_query_counts(self): self.client.login(username=self.user.username, password=self.user_password) CourseEnrollment.enroll(self.user, course.id) - with self.assertNumQueries(154, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): + with self.assertNumQueries(152, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): with check_mongo_calls(3): url = reverse( 'courseware_section', diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 7a9242f595ef..e3bd2b34634f 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -48,7 +48,6 @@ default_course_url ) from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url -from openedx.features.course_experience.views.course_sock import CourseSockFragmentView from openedx.features.enterprise_support.api import data_sharing_consent_required from ..access import has_access @@ -454,9 +453,6 @@ def _create_courseware_context(self, request): table_of_contents['chapters'], ) - courseware_context['course_sock_fragment'] = CourseSockFragmentView().render_to_fragment( - request, course=self.course) - # entrance exam data self._add_entrance_exam_to_context(courseware_context) diff --git a/lms/static/sass/_build-course.scss b/lms/static/sass/_build-course.scss index 732eb2caf888..47fde243c7f6 100644 --- a/lms/static/sass/_build-course.scss +++ b/lms/static/sass/_build-course.scss @@ -66,6 +66,3 @@ // responsive @import 'base/layouts'; // temporary spot for responsive course @import 'header'; - -// features -@import 'features/course-sock'; diff --git a/lms/static/sass/bootstrap/lms-main.scss b/lms/static/sass/bootstrap/lms-main.scss index 70bd33da28eb..1299b84fa289 100644 --- a/lms/static/sass/bootstrap/lms-main.scss +++ b/lms/static/sass/bootstrap/lms-main.scss @@ -28,7 +28,6 @@ $static-path: '../..'; @import 'features/bookmarks'; @import 'features/course-experience'; @import 'features/course-search'; -@import 'features/course-sock'; @import 'features/course-upgrade-message'; @import 'features/course-duration-limits'; diff --git a/lms/static/sass/features/_course-sock.scss b/lms/static/sass/features/_course-sock.scss deleted file mode 100644 index a3a1f1484d75..000000000000 --- a/lms/static/sass/features/_course-sock.scss +++ /dev/null @@ -1,170 +0,0 @@ -.verification-sock { - display: inline-block; - position: relative; - width: 100%; - max-width: map-get($container-max-widths, xl); - margin: $baseline auto 0; - -webkit-transition: all 0.4s ease-out; - -moz-transition: all 0.4s ease-out; - -o-transition: all 0.4s ease-out; - -ms-transition: all 0.4s ease-out; - transition: all 0.4s ease-out; - - .action-toggle-verification-sock { - @include left(50%); - @include margin-left(-1 * $baseline * 15/2); - - position: absolute; - top: (-1 * $baseline); - width: ($baseline * 15); - color: theme-color("inverse"); - background-color: theme-color("success"); - border-color: theme-color("success"); - background-image: none; - box-shadow: none; - -webkit-transition: background-color 0.5s; - transition: background-color 0.5s; - cursor: pointer; - - &.active, - &:focus, - &:hover { - color: theme-color("success"); - background-color: theme-color("inverse"); - border-color: theme-color("success"); - background-image: none; - box-shadow: none; - } - } - - .verification-main-panel { - display: none; - overflow: hidden; - border-top: 1px solid $border-color; - padding: ($baseline * 5/2) ($baseline * 2); - -webkit-transition: height ease-out; - transition: height ease-out; - - .verification-desc-panel { - color: $black-t3; - position: relative; - - h2 { - font-size: 1.5rem; - font-weight: $font-weight-bold; - } - - h3 { - font-size: 1.25rem; - } - - @media (max-width: 960px) { - .mini-cert { - display: none; - border: 1px solid $black-t0; - } - } - - .mini-cert { - @include right($baseline); - - position: absolute; - top: $baseline; - width: ($baseline * 13); - } - - .learner-story-container { - display: flex; - max-width: 630px; - - .student-image { - margin: ($baseline / 4) $baseline 0 0; - height: ($baseline * 5/2); - width: ($baseline * 5/2); - } - - .story-quote > .author { - display: block; - margin-top: ($baseline / 4); - font-weight: 600; - } - - &:not(:first-child) { - margin-top: ($baseline * 2); - } - } - - .action-upgrade-certificate { - position: absolute; - right: $baseline; - background-color: theme-color("success"); - border-color: theme-color("success"); - color: theme-color("inverse"); - background-image: none; - box-shadow: none; - cursor: pointer; - - &:hover { - background-color: theme-color("inverse"); - color: theme-color("success"); - } - - @media (max-width: 960px) { - & { - position: relative; - margin-top: ($baseline * 2); - } - } - - @media (min-width: 960px) { - &.stuck-top { - bottom: auto; - top: $baseline * (52 / 5); - } - - &.stuck-bottom { - top: auto; - bottom: $baseline * (-1 * 3/2); - } - - &.attached { - @include right($baseline); - - position: fixed; - bottom: $baseline; - top: auto; - } - } - } - } - } -} - -// Overrides for the courseware page. -.view-courseware { - .verification-sock { - margin-top: 0; - border-top: none; - border-bottom: none; - - .action-toggle-verification-sock { - top: (-1 * $baseline * 5/4); - - &:not(.active) { - color: theme-color("inverse"); - background-color: theme-color("success"); - box-shadow: none; - border: 1px solid theme-color("success"); - - &:hover { - background-color: $success-color-hover; - } - } - } - - .verification-main-panel { - border-top: 0; - border-bottom: 1px solid $border-color; - } - } -} diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index eb94fab17c91..ce9dd19b2934 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -290,7 +290,6 @@ - ${HTML(course_sock_fragment.body_html())}