diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0a25729d5c..0658b92b2e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,8 @@ We don't maintain a detailed changelog. For details of changes, please see -either the `edX Release Notes`_ or the `GitHub commit history`_. +either the `Open edX Release Notes`_ or the `GitHub commit history`_. -.. _edX Release Notes: https://edx.readthedocs.io/projects/open-edx-release-notes/en/latest/ +.. _Open edX Release Notes: https://docs.openedx.org/en/latest/community/release_notes/index.html .. _GitHub commit history: https://github.com/openedx/edx-platform/commits/master diff --git a/cms/README.rst b/cms/README.rst index ee96c5cf09..27aa819a14 100644 --- a/cms/README.rst +++ b/cms/README.rst @@ -1,7 +1,7 @@ CMS === -This directory contains code relating to the Open edX Content Management System ("CMS"). It allows learning content to be created, edited, versioned, and eventually published to the `Open edX Learning Mangement System <../lms>`_ ("LMS"). The main user-facing application that CMS powers is the `Open edX Studio `_ +This directory contains code relating to the Open edX Content Management System ("CMS"). It allows learning content to be created, edited, versioned, and eventually published to the `Open edX Learning Mangement System <../lms>`_ ("LMS"). The main user-facing application that CMS powers is the `Open edX Studio `_ See also -------- diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index d2e7023c21..333cdbeb76 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -82,6 +82,22 @@ def is_unit(xblock, parent_xblock=None): return False +def is_library_content(xblock): + """ + Returns true if the specified xblock is library content. + """ + return xblock.category == 'library_content' + + +def get_parent_if_split_test(xblock): + """ + Returns the parent of the specified xblock if it is a split test, otherwise returns None. + """ + parent_xblock = get_parent_xblock(xblock) + if parent_xblock and parent_xblock.category == 'split_test': + return parent_xblock + + def xblock_has_own_studio_page(xblock, parent_xblock=None): """ Returns true if the specified xblock has an associated Studio page. Most xblocks do diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 914c078846..50aaaaded7 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -25,7 +25,11 @@ from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag -from cms.djangoapps.contentstore.helpers import is_unit +from cms.djangoapps.contentstore.helpers import ( + get_parent_if_split_test, + is_unit, + is_library_content, +) from cms.djangoapps.contentstore.toggles import ( libraries_v1_enabled, libraries_v2_enabled, @@ -148,11 +152,12 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st except ItemNotFoundError: return HttpResponseBadRequest() - is_unit_page = is_unit(xblock) - unit = xblock if is_unit_page else None + if use_new_unit_page(course.id): + if is_unit(xblock) or is_library_content(xblock): + return redirect(get_unit_url(course.id, xblock.location)) - if is_unit_page and use_new_unit_page(course.id): - return redirect(get_unit_url(course.id, unit.location)) + if split_xblock := get_parent_if_split_test(xblock): + return redirect(get_unit_url(course.id, split_xblock.location)) container_handler_context = get_container_handler_context(request, usage_key, course, xblock) container_handler_context.update({ diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 9a5efe70fc..19e20804c2 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -611,6 +611,7 @@ def _create_block(request): modulestore().update_item(created_block, request.user.id) response["upstreamRef"] = upstream_ref response["static_file_notices"] = asdict(static_file_notices) + response["parent_locator"] = parent_locator return JsonResponse(response) diff --git a/cms/envs/common.py b/cms/envs/common.py index 5d4dea7c23..6cc1938271 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2779,16 +2779,16 @@ SHOW_ACCOUNT_ACTIVATION_CTA = False ################# Documentation links for course apps ################# # pylint: disable=line-too-long -CALCULATOR_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/calculator.html" -DISCUSSIONS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_components/create_discussion.html" -EDXNOTES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/notes.html" -PROGRESS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html?highlight=progress#hiding-or-showing-the-wiki-or-progress-pages" -TEAMS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_features/teams/teams_setup.html" -TEXTBOOKS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/textbooks.html" -WIKI_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/course_wiki.html" -CUSTOM_PAGES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#adding-custom-pages" -COURSE_LIVE_HELP_URL = "https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/course_assets/course_live.html" -ORA_SETTINGS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#configuring-course-level-open-response-assessment-settings" +CALCULATOR_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_calculator.html" +DISCUSSIONS_HELP_URL = "https://docs.openedx.org/en/latest/educators/concepts/communication/about_course_discussions.html" +EDXNOTES_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/enable_notes.html" +PROGRESS_HELP_URL = "https://docs.openedx.org/en/latest/educators/references/data/progress_page.html" +TEAMS_HELP_URL = "https://docs.openedx.org/en/latest/educators/navigation/advanced_features.html#use-teams-in-your-course" +TEXTBOOKS_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_textbooks.html" +WIKI_HELP_URL = "https://docs.openedx.org/en/latest/educators/concepts/communication/about_course_wiki.html" +CUSTOM_PAGES_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_custom_page.html" +COURSE_LIVE_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/add_course_live.html" +ORA_SETTINGS_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/Manage_ORA_Assignment.html" # pylint: enable=line-too-long # keys for big blue button live provider @@ -2813,7 +2813,7 @@ DISCUSSIONS_INCONTEXT_FEEDBACK_URL = '' # Learn More link in upgraded discussion notification alert # pylint: disable=line-too-long -DISCUSSIONS_INCONTEXT_LEARNMORE_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_discussions/discussions.html" +DISCUSSIONS_INCONTEXT_LEARNMORE_URL = "https://docs.openedx.org/en/latest/educators/concepts/communication/about_course_discussions.html" # pylint: enable=line-too-long #### django-simple-history## @@ -2837,7 +2837,7 @@ def _should_send_learning_badge_events(settings): # Each topic configuration dictionary contains # * `enabled`: a toggle denoting whether the event will be published to the topic. These should be annotated # according to -# https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html +# https://docs.openedx.org/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html # * `event_key_field` which is a period-delimited string path to event data field to use as event key. # Note: The topic names should not include environment prefix as it will be dynamically added based on # EVENT_BUS_TOPIC_PREFIX setting. diff --git a/cms/static/images/advanced-icon.svg b/cms/static/images/advanced-icon.svg new file mode 100644 index 0000000000..86096aefd2 --- /dev/null +++ b/cms/static/images/advanced-icon.svg @@ -0,0 +1,6 @@ + diff --git a/cms/static/images/drag-and-drop-v2-icon.svg b/cms/static/images/drag-and-drop-v2-icon.svg new file mode 100644 index 0000000000..733fb744d7 --- /dev/null +++ b/cms/static/images/drag-and-drop-v2-icon.svg @@ -0,0 +1,6 @@ + diff --git a/cms/static/images/itembank-icon.svg b/cms/static/images/itembank-icon.svg new file mode 100644 index 0000000000..b2198e1aba --- /dev/null +++ b/cms/static/images/itembank-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cms/static/images/library-icon.svg b/cms/static/images/library-icon.svg new file mode 100644 index 0000000000..a4fe81244e --- /dev/null +++ b/cms/static/images/library-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cms/static/images/library_v2-icon.svg b/cms/static/images/library_v2-icon.svg new file mode 100644 index 0000000000..4d69dbf9a0 --- /dev/null +++ b/cms/static/images/library_v2-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cms/static/images/openassessment-icon.svg b/cms/static/images/openassessment-icon.svg new file mode 100644 index 0000000000..4841b6b43d --- /dev/null +++ b/cms/static/images/openassessment-icon.svg @@ -0,0 +1,6 @@ + diff --git a/cms/static/images/problem-icon.svg b/cms/static/images/problem-icon.svg new file mode 100644 index 0000000000..7d51f436f7 --- /dev/null +++ b/cms/static/images/problem-icon.svg @@ -0,0 +1,6 @@ + diff --git a/cms/static/images/text-icon.svg b/cms/static/images/text-icon.svg new file mode 100644 index 0000000000..8588a471c9 --- /dev/null +++ b/cms/static/images/text-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cms/static/images/video-icon.svg b/cms/static/images/video-icon.svg new file mode 100644 index 0000000000..08f7444b62 --- /dev/null +++ b/cms/static/images/video-icon.svg @@ -0,0 +1,3 @@ + diff --git a/cms/static/js/views/components/add_xblock.js b/cms/static/js/views/components/add_xblock.js index 29ce5eec76..dd2f11dbe8 100644 --- a/cms/static/js/views/components/add_xblock.js +++ b/cms/static/js/views/components/add_xblock.js @@ -42,10 +42,38 @@ function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, Add }, showComponentTemplates: function(event) { - var type; + var type, parentLocator, model, parentBlockType; event.preventDefault(); event.stopPropagation(); + type = $(event.currentTarget).data('type'); + parentLocator = $(event.currentTarget).closest('.xblock[data-usage-id]').data('usage-id'); + parentBlockType = $(event.currentTarget).parents('.xblock-author_view').last().data('block-type'); + model = this.collection.models.find(function(item) { return item.type === type; }) || {}; + + try { + if (this.options.isIframeEmbed && parentBlockType !== 'split_test') { + window.parent.postMessage( + { + type: 'showComponentTemplates', + payload: { + type: type, + parentLocator: parentLocator, + model: { + type: model.type, + display_name: model.display_name, + templates: model.templates, + support_legend: model.support_legend, + }, + } + }, document.referrer + ); + return true; + } + } catch (e) { + console.error(e); + } + this.$('.new-component').slideUp(250); this.$('.new-component-' + type).slideDown(250); this.$('.new-component-' + type + ' div').focus(); @@ -65,11 +93,25 @@ function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, Add var self = this, $element = $(event.currentTarget), saveData = $element.data(), - oldOffset = ViewUtils.getScrollOffset(this.$el); + oldOffset = ViewUtils.getScrollOffset(this.$el), + usageId = $element.closest('.xblock[data-usage-id]').data('usage-id'); event.preventDefault(); this.closeNewComponent(event); if (saveData.type === 'library_v2') { + try { + if (this.options.isIframeEmbed) { + return window.parent.postMessage( + { + type: 'showSingleComponentPicker', + payload: { usageId }, + }, document.referrer + ); + } + } catch (e) { + console.error(e); + } + var modal = new AddLibraryContent(); modal.showComponentPicker( this.options.libraryContentPickerUrl, diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 2bd3cc18a3..684bd0ab7d 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -40,6 +40,7 @@ function($, _, Backbone, gettext, BasePage, 'change .header-library-checkbox': 'toggleLibraryComponent', 'click .collapse-button': 'collapseXBlock', 'click .xblock-view-action-button': 'viewXBlockContent', + 'click .xblock-view-group-link': 'viewXBlockContent', }, options: { @@ -60,8 +61,9 @@ function($, _, Backbone, gettext, BasePage, initialize: function(options) { BasePage.prototype.initialize.call(this, options); this.viewClass = options.viewClass || this.defaultViewClass; - this.isLibraryPage = (this.model.attributes.category === 'library'); - this.isLibraryContentPage = (this.model.attributes.category === 'library_content'); + this.isLibraryPage = this.model.attributes.category === 'library'; + this.isLibraryContentPage = this.model.attributes.category === 'library_content'; + this.isSplitTestContentPage = this.model.attributes.category === 'split_test'; this.nameEditor = new XBlockStringFieldEditor({ el: this.$('.wrapper-xblock-field'), model: this.model @@ -131,13 +133,16 @@ function($, _, Backbone, gettext, BasePage, if (this.options.isIframeEmbed) { window.addEventListener('message', (event) => { - const { data } = event; + const { data: initialData } = event; - if (!data) return; + if (!initialData) return; let xblockElement; let xblockWrapper; + const data = { ...initialData }; + data.payload = { ...data?.payload, ...data?.payload?.data }; + if (data.payload && data.payload.locator) { xblockElement = $(`[data-locator="${data.payload.locator}"]`); xblockWrapper = $("li.studio-xblock-wrapper[data-locator='" + data.payload.locator + "']"); @@ -173,6 +178,25 @@ function($, _, Backbone, gettext, BasePage, this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved); }, + postMessageToParent: function(body, callbackFn = null) { + try { + window.parent.postMessage(body, document.referrer); + if (callbackFn) { + callbackFn(); + } + } catch (e) { + console.error('Failed to post message:', e); + } + }, + + postMessageForHideProcessingNotification: function () { + this.postMessageToParent({ + type: 'hideProcessingNotification', + message: 'Hide processing notification', + payload: {}, + }); + }, + getViewParameters: function() { return { el: this.$('.wrapper-xblock'), @@ -237,18 +261,14 @@ function($, _, Backbone, gettext, BasePage, const scrollOffset = scrollOffsetString ? parseInt(scrollOffsetString, 10) : 0; if (scrollOffset) { - try { - window.parent.postMessage( - { - type: 'scrollToXBlock', - message: 'Scroll to XBlock', - payload: { scrollOffset } - }, document.referrer - ); - localStorage.removeItem('modalEditLastYPosition'); - } catch (e) { - console.error(e); - } + self.postMessageToParent( + { + type: 'scrollToXBlock', + message: 'Scroll to XBlock', + payload: { scrollOffset } + }, + () => localStorage.removeItem('modalEditLastYPosition') + ); } } }, @@ -272,13 +292,14 @@ function($, _, Backbone, gettext, BasePage, renderAddXBlockComponents: function() { var self = this; - if (self.options.canEdit && !self.options.isIframeEmbed) { + if (self.options.canEdit && (!self.options.isIframeEmbed || self.isSplitTestContentPage)) { this.$('.add-xblock-component').each(function(index, element) { var component = new AddXBlockComponent({ el: element, createComponent: _.bind(self.createComponent, self), collection: self.options.templates, libraryContentPickerUrl: self.options.libraryContentPickerUrl, + isIframeEmbed: self.options.isIframeEmbed, }); component.render(); }); @@ -288,7 +309,7 @@ function($, _, Backbone, gettext, BasePage, }, initializePasteButton() { - if (this.options.canEdit && !this.options.isIframeEmbed) { + if (this.options.canEdit && (!this.options.isIframeEmbed || this.isSplitTestContentPage)) { // We should have the user's clipboard status. const data = this.options.clipboardData; this.refreshPasteButton(data); @@ -305,7 +326,8 @@ function($, _, Backbone, gettext, BasePage, refreshPasteButton(data) { // Do not perform any changes on paste button since they are not // rendered on Library or LibraryContent pages - if (!this.isLibraryPage && !this.isLibraryContentPage && !this.options.isIframeEmbed) { + if (!this.isLibraryPage && !this.isLibraryContentPage && (!this.options.isIframeEmbed || this.isSplitTestContentPage)) { + this.postMessageForHideProcessingNotification(); // 'data' is the same data returned by the "get clipboard status" API endpoint // i.e. /api/content-staging/v1/clipboard/ if (this.options.canEdit && data.content) { @@ -340,17 +362,11 @@ function($, _, Backbone, gettext, BasePage, /** The user has clicked on the "Paste Component button" */ pasteComponent(event) { event.preventDefault(); - try { - if (this.options.isIframeEmbed) { - window.parent.postMessage( - { - type: 'pasteComponent', - payload: {} - }, document.referrer - ); - } - } catch (e) { - console.error(e); + if (this.options.isIframeEmbed) { + this.postMessageToParent({ + type: 'pasteComponent', + payload: {}, + }); } // Get the ID of the container (usually a unit/vertical) that we're pasting into: const parentElement = this.findXBlockElement(event.target); @@ -375,6 +391,9 @@ function($, _, Backbone, gettext, BasePage, placeholderElement.remove(); }); }).done((data) => { + if (this.options.isIframeEmbed) { + this.postMessageForHideProcessingNotification(); + } const { conflicting_files: conflictingFiles, error_files: errorFiles, @@ -646,17 +665,11 @@ function($, _, Backbone, gettext, BasePage, subMenu.classList.toggle('is-shown'); if (!subMenu.classList.contains('is-shown') && this.options.isIframeEmbed) { - try { - window.parent.postMessage( - { - type: 'toggleCourseXBlockDropdown', - message: 'Adjust the height of the dropdown menu', - payload: { courseXBlockDropdownHeight: 0 } - }, document.referrer - ); - } catch (error) { - console.error(error); - } + this.postMessageToParent({ + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { courseXBlockDropdownHeight: 0 } + }); } // Calculate the viewport height and the dropdown menu height. @@ -668,33 +681,21 @@ function($, _, Backbone, gettext, BasePage, if (courseUnitXBlockIframeHeight < courseXBlockDropdownHeight) { // If the dropdown menu is taller than the iframe, adjust the height of the dropdown menu. - try { - window.parent.postMessage( - { - type: 'toggleCourseXBlockDropdown', - message: 'Adjust the height of the dropdown menu', - payload: { courseXBlockDropdownHeight }, - }, document.referrer - ); - } catch (error) { - console.error(error); - } + this.postMessageToParent({ + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { courseXBlockDropdownHeight }, + }); } else if ((courseXBlockDropdownHeight + clickYPosition) > courseUnitXBlockIframeHeight) { if (courseXBlockDropdownHeight > courseUnitXBlockIframeHeight / 2) { // If the dropdown menu is taller than half the iframe, send a message to adjust its height. - try { - window.parent.postMessage( - { - type: 'toggleCourseXBlockDropdown', - message: 'Adjust the height of the dropdown menu', - payload: { - courseXBlockDropdownHeight: courseXBlockDropdownHeight / 2, - }, - }, document.referrer - ); - } catch (error) { - console.error(error); - } + this.postMessageToParent({ + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { + courseXBlockDropdownHeight: courseXBlockDropdownHeight / 2, + }, + }); } else { // Move the dropdown menu upward to prevent it from overflowing out of the viewport. if (this.options.isIframeEmbed) { @@ -719,18 +720,12 @@ function($, _, Backbone, gettext, BasePage, }, openManageTags: function(event) { - const contentId = this.findXBlockElement(event.target).data('locator'); - try { - if (this.options.isIframeEmbed) { - window.parent.postMessage( - { - type: 'openManageTags', - payload: { contentId } - }, document.referrer - ); - } - } catch (e) { - console.error(e); + const contentId = this.findXBlockElement(event.target).data('locator'); + if (this.options.isIframeEmbed) { + this.postMessageToParent({ + type: 'openManageTags', + payload: { contentId }, + }); } const taxonomyTagsWidgetUrl = this.model.get('taxonomy_tags_widget_url'); @@ -747,13 +742,17 @@ function($, _, Backbone, gettext, BasePage, const usageId = encodeURI(primaryHeader.attr('data-usage-id')); try { if (this.options.isIframeEmbed) { - return window.parent.postMessage( + window.parent.postMessage( { - type: 'copyXBlock', + type: this.isSplitTestContentPage ? 'copyXBlockLegacy' : 'copyXBlock', message: 'Copy the XBlock', payload: { usageId } }, document.referrer ); + + if (!this.isSplitTestContentPage) { + return; + } } } catch (e) { console.error(e); @@ -795,6 +794,7 @@ function($, _, Backbone, gettext, BasePage, setTimeout(checkStatus, 1_000); return deferred; } else { + this.postMessageForHideProcessingNotification(); throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); } }); @@ -909,15 +909,12 @@ function($, _, Backbone, gettext, BasePage, this.deleteComponent(this.findXBlockElement(event.target)); }, - createPlaceholderElement: function() { - return $('
', {class: 'studio-xblock-wrapper'}); - }, - createComponent: function(template, target, iframeMessageData) { // A placeholder element is created in the correct location for the new xblock // and then onNewXBlock will replace it with a rendering of the xblock. Note that // for xblocks that can't be replaced inline, the entire parent will be refreshed. var parentElement = this.findXBlockElement(target), + self = this, parentLocator = parentElement.data('locator'), buttonPanel = target?.closest('.add-xblock-component'), listPanel = buttonPanel?.prev(), @@ -929,28 +926,55 @@ function($, _, Backbone, gettext, BasePage, placeholderElement, $container; - if (this.options.isIframeEmbed) { + if (this.options.isIframeEmbed && !this.isSplitTestContentPage) { $container = $('ol.reorderable-container.ui-sortable'); scrollOffset = 0; } else { $container = listPanel; - scrollOffset = ViewUtils.getScrollOffset(buttonPanel); + if (!target.length && iframeMessageData.payload.parent_locator) { + $container = $('.xblock[data-usage-id="' + iframeMessageData.payload.parent_locator + '"]') + .find('ol.reorderable-container.ui-sortable'); + } + if (!iframeMessageData) { + scrollOffset = ViewUtils.getScrollOffset(buttonPanel); + } } placeholderElement = $placeholderEl.appendTo($container); - if (this.options.isIframeEmbed) { - if (iframeMessageData.payload.data && iframeMessageData.type === 'addXBlock') { - return this.onNewXBlock(placeholderElement, scrollOffset, false, iframeMessageData.payload.data); + if (this.options.isIframeEmbed && iframeMessageData) { + if (iframeMessageData.payload.data && iframeMessageData.type === 'addXBlock') { + return this.onNewXBlock(placeholderElement, scrollOffset, false, iframeMessageData.payload.data); + } + } + + if (this.options.isIframeEmbed && this.isSplitTestContentPage) { + this.postMessageToParent({ + type: 'addNewComponent', + message: 'Add new XBlock', + payload: {}, + }); + if (iframeMessageData) { + return; } } return $.postJSON(this.getURLRoot() + '/', requestData, _.bind(this.onNewXBlock, this, placeholderElement, scrollOffset, false)) + .always(function () { + if (self.options.isIframeEmbed && self.isSplitTestContentPage) { + self.postMessageToParent({ + type: 'hideProcessingNotification', + message: 'Hide processing notification', + payload: {} + }); + return true; + } + }) .fail(function() { // Remove the placeholder if the update failed placeholderElement.remove(); - }); + }); }, duplicateComponent: function(xblockElement) { @@ -966,17 +990,11 @@ function($, _, Backbone, gettext, BasePage, placeholderElement = $placeholderEl.insertAfter(xblockElement); if (this.options.isIframeEmbed) { - try { - window.parent.postMessage( - { - type: 'scrollToXBlock', - message: 'Scroll to XBlock', - payload: { scrollOffset: xblockElement.height() } - }, document.referrer - ); - } catch (e) { - console.error(e); - } + this.postMessageToParent({ + type: 'scrollToXBlock', + message: 'Scroll to XBlock', + payload: { scrollOffset: xblockElement.height() } + }); const messageHandler = ({ data }) => { if (data && data.type === 'completeXBlockDuplicating') { @@ -1028,7 +1046,6 @@ function($, _, Backbone, gettext, BasePage, getSelectedLibraryComponents: function() { var self = this; var locator = this.$el.find('.studio-xblock-wrapper').data('locator'); - console.log(ModuleUtils); $.getJSON( ModuleUtils.getUpdateUrl(locator) + '/handler/get_block_ids', function(data) { @@ -1065,19 +1082,16 @@ function($, _, Backbone, gettext, BasePage, }, viewXBlockContent: function(event) { - try { - if (this.options.isIframeEmbed) { - event.preventDefault(); - var usageId = event.currentTarget.href.split('/').pop() || ''; - window.parent.postMessage({ - type: 'handleViewXBlockContent', - message: 'View the content of the XBlock', - payload: { usageId }, - }, document.referrer); - return true; - } - } catch (e) { - console.error(e); + if (this.options.isIframeEmbed) { + event.preventDefault(); + const usageId = event.currentTarget.href.split('/').pop() || ''; + const isViewGroupLink = event.currentTarget.classList.contains('xblock-view-group-link'); + this.postMessageToParent({ + type: isViewGroupLink ? 'handleViewGroupConfigurations' : 'handleViewXBlockContent', + message: isViewGroupLink ? 'View the group configurations page' : 'View the content of the XBlock', + payload: { usageId }, + }); + return true; } }, @@ -1142,6 +1156,17 @@ function($, _, Backbone, gettext, BasePage, destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/' + blockType[1] + '/' + encodeURI(data.locator); } + if (this.options.isIframeEmbed && this.isSplitTestContentPage) { + return this.postMessageToParent({ + type: 'handleRedirectToXBlockEditPage', + message: 'Redirect to xBlock edit page', + payload: { + type: blockType[1], + locator: encodeURI(data.locator), + }, + }); + } + window.location.href = destinationUrl; return; } diff --git a/cms/static/sass/course-unit-mfe-iframe-bundle.scss b/cms/static/sass/course-unit-mfe-iframe-bundle.scss index 79c20ea26b..fd0949e2e4 100644 --- a/cms/static/sass/course-unit-mfe-iframe-bundle.scss +++ b/cms/static/sass/course-unit-mfe-iframe-bundle.scss @@ -8,6 +8,11 @@ html { } } +body, +#main { + background-color: transparent; +} + [class*="view-"] .wrapper { .inner-wrapper { max-width: 100%; @@ -39,67 +44,105 @@ html { .actions-list .action-item .action-button { border-radius: 4px; + display: inline-flex; + align-items: center; + gap: ($baseline * .3); + padding: ($baseline * .15) ($baseline / 2); &:hover { background-color: $primary; color: $white; } - } - } - &.level-page .xblock-message { - padding: ($baseline * .75) ($baseline * 1.2); - border-radius: 0 0 4px 4px; - - &.information { - color: $text-color; - background-color: $xblock-message-info-bg; - border-color: $xblock-message-info-border-color; - } - - &.validation.has-warnings { - color: $black; - background-color: $xblock-message-warning-bg; - border-color: $xblock-message-warning-border-color; - border-top-width: 1px; - - .icon { - color: $xblock-message-warning-border-color; + .action-button-text { + line-height: 20px; } } - - a { - color: $primary; - } } - .xblock-author_view-library_content > .wrapper-xblock-message .xblock-message { - font-size: 16px; - line-height: 22px; - border-radius: 4px; - padding: ($baseline * 1.2); - box-shadow: 0 1px 2px rgba(0, 0, 0, .15), 0 1px 4px rgba(0, 0, 0, .15); - margin-bottom: ($baseline * 1.4); + &.level-page { + .xblock-message { + padding: ($baseline * .75) ($baseline * 1.2); + border-radius: 0 0 4px 4px; + + .xblock-message-list { + color: $black; + } + + &.information, + &.validation.has-warnings, + &.validation.has-errors { + color: $black; + border-width: 0; + font-size: 16px; + line-height: 22px; + padding: ($baseline * 1.2); + box-shadow: 0 1px 2px rgba(0, 0, 0, .15), 0 1px 4px rgba(0, 0, 0, .15); + } + + &.information { + background-color: $xblock-message-info-bg; + + .icon { + color: $xblock-message-info-icon-color; + } + } + + &.validation.has-warnings { + background-color: $xblock-message-warning-bg; + + .icon { + color: $xblock-message-warning-icon-color; + } + } + + &.validation.has-errors { + background-color: $xblock-message-error-bg; + + .icon { + color: $xblock-message-error-icon-color; + } + } + + a { + color: $primary; + } + } + + &.studio-xblock-wrapper > .wrapper-xblock-message .xblock-message, + .xblock > .wrapper-xblock-message .xblock-message { + border-radius: 4px; + margin-bottom: ($baseline * 1.4); + } + } + + .xblock-author_view-split_test .wrapper-xblock { + background: $white; + box-shadow: 0 2px 4px rgba(0, 0, 0, .15), 0 2px 8px rgba(0, 0, 0, .15); } &.level-element { box-shadow: 0 2px 4px rgba(0, 0, 0, .15), 0 2px 8px rgba(0, 0, 0, .15); margin: 0 0 ($baseline * 1.4) 0; - } - &.level-element .xblock-header-primary { - background-color: $white; - } + .xblock-header-primary { + background-color: $white; + } - &.level-element .xblock-render { - background: $white; - margin: 0; - padding: $baseline; - border-bottom-left-radius: 6px; - border-bottom-right-radius: 6px; + .xblock-render { + background: $white; + margin: 0; + padding: $baseline; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } } .wrapper-xblock .header-actions .actions-list { + .wrapper-nav-sub { + z-index: 11; + } + .action-actions-menu:last-of-type .nav-sub { right: 120px; } @@ -176,6 +219,13 @@ html { } } } + + .wrapper-groups.is-inactive { + box-shadow: 0 2px 4px rgba(0, 0, 0, .15), 0 2px 8px rgba(0, 0, 0, .15); + border-radius: 6px; + border: none; + margin: ($baseline * 1.5) ($baseline / 2) 0; + } } .edit-xblock-modal select { @@ -443,8 +493,8 @@ html { } &.xmodule_DoneXBlock { - margin-top: 60px; - padding: 0 20px; + margin-top: ($baseline * 3); + padding: 0 $baseline; } .xblock-actions { @@ -578,7 +628,7 @@ html { } body [class*="view-"] .openassessment_editor_buttons.xblock-actions { - padding: 15px 2% 3px 2%; + padding: ($baseline * .75) 2% ($baseline * .15) 2%; } [class*="view-"] { @@ -634,7 +684,7 @@ body [class*="view-"] .openassessment_editor_buttons.xblock-actions { .list-input.settings-list { .field.comp-setting-entry.is-set .setting-input { color: $text-color; - margin-bottom: 5px; + margin-bottom: ($baseline * .25); } select { @@ -733,7 +783,7 @@ select { #openassessment_editor_header .editor_tabs .oa_editor_tab { @extend %light-button; - padding: 0 10px; + padding: 0 ($baseline / 2); } #openassessment_editor_header, @@ -762,7 +812,7 @@ select { #oa_rubric_editor_wrapper .openassessment_criterion_option .openassessment_criterion_option_point_wrapper label input { min-width: 70px; - font-size: 18px; + font-size: $base-font-size; height: 44px; } @@ -835,7 +885,7 @@ select { width: 100%; &.background-url { - margin-bottom: 10px; + margin-bottom: ($baseline / 2); } &.autozone-layout { @@ -858,3 +908,104 @@ select { width: 100%; } } + +.xblock-render { + .add-xblock-component { + background: transparent; + padding: $baseline; + + .new-component { + h5 { + margin-bottom: ($baseline * 1.2); + font-size: 22px; + font-weight: 700; + color: $black; + } + + .new-component-type { + display: flex; + flex-wrap: wrap; + gap: ($baseline * .6); + align-items: center; + justify-content: center; + + .add-xblock-component-button { + box-shadow: 0 1px 2px rgba(0, 0, 0, .15), 0 1px 4px rgba(0, 0, 0, .15); + width: 176px; + height: 110px; + color: $primary; + border-color: $primary; + background: transparent; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: ($baseline * .4); + + &:hover { + color: darken($primary, 10%); + background-color: lighten($primary, 80%); + border-color: darken($primary, 15%); + } + + .large-template-icon { + width: 24px; + height: 24px; + background: $primary; + + @each $name, $file in $template-icon-map { + &.large-#{$name}-icon { + mask: url("#{$static-path}/images/#{$file}.svg") center no-repeat; + } + } + } + + .name { + color: inherit; + font-size: 15.75px; + font-weight: 400; + } + + .beta { + color: $white; + background-color: $primary; + padding: ($baseline * .1) ($baseline * .4) ($baseline * .2); + font-size: 13.5px; + font-weight: 700; + line-height: 1; + margin: -($baseline * .3) 0 0; + } + } + } + } + + .new-component-templates { + border: 1px solid $border-color; + border-radius: 5px; + box-shadow: 0 1px 2px rgba(0, 0, 0, .15), 0 1px 4px rgba(0, 0, 0, .15); + margin: $baseline; + overflow: hidden; + + .button-component:hover { + background: $primary; + } + + .cancel-button { + @extend %primary-button; + } + } + } +} + +.paste-component { + margin: ($baseline * 1.2) ($baseline / 2) 0; + + .paste-component-whats-in-clipboard .clipboard-details-popup { + right: ($baseline / 2 * -1); + } + + .paste-component-button.button { + @extend %button-primary-outline; + } +} diff --git a/cms/static/sass/elements/_course-unit-mfe-iframe.scss b/cms/static/sass/elements/_course-unit-mfe-iframe.scss index dc75109942..5985e9aadd 100644 --- a/cms/static/sass/elements/_course-unit-mfe-iframe.scss +++ b/cms/static/sass/elements/_course-unit-mfe-iframe.scss @@ -31,6 +31,8 @@ cursor: pointer; background-image: none; display: block; + box-shadow: none; + text-shadow: none; &:hover { background: darken($primary, 5%); @@ -46,6 +48,35 @@ } } +%button-primary-outline { + @extend %modal-actions-button; + + color: $primary; + border-color: $primary; + text-shadow: none; + font-weight: 400; + position: relative; + + &:focus { + color: $primary; + background: transparent; + + &:before { + content: ""; + position: absolute; + inset: -5px; + border: 2px solid $primary; + border-radius: 10px; + } + } + + &:hover { + color: darken($primary, 10%); + background-color: lighten($primary, 80%); + border-color: darken($primary, 15%); + } +} + %light-button { @extend %modal-actions-button; diff --git a/cms/static/sass/partials/cms/theme/_variables-v1.scss b/cms/static/sass/partials/cms/theme/_variables-v1.scss index 0b3fe6b6e4..b60bc15f2e 100644 --- a/cms/static/sass/partials/cms/theme/_variables-v1.scss +++ b/cms/static/sass/partials/cms/theme/_variables-v1.scss @@ -317,6 +317,23 @@ $dark: #212529; $zindex-dropdown: 100; $xblock-message-info-bg: #eff8fa; -$xblock-message-info-border-color: #9cd2e6; +$xblock-message-info-icon-color: #9cd2e6; + $xblock-message-warning-bg: #fffdf0; -$xblock-message-warning-border-color: #fff6bf; +$xblock-message-warning-icon-color: #ffd900; + +$xblock-message-error-bg: #fbf2f3; +$xblock-message-error-icon-color: #c32d3a; + +$template-icon-map: ( + "library": "library-icon", + "library_v2": "library_v2-icon", + "itembank": "itembank-icon", + "advanced": "advanced-icon", + "html": "text-icon", + "openassessment": "openassessment-icon", + "problem": "problem-icon", + "video": "video-icon", + "drag-and-drop-v2": "drag-and-drop-v2-icon", + "text": "text-icon" +); diff --git a/cms/templates/container_chromeless.html b/cms/templates/container_chromeless.html index 2fe821e49d..a233e0dc7d 100644 --- a/cms/templates/container_chromeless.html +++ b/cms/templates/container_chromeless.html @@ -201,6 +201,7 @@ from openedx.core.release import RELEASE_LINE outlineURL: "${outline_url | n, js_escaped_string}", clipboardData: ${user_clipboard | n, dump_js_escaped_json}, isIframeEmbed: true, + libraryContentPickerUrl: "${library_content_picker_url | n, js_escaped_string}", } ); diff --git a/cms/templates/js/add-xblock-component-support-legend.underscore b/cms/templates/js/add-xblock-component-support-legend.underscore index 483e786581..1e15448563 100644 --- a/cms/templates/js/add-xblock-component-support-legend.underscore +++ b/cms/templates/js/add-xblock-component-support-legend.underscore @@ -1,7 +1,7 @@ <% if (support_legend.show_legend) { %> + href="https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/guide_problem_types.html" rel="noopener" target="_blank"> <%- support_legend.documentation_label %> diff --git a/common/djangoapps/third_party_auth/README.rst b/common/djangoapps/third_party_auth/README.rst index d2e1089eca..eae19bc79b 100644 --- a/common/djangoapps/third_party_auth/README.rst +++ b/common/djangoapps/third_party_auth/README.rst @@ -8,4 +8,4 @@ We make use of the `social-auth-app-django`_ as our backend library for this dja To enable this feature, check out the `third party authentication documentation`. .. _social-auth-app-django: https://github.com/python-social-auth/social-app-django -.. _third party authentication documentation: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/tpa/index.html +.. _third party authentication documentation: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/tpa/index.html diff --git a/common/djangoapps/track/README.rst b/common/djangoapps/track/README.rst index 3472048286..80a62a2318 100644 --- a/common/djangoapps/track/README.rst +++ b/common/djangoapps/track/README.rst @@ -12,4 +12,4 @@ Glossary More Documentation ================== -`Events in the Tracking Logs `_ +`Events in the Tracking Logs `_ diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 1ec231eb6e..f994294a45 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/common/test/data/scoreable/about/overview.html b/common/test/data/scoreable/about/overview.html index d554b6a018..4c4e94bb9f 100644 --- a/common/test/data/scoreable/about/overview.html +++ b/common/test/data/scoreable/about/overview.html @@ -37,7 +37,7 @@

What web browser should I use?

The Open edX platform works best with current versions of Chrome, Firefox or Safari, or with Internet Explorer version 9 and above.

-

See our list of supported browsers for the most up-to-date information.

+

See our list of supported browsers for the most up-to-date information.

diff --git a/docs/concepts/extension_points.rst b/docs/concepts/extension_points.rst index 3136aa8057..ac70edc432 100644 --- a/docs/concepts/extension_points.rst +++ b/docs/concepts/extension_points.rst @@ -69,12 +69,12 @@ If you want to provide learners with new content experiences within courses, opt For a more detailed comparison of content integration options, see `Options for Extending the edX Platform`_ in the *Open edX Developer's Guide*. -.. _XBlock tutorial: https://edx.readthedocs.io/projects/xblock-tutorial/en/latest/ -.. _as a consumer: https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/lti_component.html -.. _as a provider: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/lti/ -.. _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 +.. _XBlock tutorial: https://docs.openedx.org/projects/xblock/en/latest/xblock-tutorial/index.html +.. _as a consumer: https://docs.openedx.org/en/latest/educators/navigation/components_activities.html#lti-component +.. _as a provider: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/lti/index.html +.. _Options for Extending the edX Platform: https://docs.openedx.org/en/latest/developers/references/developer_guide/extending_platform/extending.html +.. _custom JavaScript application: https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/custom_javascript.html +.. _external grader documentation: https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_external_graders.html .. _You can follow this guide to install and enable custom TinyMCE plugins: extensions/tinymce_plugins.rst @@ -150,7 +150,7 @@ Here are the different integration points that python plugins can use: .. _course tabs documentation: https://openedx.atlassian.net/wiki/spaces/AC/pages/30965919/Adding+a+new+course+tab .. |course_tools.py| replace:: ``course_tools.py`` .. _course_tools.py: https://github.com/openedx/edx-platform/blob/master/openedx/features/course_experience/course_tools.py -.. _Adding Custom Fields to the Registration Page: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/customize_registration_page.html +.. _Adding Custom Fields to the Registration Page: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/customize_registration_page.html .. |learning_context.py| replace:: ``learning_context.py`` .. _learning_context.py: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/xblock/learning_context/learning_context.py .. |UserPartition docstring| replace:: ``UserPartition`` docstring @@ -189,8 +189,8 @@ In addition, Open edX operators will be able to replace entire MFEs with complet .. |example edx theme| replace:: example ``edx`` theme .. _example edx theme: https://github.com/openedx/paragon/tree/master/scss/edx -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/ -.. _Overriding Brand Specific Elements: https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#overriding-brand-specific-elements +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/theming/index.html +.. _Overriding Brand Specific Elements: https://github.com/openedx/brand-openedx Custom frontends **************** diff --git a/docs/decisions/0001-courses-in-lms.rst b/docs/decisions/0001-courses-in-lms.rst index f4f931aaa0..3c191a464e 100644 --- a/docs/decisions/0001-courses-in-lms.rst +++ b/docs/decisions/0001-courses-in-lms.rst @@ -21,8 +21,8 @@ In the LMS, the following technologies can be used to access course content and .. _edX DDD Ubiquitous Language: https://openedx.atlassian.net/wiki/spaces/AC/pages/188032048/edX+DDD+Ubiquitous+Language .. _Course Overviews: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/content/course_overviews/__init__.py -.. _Course Blocks: https://openedx.atlassian.net/wiki/display/EDUCATOR/Course+Blocks -.. _Modulestore: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/modulestores/index.html +.. _Course Blocks: https://openedx.atlassian.net/wiki/spaces/AC/pages/158321366/Course+Blocks+aka+xblocks+components +.. _Modulestore: https://docs.openedx.org/projects/edx-platform/en/latest/references/docs/xmodule/modulestore/docs/overview.html Decisions ========= diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index fe3dce92a8..d6c77f4e72 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -880,7 +880,14 @@ def display_date_for_certificate(course, certificate): if _course_uses_available_date(course) and course.certificate_available_date < datetime.now(UTC): return course.certificate_available_date - elif course.certificates_display_behavior == CertificatesDisplayBehaviors.END and course.end: + # It is possible for a self-paced course run to end up configured with a display behavior of "END" even though it + # shouldn't be a valid option. We must check if the course is instructor-paced here to ensure that we are selecting + # the correct date to display. + elif ( + not course.self_paced + and course.certificates_display_behavior == CertificatesDisplayBehaviors.END + and course.end + ): return course.end else: return certificate.modified_date diff --git a/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst b/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst index 191a8d1d88..fd3112580f 100644 --- a/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst +++ b/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst @@ -117,9 +117,9 @@ Related DEPR (edX deprecation process) tickets: * `Remove PDF generation code`_ * `Remove PDF view code`_ -.. _Enable Course Certificates: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/enable_certificates.html +.. _Enable Course Certificates: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/enable_certificates.html .. _Deprecate web certificate setting: https://github.com/openedx/edx-platform/pull/17285 .. _Disable PDF certificate generation: https://github.com/openedx/edx-platform/pull/19833 -.. _Set Up Certificates in Studio: https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/set_up_course/studio_add_course_information/studio_creating_certificates.html +.. _Set Up Certificates in Studio: https://docs.openedx.org/en/latest/educators/how-tos/set_up_course/manage_certificates.html .. _Remove PDF generation code: https://openedx.atlassian.net/browse/DEPR-155 .. _Remove PDF view code: https://openedx.atlassian.net/browse/DEPR-157 diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index 11fc102fda..cdc9bc9326 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -1141,6 +1141,7 @@ class CertificatesApiTestCase(TestCase): certificate instance when the display behavior is set to EARLY_NO_INFO. """ with configure_waffle_namespace(True): + self.course.self_paced = False self.course.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO assert display_date_for_certificate(self.course, self.certificate) == self.certificate.modified_date @@ -1150,6 +1151,7 @@ class CertificatesApiTestCase(TestCase): associated with the course when the display behavior is set to END_WITH_DATE. """ with configure_waffle_namespace(True): + self.course.self_paced = False self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END_WITH_DATE self.course.certificate_available_date = datetime(2017, 2, 1, tzinfo=pytz.UTC) assert display_date_for_certificate(self.course, self.certificate) == self.course.certificate_available_date @@ -1160,6 +1162,7 @@ class CertificatesApiTestCase(TestCase): when the display behavior is set to END. """ with configure_waffle_namespace(True): + self.course.self_paced = False self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END assert display_date_for_certificate(self.course, self.certificate) == self.course.end @@ -1172,6 +1175,27 @@ class CertificatesApiTestCase(TestCase): self.certificate.date_override = datetime(2016, 1, 1, tzinfo=pytz.UTC) assert display_date_for_certificate(self.course, self.certificate) == self.certificate.date_override.date + def test_display_date_for_self_paced_course_run(self): + """ + Test to verify that the "earned date" displayed on a course certificate is the last modified date of a + certificate instance when the display behavior is set to EARLY_NO_INFO and the course run is self-paced. + """ + with configure_waffle_namespace(True): + self.course.self_paced = True + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO + assert display_date_for_certificate(self.course, self.certificate) == self.certificate.modified_date + + def test_display_date_for_self_paced_course_run_with_cdb_end(self): + """ + Test for a bug fix and some defensive coding. It is possible for self-paced course runs to end up with a display + behavior of END. This test ensures that we select the correct issue date even when the course run's + configuration is unexpected. + """ + with configure_waffle_namespace(True): + self.course.self_paced = True + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END + assert display_date_for_certificate(self.course, self.certificate) == self.certificate.modified_date + @ddt.ddt class CertificatesMessagingTestCase(ModuleStoreTestCase): diff --git a/lms/djangoapps/grades/docs/background.rst b/lms/djangoapps/grades/docs/background.rst index ef10145538..0db97fc5ed 100644 --- a/lms/djangoapps/grades/docs/background.rst +++ b/lms/djangoapps/grades/docs/background.rst @@ -79,13 +79,13 @@ Overall Course Grade - A learner's overall numerical grade in the course can range anywhere between 0% to 100%. - - Course teams set the `grade range `_ and specify the Pass / Fail threshold (for example, a minimum of 50/100 is required to Pass). + - Course teams set the `grade range `_ and specify the Pass / Fail threshold (for example, a minimum of 50/100 is required to Pass). - The Passing grade range can be further divided into letter grades, such as A, B, etc. Assignment Weights -* Course teams set the `assignment types `_ used in the course, along with their weights and the number of allowed drops (number of assignments with the lowest grades that can be discarded in the final grade computation). +* Course teams set the `assignment types `_ used in the course, along with their weights and the number of allowed drops (number of assignments with the lowest grades that can be discarded in the final grade computation). Computation @@ -118,9 +118,9 @@ Problem Scores - **automatically scored, synchronously** at the time of submission, such as for most Capa-based problems - - **automatically scored, asynchronously** via an `external grader service `_ + - **automatically scored, asynchronously** via an `external grader service `_ - - **manually scored**, such as for `Open Response Assessments `_, where the calculation requires human input from either + - **manually scored**, such as for `Open Response Assessments `_, where the calculation requires human input from either - a single course staff (staff assessment) @@ -153,19 +153,19 @@ As described above in the Grade Computation section, the grading policy is distr - A problem's external grader configuration - - A problem's individual grading policy - as currently supported by `ORA's assessment configuration `_ + - A problem's individual grading policy - as currently supported by `ORA's assessment configuration `_ Grade Overrides/Exceptions -------------------------- -Today, we support the following features to `adjust grades `_, but don't have a general feature to override a grade for any xBlock: +Today, we support the following features to `adjust grades `_, but don't have a general feature to override a grade for any xBlock: -* In `ORA Studio settings `_: +* In `ORA Studio settings `_: - override a learner's grade for an ORA2 block -* In LMS Instructor Dashboard or `Staff Debug Info `_: +* In LMS Instructor Dashboard or `Staff Debug Info `_: - reset the number of attempts a learner has made for a problem back to 0 @@ -173,7 +173,7 @@ Today, we support the following features to `adjust grades `_: +* In `Gradebook `_: - override a subsection grade for a learner - override subsection grades in bulk (master's track only) diff --git a/lms/djangoapps/ora_staff_grader/views.py b/lms/djangoapps/ora_staff_grader/views.py index 16c84ca27a..50a1e03196 100644 --- a/lms/djangoapps/ora_staff_grader/views.py +++ b/lms/djangoapps/ora_staff_grader/views.py @@ -105,7 +105,7 @@ class InitializeView(StaffGraderBaseView): # This toggle is documented on the edx-ora2 repo in openassessment/xblock/config_mixin.py # Note: Do not copy this practice of directly using a toggle from a library. # Instead, see docs for exposing a wrapper api: - # https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/implement_the_right_toggle_type.html#using-other-toggles pylint: disable=line-too-long + # https://docs.openedx.org/projects/edx-toggles/en/latest/how_to/implement_the_right_toggle_type.html#using-other-toggles pylint: disable=line-too-long # pylint: disable=toggle-missing-annotation enhanced_staff_grader_flag = CourseWaffleFlag( f"{WAFFLE_NAMESPACE}.{ENHANCED_STAFF_GRADER}", diff --git a/lms/envs/common.py b/lms/envs/common.py index 6b9d516eeb..316dad98da 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -320,7 +320,7 @@ FEATURES = { # .. toggle_default: False # .. toggle_description: Set to True to enable Custom Courses for edX, a feature that is more commonly known as # CCX. Documentation for configuring and using this feature is available at - # https://edx.readthedocs.io/projects/open-edx-ca/en/latest/set_up_course/custom_courses.html + # https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/enable_ccx.html # .. toggle_warning: When set to true, 'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider' will # be added to MODULESTORE_FIELD_OVERRIDE_PROVIDERS # .. toggle_use_cases: opt_in, circuit_breaker @@ -633,7 +633,7 @@ FEATURES = { # .. toggle_description: Set to True to enable course certificates on your instance of Open edX. # .. toggle_warning: You must enable this feature flag in both Studio and the LMS and complete the configuration tasks # described here: - # https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/enable_certificates.html pylint: disable=line-too-long,useless-suppression + # https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/enable_certificates.html pylint: disable=line-too-long,useless-suppression # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2015-03-13 # .. toggle_target_removal_date: None @@ -706,7 +706,7 @@ FEATURES = { # and applications. # .. toggle_warning: After enabling this feature flag there are multiple steps involved to configure edX # as LTI provider. Full guide is available here: - # https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/lti/index.html + # https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/lti/index.html # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2015-04-24 # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/7689 @@ -2055,8 +2055,7 @@ LOCALE_PATHS = Derived(_make_locale_paths) MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' # Guidelines for translators -TRANSLATORS_GUIDE = 'https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/' \ - 'conventions/internationalization/i18n_translators_guide.html' +TRANSLATORS_GUIDE = 'https://docs.openedx.org/en/latest/translators/index.html' #################################### AWS ####################################### # The number of seconds that a generated URL is valid for. @@ -5257,16 +5256,16 @@ SHOW_ACCOUNT_ACTIVATION_CTA = False ################# Documentation links for course apps ################# # pylint: disable=line-too-long -CALCULATOR_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/calculator.html" -DISCUSSIONS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_components/create_discussion.html" -EDXNOTES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/notes.html" -PROGRESS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html?highlight=progress#hiding-or-showing-the-wiki-or-progress-pages" -TEAMS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_features/teams/teams_setup.html" -TEXTBOOKS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/textbooks.html" -WIKI_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/course_wiki.html" -CUSTOM_PAGES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#adding-custom-pages" -COURSE_BULK_EMAIL_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_live_course/bulk_email.html" -ORA_SETTINGS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#configuring-course-level-open-response-assessment-settings" +CALCULATOR_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_calculator.html" +DISCUSSIONS_HELP_URL = "https://docs.openedx.org/en/latest/educators/concepts/communication/about_course_discussions.html" +EDXNOTES_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/enable_notes.html" +PROGRESS_HELP_URL = "https://docs.openedx.org/en/latest/educators/references/data/progress_page.html" +TEAMS_HELP_URL = "https://docs.openedx.org/en/latest/educators/navigation/advanced_features.html#use-teams-in-your-course" +TEXTBOOKS_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_textbooks.html" +WIKI_HELP_URL = "https://docs.openedx.org/en/latest/educators/concepts/communication/about_course_wiki.html" +CUSTOM_PAGES_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_custom_page.html" +COURSE_BULK_EMAIL_HELP_URL = "https://docs.openedx.org/en/latest/educators/references/communication/bulk_email.html" +ORA_SETTINGS_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/Manage_ORA_Assignment.html" ################# Bulk Course Email Settings ################# # If set, recipients of bulk course email messages will be filtered based on the last_login date of their User account. @@ -5425,7 +5424,7 @@ def _should_send_learning_badge_events(settings): # Each topic configuration dictionary contains # * `enabled`: a toggle denoting whether the event will be published to the topic. These should be annotated # according to -# https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html +# https://docs.openedx.org/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html # * `event_key_field` which is a period-delimited string path to event data field to use as event key. # Note: The topic names should not include environment prefix as it will be dynamically added based on # EVENT_BUS_TOPIC_PREFIX setting. diff --git a/lms/static/js/fixtures/calculator.html b/lms/static/js/fixtures/calculator.html index 2a6f189590..2bbff75422 100644 --- a/lms/static/js/fixtures/calculator.html +++ b/lms/static/js/fixtures/calculator.html @@ -13,8 +13,8 @@
  • For detailed information, see - Entering Mathematical and Scientific Expressions in the - EdX Learner's Guide. + Entering Mathematical and Scientific Expressions in the + Open edX Learner's Guide.

  • diff --git a/lms/templates/calculator/toggle_calculator.html b/lms/templates/calculator/toggle_calculator.html index d4a2401fee..b37d10fa3e 100644 --- a/lms/templates/calculator/toggle_calculator.html +++ b/lms/templates/calculator/toggle_calculator.html @@ -24,10 +24,10 @@ from openedx.core.djangolib.markup import HTML, Text

    ${_('Use the arrow keys to navigate the tips or use the tab key to return to the calculator')}

    - ${Text(_("For detailed information, see {math_link_start}Entering Mathematical and Scientific Expressions{math_link_end} in the {guide_link_start}edX Guide for Students{guide_link_end}.")).format( - math_link_start=HTML(''), + ${Text(_("For detailed information, see {math_link_start}Entering Mathematical and Scientific Expressions{math_link_end} in the {guide_link_start}Open edX Guide for Students{guide_link_end}.")).format( + math_link_start=HTML(''), math_link_end=HTML(''), - guide_link_start=HTML(''), + guide_link_start=HTML(''), guide_link_end=HTML(''), )} diff --git a/lms/templates/certificates/_edx-accomplishment-print-help.html b/lms/templates/certificates/_edx-accomplishment-print-help.html index eae27d96b0..1e6a0d2279 100644 --- a/lms/templates/certificates/_edx-accomplishment-print-help.html +++ b/lms/templates/certificates/_edx-accomplishment-print-help.html @@ -8,7 +8,7 @@ from openedx.core.djangolib.markup import HTML, Text

    diff --git a/lms/templates/split_test_author_view.html b/lms/templates/split_test_author_view.html index 0c90081cdd..ddadf055ff 100644 --- a/lms/templates/split_test_author_view.html +++ b/lms/templates/split_test_author_view.html @@ -17,7 +17,7 @@ show_link = group_configuration_url is not None

    ${Text(_("This content experiment uses group configuration '{group_configuration_name}'.")).format( - group_configuration_name=Text(HTML("{}")).format(group_configuration_url, user_partition.name) if show_link else user_partition.name + group_configuration_name=Text(HTML("{}")).format(group_configuration_url, user_partition.name) if show_link else user_partition.name )}

    diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 14c7f712dc..0610157519 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -6,11 +6,12 @@ from __future__ import annotations import logging from hashlib import blake2b -from django.utils.text import slugify from django.core.exceptions import ObjectDoesNotExist +from django.utils.text import slugify from opaque_keys.edx.keys import LearningContextKey, UsageKey +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2 from openedx_learning.api import authoring as authoring_api -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator +from openedx_learning.api.authoring_models import Collection from rest_framework.exceptions import NotFound from openedx.core.djangoapps.content.search.models import SearchAccess @@ -19,7 +20,6 @@ from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangoapps.xblock.data import LatestVersion -from openedx_learning.api.authoring_models import Collection log = logging.getLogger(__name__) @@ -554,7 +554,7 @@ def searchable_doc_for_container( ) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine - like Meilisearch or Elasticsearch, so that the given collection can be + like Meilisearch or Elasticsearch, so that the given container can be found using faceted search. If no container is found for the given container key, the returned document @@ -572,33 +572,38 @@ def searchable_doc_for_container( Fields.usage_key: str(container_key), # Field name isn't exact but this is the closest match Fields.block_id: container_key.container_id, # Field name isn't exact but this is the closest match Fields.access_id: _meili_access_id_from_context_key(container_key.library_key), + Fields.publish_status: PublishStatus.never, } try: container = lib_api.get_container(container_key) - except lib_api.ContentLibraryCollectionNotFound: + except lib_api.ContentLibraryContainerNotFound: # Container not found, so we can only return the base doc - pass + return doc - if container: - # TODO: check if there's a more efficient way to load these num_children counts? - draft_num_children = len(lib_api.get_container_children(container_key, published=False)) + draft_num_children = lib_api.get_container_children_count(container_key, published=False) + publish_status = PublishStatus.published + if container.last_published is None: + publish_status = PublishStatus.never + elif container.has_unpublished_changes: + publish_status = PublishStatus.modified - doc.update({ - Fields.display_name: container.display_name, - Fields.created: container.created.timestamp(), - Fields.modified: container.modified.timestamp(), - Fields.num_children: draft_num_children, - }) - library = lib_api.get_library(container_key.library_key) - if library: - doc[Fields.breadcrumbs] = [{"display_name": library.title}] + doc.update({ + Fields.display_name: container.display_name, + Fields.created: container.created.timestamp(), + Fields.modified: container.modified.timestamp(), + Fields.num_children: draft_num_children, + Fields.publish_status: publish_status, + }) + library = lib_api.get_library(container_key.library_key) + if library: + doc[Fields.breadcrumbs] = [{"display_name": library.title}] - if container.published_version_num is not None: - published_num_children = len(lib_api.get_container_children(container_key, published=True)) - doc[Fields.published] = { - # Fields.published_display_name: container_published.title, TODO: set the published title - Fields.published_num_children: published_num_children, - } + if container.published_version_num is not None: + published_num_children = lib_api.get_container_children_count(container_key, published=True) + doc[Fields.published] = { + # Fields.published_display_name: container_published.title, TODO: set the published title + Fields.published_num_children: published_num_children, + } return doc diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 2decf2374f..813db0241d 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -227,6 +227,7 @@ class TestSearchApi(ModuleStoreTestCase): "display_name": "Unit 1", # description is not set for containers "num_children": 0, + "publish_status": "never", "context_key": "lib:org1:lib", "org": "org1", "created": created_date.timestamp(), diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 38b1d607ab..a2964436d0 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -3,12 +3,13 @@ Tests for the Studio content search documents (what gets stored in the index) """ from dataclasses import replace from datetime import datetime, timezone -from organizations.models import Organization from freezegun import freeze_time +from openedx_learning.api import authoring as authoring_api +from organizations.models import Organization -from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.content_libraries import api as library_api +from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -17,13 +18,13 @@ from xmodule.modulestore.tests.factories import BlockFactory, ToyCourseFactory try: # This import errors in the lms because content.search is not an installed app there. from ..documents import ( - searchable_doc_for_course_block, - searchable_doc_tags, - searchable_doc_tags_for_collection, searchable_doc_collections, searchable_doc_for_collection, searchable_doc_for_container, + searchable_doc_for_course_block, searchable_doc_for_library_block, + searchable_doc_tags, + searchable_doc_tags_for_collection, ) from ..models import SearchAccess except RuntimeError: @@ -522,6 +523,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): "display_name": "A Unit in the Search Index", # description is not set for containers "num_children": 0, + "publish_status": "never", "context_key": "lib:edX:2012_Fall", "access_id": self.library_access_id, "breadcrumbs": [{"display_name": "some content_library"}], @@ -531,6 +533,106 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): # "published" is not set since we haven't published it yet } + def test_published_container(self): + """ + Test creating a search document for a published container + """ + created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(created_date): + container_meta = library_api.create_container( + self.library.key, + container_type=library_api.ContainerType.Unit, + slug="unit1", + title="A Unit in the Search Index", + user_id=None, + ) + library_api.update_container_children( + container_meta.container_key, + [self.library_block.usage_key], + user_id=None, + ) + library_api.publish_changes(self.library.key) + + doc = searchable_doc_for_container(container_meta.container_key) + + assert doc == { + "id": "lctedx2012_fallunitunit1-edd13a0c", + "block_id": "unit1", + "block_type": "unit", + "usage_key": "lct:edX:2012_Fall:unit:unit1", + "type": "library_container", + "org": "edX", + "display_name": "A Unit in the Search Index", + # description is not set for containers + "num_children": 1, + "publish_status": "published", + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + "published": {"num_children": 1}, + # "tags" should be here but we haven't implemented them yet + # "published" is not set since we haven't published it yet + } + + def test_published_container_with_changes(self): + """ + Test creating a search document for a published container + """ + created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(created_date): + container_meta = library_api.create_container( + self.library.key, + container_type=library_api.ContainerType.Unit, + slug="unit1", + title="A Unit in the Search Index", + user_id=None, + ) + library_api.update_container_children( + container_meta.container_key, + [self.library_block.usage_key], + user_id=None, + ) + library_api.publish_changes(self.library.key) + block_2 = library_api.create_library_block( + self.library.key, + "html", + "text3", + ) + + # Add another component after publish + with freeze_time(created_date): + library_api.update_container_children( + container_meta.container_key, + [block_2.usage_key], + user_id=None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + doc = searchable_doc_for_container(container_meta.container_key) + + assert doc == { + "id": "lctedx2012_fallunitunit1-edd13a0c", + "block_id": "unit1", + "block_type": "unit", + "usage_key": "lct:edX:2012_Fall:unit:unit1", + "type": "library_container", + "org": "edX", + "display_name": "A Unit in the Search Index", + # description is not set for containers + "num_children": 2, + "publish_status": "modified", + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + "published": {"num_children": 1}, + # "tags" should be here but we haven't implemented them yet + # "published" is not set since we haven't published it yet + } + def test_mathjax_plain_text_conversion_for_search(self): """ Test how an HTML block with mathjax equations gets converted to plain text in search description. diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 5b24b6540b..c03cee2b84 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -2,6 +2,7 @@ API for containers (Sections, Subsections, Units) in Content Libraries """ from __future__ import annotations + from dataclasses import dataclass from datetime import datetime from enum import Enum @@ -9,10 +10,11 @@ from uuid import uuid4 from django.utils.text import slugify from opaque_keys.edx.locator import ( - LibraryLocatorV2, LibraryContainerLocator, + LibraryLocatorV2, + UsageKeyV2, + LibraryUsageLocatorV2, ) - from openedx_events.content_authoring.data import LibraryContainerData from openedx_events.content_authoring.signals import ( LIBRARY_CONTAINER_CREATED, @@ -22,8 +24,10 @@ from openedx_events.content_authoring.signals import ( from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Container +from openedx.core.djangoapps.xblock.api import get_component_from_usage_key + from ..models import ContentLibrary -from .libraries import PublishableItem +from .libraries import LibraryXBlockMetadata, PublishableItem # The public API is only the following symbols: @@ -34,9 +38,12 @@ __all__ = [ "get_container", "create_container", "get_container_children", + "get_container_children_count", "library_container_locator", "update_container", "delete_container", + "update_container_children", + "get_containers_contains_component", ] @@ -252,14 +259,79 @@ def get_container_children( """ Get the entities contained in the given container (e.g. the components/xblocks in a unit) """ - assert isinstance(container_key, LibraryContainerLocator) - content_library = ContentLibrary.objects.get_by_key(container_key.library_key) - learning_package = content_library.learning_package - assert learning_package is not None - container = authoring_api.get_container_by_key( - learning_package.id, - key=container_key.container_id, + container = _get_container(container_key) + if container_key.container_type == ContainerType.Unit.value: + child_components = authoring_api.get_components_in_unit(container.unit, published=published) + return [LibraryXBlockMetadata.from_component( + container_key.library_key, + entry.component + ) for entry in child_components] + else: + child_entities = authoring_api.get_entities_in_container(container, published=published) + return [ContainerMetadata.from_container( + container_key.library_key, + entry.entity + ) for entry in child_entities] + + +def get_container_children_count( + container_key: LibraryContainerLocator, + published=False, +) -> int: + """ + Get the count of entities contained in the given container (e.g. the components/xblocks in a unit) + """ + container = _get_container(container_key) + return authoring_api.get_container_children_count(container, published=published) + + +def update_container_children( + container_key: LibraryContainerLocator, + children_ids: list[UsageKeyV2] | list[LibraryContainerLocator], + user_id: int | None, + entities_action: authoring_api.ChildrenEntitiesAction = authoring_api.ChildrenEntitiesAction.REPLACE, +): + """ + Adds children components or containers to given container. + """ + library_key = container_key.library_key + container_type = container_key.container_type + container = _get_container(container_key) + match container_type: + case ContainerType.Unit.value: + components = [get_component_from_usage_key(key) for key in children_ids] # type: ignore[arg-type] + new_version = authoring_api.create_next_unit_version( + container.unit, + components=components, # type: ignore[arg-type] + created=datetime.now(), + created_by=user_id, + entities_action=entities_action, + ) + case _: + raise ValueError(f"Invalid container type: {container_type}") + + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=library_key, + container_key=str(container_key), + ) ) - child_entities = authoring_api.get_entities_in_container(container, published=published) - # TODO: convert the return type to list[ContainerMetadata | LibraryXBlockMetadata] ? - return child_entities + + return ContainerMetadata.from_container(library_key, new_version.container) + + +def get_containers_contains_component( + usage_key: LibraryUsageLocatorV2 +) -> list[ContainerMetadata]: + """ + Get containers that contains the component. + """ + assert isinstance(usage_key, LibraryUsageLocatorV2) + component = get_component_from_usage_key(usage_key) + containers = authoring_api.get_containers_with_entity( + component.publishable_entity.pk, + ) + return [ + ContainerMetadata.from_container(usage_key.context_key, container) + for container in containers + ] diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index 13d41921e8..3884614ae4 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -82,6 +82,7 @@ from openedx_events.content_authoring.data import ( ContentLibraryData, LibraryBlockData, LibraryCollectionData, + LibraryContainerData, ContentObjectChangedData, ) from openedx_events.content_authoring.signals import ( @@ -92,6 +93,7 @@ from openedx_events.content_authoring.signals import ( LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_UPDATED, CONTENT_OBJECT_ASSOCIATIONS_CHANGED, ) from openedx_learning.api import authoring as authoring_api @@ -113,6 +115,7 @@ from openedx.core.djangoapps.xblock.api import ( xblock_type_display_name, ) from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core +from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.types import User as UserType from xmodule.modulestore.django import modulestore @@ -302,6 +305,7 @@ class PublishableItem(LibraryItem): last_draft_created_by: str = "" has_unpublished_changes: bool = False collections: list[CollectionMetadata] = field(default_factory=list) + can_stand_alone: bool = True @dataclass(frozen=True, kw_only=True) @@ -343,6 +347,7 @@ class LibraryXBlockMetadata(PublishableItem): last_draft_created_by=last_draft_created_by, has_unpublished_changes=component.versioning.has_unpublished_changes, collections=associated_collections or [], + can_stand_alone=component.publishable_entity.can_stand_alone, ) @@ -899,6 +904,18 @@ def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) -> ) ) + # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger + # container indexing asynchronously. + affected_containers = lib_api.get_containers_contains_component(usage_key) + for container in affected_containers: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=usage_key.lib_key, + container_key=str(container.container_key), + background=True, + ) + ) + return new_component_version @@ -958,9 +975,17 @@ def validate_can_add_block_to_library( return content_library, usage_key -def create_library_block(library_key, block_type, definition_id, user_id=None): +def create_library_block( + library_key: LibraryLocatorV2, + block_type: str, + definition_id: str, + user_id: int | None = None, + can_stand_alone: bool = True, +): """ Create a new XBlock in this library of the specified type (e.g. "html"). + + Set can_stand_alone = False when a component is created under a container, like unit. """ # It's in the serializer as ``definition_id``, but for our purposes, it's # the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for @@ -969,7 +994,7 @@ def create_library_block(library_key, block_type, definition_id, user_id=None): content_library, usage_key = validate_can_add_block_to_library(library_key, block_type, block_id) - _create_component_for_block(content_library, usage_key, user_id) + _create_component_for_block(content_library, usage_key, user_id, can_stand_alone) # Now return the metadata about the new block: LIBRARY_BLOCK_CREATED.send_event( @@ -1135,6 +1160,7 @@ def _create_component_for_block( content_lib: ContentLibrary, usage_key: LibraryUsageLocatorV2, user_id: int | None = None, + can_stand_alone: bool = True, ): """ Create a Component for an XBlock type, initialize it, and return the ComponentVersion. @@ -1144,6 +1170,8 @@ def _create_component_for_block( will be set as the current draft. This function does not publish the Component. + Set can_stand_alone = False when a component is created under a container, like unit. + TODO: We should probably shift this to openedx.core.djangoapps.xblock.api (along with its caller) since it gives runtime storage specifics. The Library-specific logic stays in this module, so "create a block for my lib" @@ -1168,6 +1196,7 @@ def _create_component_for_block( title=display_name, created=now, created_by=user_id, + can_stand_alone=can_stand_alone, ) content = authoring_api.get_or_create_text_content( learning_package.id, @@ -1191,6 +1220,7 @@ def delete_library_block(usage_key: LibraryUsageLocatorV2, remove_from_parent=Tr component = get_component_from_usage_key(usage_key) library_key = usage_key.context_key affected_collections = authoring_api.get_entity_collections(component.learning_package_id, component.key) + affected_containers = lib_api.get_containers_contains_component(usage_key) authoring_api.soft_delete_draft(component.pk) @@ -1214,6 +1244,19 @@ def delete_library_block(usage_key: LibraryUsageLocatorV2, remove_from_parent=Tr ) ) + # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger + # container indexing asynchronously. + # + # To update the components count in containers + for container in affected_containers: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=library_key, + container_key=str(container.container_key), + background=True, + ) + ) + def restore_library_block(usage_key: LibraryUsageLocatorV2) -> None: """ diff --git a/openedx/core/djangoapps/content_libraries/library_context.py b/openedx/core/djangoapps/content_libraries/library_context.py index 4bda10eb12..d5f121d839 100644 --- a/openedx/core/djangoapps/content_libraries/library_context.py +++ b/openedx/core/djangoapps/content_libraries/library_context.py @@ -6,8 +6,8 @@ import logging from django.core.exceptions import PermissionDenied from rest_framework.exceptions import NotFound -from openedx_events.content_authoring.data import LibraryBlockData -from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED +from openedx_events.content_authoring.data import LibraryBlockData, LibraryContainerData +from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED, LIBRARY_CONTAINER_UPDATED from opaque_keys.edx.keys import UsageKeyV2 from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2 from openedx_learning.api import authoring as authoring_api @@ -114,3 +114,19 @@ class LibraryContextImpl(LearningContext): usage_key=usage_key, ) ) + + def send_container_updated_events(self, usage_key: UsageKeyV2): + """ + Send "container updated" events for containers that contains the library block + with the given usage_key. + """ + assert isinstance(usage_key, LibraryUsageLocatorV2) + affected_containers = api.get_containers_contains_component(usage_key) + for container in affected_containers: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=usage_key.lib_key, + container_key=str(container.container_key), + background=True, + ) + ) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/collections.py b/openedx/core/djangoapps/content_libraries/rest_api/collections.py index f1b63b2c18..c49822ae2f 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/collections.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/collections.py @@ -21,8 +21,8 @@ from ..models import ContentLibrary from .utils import convert_exceptions from .serializers import ( ContentLibraryCollectionSerializer, - ContentLibraryCollectionComponentsUpdateSerializer, ContentLibraryCollectionUpdateSerializer, + ContentLibraryComponentKeysSerializer, ) from openedx.core.types.http import RestRequest @@ -200,7 +200,7 @@ class LibraryCollectionsView(ModelViewSet): content_library = self.get_content_library() collection_key = kwargs["key"] - serializer = ContentLibraryCollectionComponentsUpdateSerializer(data=request.data) + serializer = ContentLibraryComponentKeysSerializer(data=request.data) serializer.is_valid(raise_exception=True) usage_keys = serializer.validated_data["usage_keys"] diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index ad23a51d55..95e468b4a4 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -8,10 +8,10 @@ import logging from django.contrib.auth import get_user_model from django.db.transaction import non_atomic_requests from django.utils.decorators import method_decorator -from django.utils.translation import gettext as _ from drf_yasg.utils import swagger_auto_schema from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator +from openedx_learning.api import authoring as authoring_api from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.status import HTTP_204_NO_CONTENT @@ -124,3 +124,152 @@ class LibraryContainerView(GenericAPIView): ) return Response({}, status=HTTP_204_NO_CONTENT) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerChildrenView(GenericAPIView): + """ + View to get or update children of specific container (a section, subsection, or unit) + """ + serializer_class = serializers.LibraryXBlockMetadataSerializer + + @convert_exceptions + @swagger_auto_schema( + responses={200: list[serializers.LibraryXBlockMetadataSerializer]} + ) + def get(self, request, container_key: LibraryContainerLocator): + """ + Get children components of given container + Example: + GET /api/libraries/v2/containers//children/ + Result: + [ + { + 'block_type': 'problem', + 'can_stand_alone': True, + 'collections': [], + 'created': '2025-03-21T13:53:55Z', + 'def_key': None, + 'display_name': 'Blank Problem', + 'has_unpublished_changes': True, + 'id': 'lb:CL-TEST:containers:problem:Problem1', + 'last_draft_created': '2025-03-21T13:53:55Z', + 'last_draft_created_by': 'Bob', + 'last_published': None, + 'modified': '2025-03-21T13:53:55Z', + 'published_by': None, + }, + { + 'block_type': 'html', + 'can_stand_alone': False, + 'collections': [], + 'created': '2025-03-21T13:53:55Z', + 'def_key': None, + 'display_name': 'Text', + 'has_unpublished_changes': True, + 'id': 'lb:CL-TEST:containers:html:Html1', + 'last_draft_created': '2025-03-21T13:53:55Z', + 'last_draft_created_by': 'Bob', + 'last_published': None, + 'modified': '2025-03-21T13:53:55Z', + 'published_by': None, + } + ] + """ + published = request.GET.get('published', False) + api.require_permission_for_library_key( + container_key.library_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + child_entities = api.get_container_children(container_key, published) + if container_key.container_type == api.ContainerType.Unit.value: + data = serializers.LibraryXBlockMetadataSerializer(child_entities, many=True).data + else: + data = serializers.LibraryContainerMetadataSerializer(child_entities, many=True).data + return Response(data) + + def _update_component_children( + self, + request, + container_key: LibraryContainerLocator, + action: authoring_api.ChildrenEntitiesAction, + ): + """ + Helper function to update children in container. + """ + api.require_permission_for_library_key( + container_key.library_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + serializer = serializers.ContentLibraryComponentKeysSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + # Only components under units are supported for now. + assert container_key.container_type == api.ContainerType.Unit.value + + container = api.update_container_children( + container_key, + children_ids=serializer.validated_data["usage_keys"], + user_id=request.user.id, + entities_action=action, + ) + return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.ContentLibraryComponentKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def post(self, request, container_key: LibraryContainerLocator): + """ + Add components to unit + Example: + POST /api/libraries/v2/containers//children/ + Request body: + {"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']} + """ + return self._update_component_children( + request, + container_key, + action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.ContentLibraryComponentKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def delete(self, request, container_key: LibraryContainerLocator): + """ + Remove components from unit + Example: + DELETE /api/libraries/v2/containers//children/ + Request body: + {"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']} + """ + return self._update_component_children( + request, + container_key, + action=authoring_api.ChildrenEntitiesAction.REMOVE, + ) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.ContentLibraryComponentKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def patch(self, request, container_key: LibraryContainerLocator): + """ + Replace components in unit, can be used to reorder components as well. + Example: + PATCH /api/libraries/v2/containers//children/ + Request body: + {"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']} + """ + return self._update_component_children( + request, + container_key, + action=authoring_api.ChildrenEntitiesAction.REPLACE, + ) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index 67b8ec00e5..46bec36e3c 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -159,6 +159,7 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer): tags_count = serializers.IntegerField(read_only=True) collections = CollectionMetadataSerializer(many=True, required=False) + can_stand_alone = serializers.BooleanField(read_only=True) class LibraryXBlockTypeSerializer(serializers.Serializer): @@ -193,6 +194,9 @@ class LibraryXBlockCreationSerializer(serializers.Serializer): # creating new block from scratch staged_content = serializers.CharField(required=False) + # Optional param defaults to True, set to False if block is being created under a container. + can_stand_alone = serializers.BooleanField(required=False, default=True) + class LibraryPasteClipboardSerializer(serializers.Serializer): """ @@ -345,7 +349,7 @@ class UsageKeyV2Serializer(serializers.BaseSerializer): raise ValidationError from err -class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer): +class ContentLibraryComponentKeysSerializer(serializers.Serializer): """ Serializer for adding/removing Components to/from a Collection. """ diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index d8030afae7..6adb8184ea 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -33,6 +33,7 @@ URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specifie URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library +URL_LIB_CONTAINER_COMPONENTS = URL_LIB_CONTAINER + 'children/' # Get, add or delete a component in this container URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/' URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/' @@ -229,9 +230,21 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): expect_response ) - def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200): + def _add_block_to_library( + self, + lib_key, + block_type, + slug, + parent_block=None, + can_stand_alone=True, + expect_response=200, + ): """ Add a new XBlock to the library """ - data = {"block_type": block_type, "definition_id": slug} + data = { + "block_type": block_type, + "definition_id": slug, + "can_stand_alone": can_stand_alone, + } if parent_block: data["parent_block"] = parent_block return self._api('post', URL_LIB_BLOCKS.format(lib_key=lib_key), data, expect_response) @@ -372,3 +385,54 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): def _delete_container(self, container_key: str, expect_response=204): """ Delete a container (unit etc.) """ return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) + + def _get_container_components(self, container_key: str, expect_response=200): + """ Get container components""" + return self._api( + 'get', + URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key), + None, + expect_response + ) + + def _add_container_components( + self, + container_key: str, + children_ids: list[str], + expect_response=200, + ): + """ Add container components""" + return self._api( + 'post', + URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key), + {'usage_keys': children_ids}, + expect_response + ) + + def _remove_container_components( + self, + container_key: str, + children_ids: list[str], + expect_response=200, + ): + """ Remove container components""" + return self._api( + 'delete', + URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key), + {'usage_keys': children_ids}, + expect_response + ) + + def _patch_container_components( + self, + container_key: str, + children_ids: list[str], + expect_response=200, + ): + """ Update container components""" + return self._api( + 'patch', + URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key), + {'usage_keys': children_ids}, + expect_response + ) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index d4be6fdf6b..a1475aa1a0 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -16,12 +16,14 @@ from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_events.content_authoring.data import ( ContentObjectChangedData, LibraryCollectionData, + LibraryContainerData, ) from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_UPDATED, ) from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx_learning.api import authoring as authoring_api @@ -742,3 +744,109 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe }, event_receiver.call_args_list[1].kwargs, ) + + +class ContentLibraryContainersTest(ContentLibrariesRestApiTest, TestCase): + """ + Tests for Content Library API containers methods. + """ + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-cont-1", "Test Library 1") + + # Fetch the created ContentLibrare objects so we can access their learning_package.id + self.lib1 = ContentLibrary.objects.get(slug="test-lib-cont-1") + + # Create Units + self.unit1 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-1', 'Unit 1', None) + self.unit2 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-2', 'Unit 2', None) + + # Create XBlocks + # Create some library blocks in lib1 + self.problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problem1", + ) + self.problem_block_usage_key = UsageKey.from_string(self.problem_block["id"]) + self.html_block = self._add_block_to_library( + self.lib1.library_key, "html", "html1", + ) + self.html_block_usage_key = UsageKey.from_string(self.html_block["id"]) + + # Add content to units + api.update_container_children( + self.unit1.container_key, + [self.problem_block_usage_key, self.html_block_usage_key], + None, + ) + api.update_container_children( + self.unit2.container_key, + [self.html_block_usage_key], + None, + ) + + def test_get_containers_contains_component(self): + problem_block_containers = api.get_containers_contains_component(self.problem_block_usage_key) + html_block_containers = api.get_containers_contains_component(self.html_block_usage_key) + + assert len(problem_block_containers) == 1 + assert problem_block_containers[0].container_key == self.unit1.container_key + + assert len(html_block_containers) == 2 + assert html_block_containers[0].container_key == self.unit1.container_key + assert html_block_containers[1].container_key == self.unit2.container_key + + def _validate_calls_of_html_block(self, event_mock): + """ + Validate that the `event_mock` has been called twice + using the `LIBRARY_CONTAINER_UPDATED` signal. + """ + assert event_mock.call_count == 2 + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + library_key=self.lib1.library_key, + container_key=str(self.unit1.container_key), + background=True, + ) + }, + event_mock.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + library_key=self.lib1.library_key, + container_key=str(self.unit2.container_key), + background=True, + ) + }, + event_mock.call_args_list[1].kwargs, + ) + + def test_call_container_update_signal_when_delete_component(self): + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + api.delete_library_block(self.html_block_usage_key) + self._validate_calls_of_html_block(container_update_event_receiver) + + def test_call_container_update_signal_when_update_olx(self): + block_olx = "Hello world!" + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + self._set_library_block_olx(self.html_block_usage_key, block_olx) + self._validate_calls_of_html_block(container_update_event_receiver) + + def test_call_container_update_signal_when_update_component(self): + block_olx = "Hello world!" + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}}) + self._validate_calls_of_html_block(container_update_event_receiver) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 23a519899a..52546396f2 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -2,10 +2,10 @@ Tests for Learning-Core-based Content Libraries """ from datetime import datetime, timezone +from unittest import mock import ddt from freezegun import freeze_time -from unittest import mock from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_events.content_authoring.data import LibraryContainerData @@ -178,3 +178,155 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest): assert container1_data["container_key"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-") assert container2_data["container_key"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-") assert container1_data["container_key"] != container2_data["container_key"] + + def test_unit_add_children(self): + """ + Test that we can add and get unit children components + """ + update_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(update_receiver) + lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") + lib_key = LibraryLocatorV2.from_string(lib["id"]) + + # Create container and add some components + container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None) + problem_block = self._add_block_to_library(lib["id"], "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(lib["id"], "html", "Html1", can_stand_alone=False) + self._add_container_components( + container_data["container_key"], + children_ids=[problem_block["id"], html_block["id"]] + ) + data = self._get_container_components(container_data["container_key"]) + assert len(data) == 2 + assert data[0]['id'] == problem_block['id'] + assert not data[0]['can_stand_alone'] + assert data[1]['id'] == html_block['id'] + assert not data[1]['can_stand_alone'] + problem_block_2 = self._add_block_to_library(lib["id"], "problem", "Problem2", can_stand_alone=False) + html_block_2 = self._add_block_to_library(lib["id"], "html", "Html2") + # Add two more components + self._add_container_components( + container_data["container_key"], + children_ids=[problem_block_2["id"], html_block_2["id"]] + ) + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + lib_key, + container_key=container_data["container_key"], + ), + }, + update_receiver.call_args_list[0].kwargs, + ) + data = self._get_container_components(container_data["container_key"]) + # Verify total number of components to be 2 + 2 = 4 + assert len(data) == 4 + assert data[2]['id'] == problem_block_2['id'] + assert not data[2]['can_stand_alone'] + assert data[3]['id'] == html_block_2['id'] + assert data[3]['can_stand_alone'] + + def test_unit_remove_children(self): + """ + Test that we can remove unit children components + """ + update_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(update_receiver) + lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") + lib_key = LibraryLocatorV2.from_string(lib["id"]) + + # Create container and add some components + container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None) + problem_block = self._add_block_to_library(lib["id"], "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(lib["id"], "html", "Html1", can_stand_alone=False) + problem_block_2 = self._add_block_to_library(lib["id"], "problem", "Problem2", can_stand_alone=False) + html_block_2 = self._add_block_to_library(lib["id"], "html", "Html2") + self._add_container_components( + container_data["container_key"], + children_ids=[problem_block["id"], html_block["id"], problem_block_2["id"], html_block_2["id"]] + ) + data = self._get_container_components(container_data["container_key"]) + assert len(data) == 4 + # Remove both problem blocks. + self._remove_container_components( + container_data["container_key"], + children_ids=[problem_block_2["id"], problem_block["id"]] + ) + data = self._get_container_components(container_data["container_key"]) + assert len(data) == 2 + assert data[0]['id'] == html_block['id'] + assert data[1]['id'] == html_block_2['id'] + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + lib_key, + container_key=container_data["container_key"], + ), + }, + update_receiver.call_args_list[0].kwargs, + ) + + def test_unit_replace_children(self): + """ + Test that we can completely replace/reorder unit children components. + """ + update_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(update_receiver) + lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more") + lib_key = LibraryLocatorV2.from_string(lib["id"]) + + # Create container and add some components + container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None) + problem_block = self._add_block_to_library(lib["id"], "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(lib["id"], "html", "Html1", can_stand_alone=False) + problem_block_2 = self._add_block_to_library(lib["id"], "problem", "Problem2", can_stand_alone=False) + html_block_2 = self._add_block_to_library(lib["id"], "html", "Html2") + self._add_container_components( + container_data["container_key"], + children_ids=[problem_block["id"], html_block["id"], problem_block_2["id"], html_block_2["id"]] + ) + data = self._get_container_components(container_data["container_key"]) + assert len(data) == 4 + assert data[0]['id'] == problem_block['id'] + assert data[1]['id'] == html_block['id'] + assert data[2]['id'] == problem_block_2['id'] + assert data[3]['id'] == html_block_2['id'] + + # Reorder the components + self._patch_container_components( + container_data["container_key"], + children_ids=[problem_block["id"], problem_block_2["id"], html_block["id"], html_block_2["id"]] + ) + data = self._get_container_components(container_data["container_key"]) + assert len(data) == 4 + assert data[0]['id'] == problem_block['id'] + assert data[1]['id'] == problem_block_2['id'] + assert data[2]['id'] == html_block['id'] + assert data[3]['id'] == html_block_2['id'] + + # Replace with new components + new_problem_block = self._add_block_to_library(lib["id"], "problem", "New_Problem", can_stand_alone=False) + new_html_block = self._add_block_to_library(lib["id"], "html", "New_Html", can_stand_alone=False) + self._patch_container_components( + container_data["container_key"], + children_ids=[new_problem_block["id"], new_html_block["id"]], + ) + data = self._get_container_components(container_data["container_key"]) + assert len(data) == 2 + assert data[0]['id'] == new_problem_block['id'] + assert data[1]['id'] == new_html_block['id'] + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + lib_key, + container_key=container_data["container_key"], + ), + }, + update_receiver.call_args_list[0].kwargs, + ) diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 1656a85896..99e98a2cc4 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -80,6 +80,8 @@ urlpatterns = [ path('containers//', include([ # Get metadata about a specific container in this library, update or delete the container: path('', containers.LibraryContainerView.as_view()), + # update components under container + path('children/', containers.LibraryContainerChildrenView.as_view()), # Update collections for a given container # path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'), # path('publish/', views.LibraryContainerPublishView.as_view()), diff --git a/openedx/core/djangoapps/schedules/docs/README.rst b/openedx/core/djangoapps/schedules/docs/README.rst index 4e9f02ca39..04982d8971 100644 --- a/openedx/core/djangoapps/schedules/docs/README.rst +++ b/openedx/core/djangoapps/schedules/docs/README.rst @@ -66,7 +66,7 @@ Glossary plan on removing this term from this app's code to avoid confusion. - **Section**: From our - `documentation `__, + `documentation `__, “A section is the topmost category in your course. A section can represent a time period in your course, a chapter, or another organizing principle. A section contains one or more subsections.” @@ -155,7 +155,7 @@ Configuring A.C.E. These instructions assume you have already setup an Open edX instance or are running devstack. See the `Open edX Developer’s -Guide `__ +Guide `__ for information on setting them up. The Schedule app relies on ACE. When live, ACE sends emails to users diff --git a/openedx/core/djangoapps/session_inactivity_timeout/middleware.py b/openedx/core/djangoapps/session_inactivity_timeout/middleware.py index 79609e4766..08e9d0e0e8 100644 --- a/openedx/core/djangoapps/session_inactivity_timeout/middleware.py +++ b/openedx/core/djangoapps/session_inactivity_timeout/middleware.py @@ -9,7 +9,7 @@ To enable this feature, set in a settings.py: This was taken from StackOverflow (http://stackoverflow.com/questions/14830669/how-to-expire-django-session-in-5minutes) If left unset, session expiration will be handled by Django's SESSION_COOKIE_AGE, -which defauts to 1209600 (2 weeks, in seconds). +which defaults to 1209600 (2 weeks, in seconds). """ diff --git a/openedx/core/djangoapps/user_api/README.rst b/openedx/core/djangoapps/user_api/README.rst index 9e5508ff14..d38f47175e 100644 --- a/openedx/core/djangoapps/user_api/README.rst +++ b/openedx/core/djangoapps/user_api/README.rst @@ -51,4 +51,4 @@ An example request using *curl*, storing information in a field named ``occupati It is important to note that this data will not be returned as part of the User API until the system's Site Configuration has been updated. Details on how to update the Site Configuration can be found `here`_. -.. _here: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/retrieve_extended_profile_metadata.html +.. _here: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/retrieve_extended_profile_metadata.html diff --git a/openedx/core/djangoapps/xblock/learning_context/learning_context.py b/openedx/core/djangoapps/xblock/learning_context/learning_context.py index b535e84ca7..dc7a21f1c3 100644 --- a/openedx/core/djangoapps/xblock/learning_context/learning_context.py +++ b/openedx/core/djangoapps/xblock/learning_context/learning_context.py @@ -76,3 +76,11 @@ class LearningContext: usage_key: the UsageKeyV2 subclass used for this learning context """ + + def send_container_updated_events(self, usage_key): + """ + Send "container updated" events for containers that contains the block with + the given usage_key in this context. + + usage_key: the UsageKeyV2 subclass used for this learning context + """ diff --git a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py index c3885fbf11..57582989f6 100644 --- a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py @@ -317,6 +317,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime): # Signal that we've modified this block learning_context = get_learning_context_impl(usage_key) learning_context.send_block_updated_event(usage_key) + learning_context.send_container_updated_events(usage_key) def _get_component_from_usage_key(self, usage_key): """ diff --git a/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst b/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst index 8305af4617..28e357e9e4 100644 --- a/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst +++ b/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst @@ -20,7 +20,7 @@ Additionally, a subset of ignored errors that are configured as ignored will als * Using New Relic terminology, this extra error class and message data will live on the Transaction and not the TransactionError, because ignored errors won't have a TransactionError. * Use these additional custom attributes to help diagnose unexpected issues with ignored errors. -.. _IGNORED_ERRORS settings and toggles on Readthedocs: https://edx.readthedocs.io/projects/edx-platform-technical/en/latest/search.html?q=IGNORED_ERRORS&check_keywords=yes&area=default +.. _IGNORED_ERRORS settings and toggles on Readthedocs: https://docs.openedx.org/projects/edx-platform/en/latest/search.html?q=IGNORED_ERRORS Logging ignored errors ----------------------- diff --git a/openedx/features/course_bookmarks/README.rst b/openedx/features/course_bookmarks/README.rst index 285e109341..774b3fd0e7 100644 --- a/openedx/features/course_bookmarks/README.rst +++ b/openedx/features/course_bookmarks/README.rst @@ -6,4 +6,4 @@ for a user to see all of their course bookmarks. It also registers a course tool called "Bookmarks" that provides a link to this page. For more information about the feature, see `Bookmarking Course Content`_. -.. _Bookmarking Course Content: https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/SFD_bookmarks.html +.. _Bookmarking Course Content: https://docs.openedx.org/en/latest/learners/SFD_bookmarks.html diff --git a/openedx/features/course_search/README.rst b/openedx/features/course_search/README.rst index c6925c2967..8d6e394c3e 100644 --- a/openedx/features/course_search/README.rst +++ b/openedx/features/course_search/README.rst @@ -4,4 +4,4 @@ Course Search This directory contains a Django application that allows a learner to search the content of their course. To learn more, see `Enabling Open edX Search`_. -.. _Enabling Open edX Search: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/edx_search.html +.. _Enabling Open edX Search: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/edx_search.html diff --git a/requirements/constraints.txt b/requirements/constraints.txt index fed7e47c54..2870901ee6 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -112,7 +112,7 @@ numpy<2.0.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.19.1 +openedx-learning==0.19.2 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 24ede4f1f2..fb768f360a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -820,7 +820,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.9 # via -r requirements/edx/kernel.in -openedx-learning==0.19.1 +openedx-learning==0.19.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c9acc77e89..aaecf53c55 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1383,7 +1383,7 @@ openedx-forum==0.1.9 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-learning==0.19.1 +openedx-learning==0.19.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index afe2bedf59..6a22620520 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -992,7 +992,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.9 # via -r requirements/edx/base.txt -openedx-learning==0.19.1 +openedx-learning==0.19.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 795e66c61a..a677d1ab17 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1050,7 +1050,7 @@ openedx-filters==2.0.1 # ora2 openedx-forum==0.1.9 # via -r requirements/edx/base.txt -openedx-learning==0.19.1 +openedx-learning==0.19.2 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/scripts/xsslint/xss_linter.py b/scripts/xsslint/xss_linter.py index b32c54aa5c..76de199f09 100755 --- a/scripts/xsslint/xss_linter.py +++ b/scripts/xsslint/xss_linter.py @@ -258,7 +258,7 @@ def main(): epilog += "understand and fix any violations, read the docs here:\n" epilog += "\n" # pylint: disable=line-too-long - epilog += " https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/conventions/preventing_xss.html#xss-linter\n" + epilog += " https://docs.openedx.org/en/latest/developers/references/developer_guide/preventing_xss/preventing_xss.html#xss-linter\n" parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, diff --git a/test_root/semgrep/celery-code-owner.yml b/test_root/semgrep/celery-code-owner.yml index c8cf417e43..a1693752d2 100644 --- a/test_root/semgrep/celery-code-owner.yml +++ b/test_root/semgrep/celery-code-owner.yml @@ -5,7 +5,7 @@ rules: # https://github.com/returntocorp/semgrep/issues/8608 # # Here's the intended URL, for reference: - # https://edx.readthedocs.io/projects/edx-django-utils/en/latest/monitoring/how_tos/add_code_owner_custom_attribute_to_an_ida.html#handling-celery-tasks + # https://docs.openedx.org/projects/edx-django-utils/en/latest/monitoring/how_tos/add_code_owner_custom_attribute_to_an_ida.html#handling-celery-tasks message: | Celery tasks need to be decorated with `@set_code_owner_attribute` (from the `edx_django_utils.monitoring` module) in order for us @@ -13,7 +13,7 @@ rules: For more information, see the Celery section of "Add Code_Owner Custom Attributes to an IDA" in the Monitoring How-Tos of - . + . languages: - python patterns: @@ -68,7 +68,7 @@ rules: For more information, see the Celery section of "Add Code_Owner Custom Attributes to an IDA" in the Monitoring How-Tos of - . + . languages: - python patterns: diff --git a/themes/README.rst b/themes/README.rst index ad09d932ea..75bae91493 100644 --- a/themes/README.rst +++ b/themes/README.rst @@ -65,7 +65,7 @@ There are two example themes provided within edx-platform's themes directory: For more details, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html HTML Templates -------------- diff --git a/themes/open-edx/README.rst b/themes/open-edx/README.rst index 5e4f97acdf..2293941935 100644 --- a/themes/open-edx/README.rst +++ b/themes/open-edx/README.rst @@ -7,8 +7,10 @@ provide any overrides, which means that it adopts the built-in themes, templates etc. The `Red Theme`_ is provided as an example of building a simple new theme. +Another is `Tutor Indigo`_. For more information on building your own theme, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html .. _Red Theme: https://github.com/openedx/edx-platform/tree/master/themes/red-theme +.. _Tutor Indigo: https://github.com/overhangio/tutor-indigo \ No newline at end of file diff --git a/themes/open-edx/cms/README.rst b/themes/open-edx/cms/README.rst index a6ed83112d..e0fe4272b5 100644 --- a/themes/open-edx/cms/README.rst +++ b/themes/open-edx/cms/README.rst @@ -7,8 +7,10 @@ provide any overrides, which means that it adopts the built-in themes, templates etc. The `Red Theme`_ is provided as an example of building a simple new theme. +Another is `Tutor Indigo`_. For more information on building your own theme, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html .. _Red Theme: https://github.com/openedx/edx-platform/tree/master/themes/red-theme +.. _Tutor Indigo: https://github.com/overhangio/tutor-indigo \ No newline at end of file diff --git a/themes/open-edx/lms/README.rst b/themes/open-edx/lms/README.rst index 12881f443b..c40581bc61 100644 --- a/themes/open-edx/lms/README.rst +++ b/themes/open-edx/lms/README.rst @@ -7,8 +7,10 @@ provide any overrides, which means that it adopts the built-in themes, templates etc. The `Red Theme`_ is provided as an example of building a simple new theme. +Another is `Tutor Indigo`_. For more information on building your own theme, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html .. _Red Theme: https://github.com/openedx/edx-platform/tree/master/themes/red-theme +.. _Tutor Indigo: https://github.com/overhangio/tutor-indigo diff --git a/xmodule/course_block.py b/xmodule/course_block.py index 5b1f92d777..c3f42ecb66 100644 --- a/xmodule/course_block.py +++ b/xmodule/course_block.py @@ -841,8 +841,8 @@ class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring # Translators: please don't translate "id". help=_( 'Configure team sets, limit team sizes, and set visibility settings using JSON. See ' - 'teams ' + '" + "href='https://docs.openedx.org/en/latest/educators/navigation/components_activities.html#lti-component'>" ) BREAK_TAG = '
    ' diff --git a/xmodule/templates/about/overview.yaml b/xmodule/templates/about/overview.yaml index c5ddfcd97e..be564e38e2 100644 --- a/xmodule/templates/about/overview.yaml +++ b/xmodule/templates/about/overview.yaml @@ -42,7 +42,7 @@ data: |
    diff --git a/xmodule/templates/problem/circuitschematic.yaml b/xmodule/templates/problem/circuitschematic.yaml index 9053718af1..6dc411e258 100644 --- a/xmodule/templates/problem/circuitschematic.yaml +++ b/xmodule/templates/problem/circuitschematic.yaml @@ -12,8 +12,8 @@ data: |

    For more information, see - - Circuit Schematic Builder Problem in Building and Running an edX Course. + + Circuit Schematic Builder Problem in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/customgrader.yaml b/xmodule/templates/problem/customgrader.yaml index f03b259005..1600f2a893 100644 --- a/xmodule/templates/problem/customgrader.yaml +++ b/xmodule/templates/problem/customgrader.yaml @@ -23,8 +23,8 @@ data: | click the "Show Answer" button.

    - For more information, see - Write-Your-Own-Grader Problem in Building and Running an edX Course. + For more information, see + Write-Your-Own-Grader Problem in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/drag_and_drop.yaml b/xmodule/templates/problem/drag_and_drop.yaml index d21afd7426..e4d6b605aa 100644 --- a/xmodule/templates/problem/drag_and_drop.yaml +++ b/xmodule/templates/problem/drag_and_drop.yaml @@ -8,8 +8,8 @@ data: |

    In drag and drop problems, students respond to a question by dragging text or objects to a specific location on an image.

    For more information, see - - Drag and Drop Problem (Deprecated) in Building and Running an edX Course. + + Drag and Drop Problem (Deprecated) in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/imageresponse.yaml b/xmodule/templates/problem/imageresponse.yaml index bb2b189f3f..ace6282572 100644 --- a/xmodule/templates/problem/imageresponse.yaml +++ b/xmodule/templates/problem/imageresponse.yaml @@ -7,9 +7,9 @@ data: |

    In an image mapped input problem, also known as a "pointing on a picture" problem, students click inside a defined region in an image. You define this region by including coordinates in the body of the problem. You can define one rectangular region, multiple rectangular regions, or one non-rectangular region. For more information, see - Image Mapped Input Problem + Image Mapped Input Problem in - Building and Running an edx Course. + Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/jsinput_response.yaml b/xmodule/templates/problem/jsinput_response.yaml index dd9fdda164..e931886bdc 100644 --- a/xmodule/templates/problem/jsinput_response.yaml +++ b/xmodule/templates/problem/jsinput_response.yaml @@ -20,13 +20,8 @@ data: |

    For more information, see - - Custom JavaScript Problem in Building and Running an edX Course. -

    -

    - JavaScript developers can also see - - Custom JavaScript Applications in the EdX Developer's Guide. + + Custom JavaScript Problem in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/latex_problem.yaml b/xmodule/templates/problem/latex_problem.yaml index bb3493ce52..4f1e18d3be 100644 --- a/xmodule/templates/problem/latex_problem.yaml +++ b/xmodule/templates/problem/latex_problem.yaml @@ -96,8 +96,8 @@ data: |

    For more information, see - - Problem Written in LaTeX in Building and Running an edX Course. + + Problem Written in LaTeX in Building and Running an Open edX Course.

    You can use the following example problems as models.

    Example Option Problem