Merge branch 'master' into jciasenza

This commit is contained in:
Feanil Patel
2025-04-03 09:14:56 -04:00
committed by GitHub
79 changed files with 1417 additions and 331 deletions

View File

@@ -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

View File

@@ -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 <https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/getting_started/CA_get_started_Studio.html#>`_
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 <https://docs.openedx.org/en/latest/educators/concepts/open_edx_platform/what_is_studio.html>`_
See also
--------

View File

@@ -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

View File

@@ -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({

View File

@@ -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)

View File

@@ -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.

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" role="img" focusable="false" aria-hidden="true">
<path
d="M19.8 18.4 14 10.67V6.5l1.35-1.69c.26-.33.03-.81-.39-.81H9.04c-.42 0-.65.48-.39.81L10 6.5v4.17L4.2 18.4c-.49.66-.02 1.6.8 1.6h14c.82 0 1.29-.94.8-1.6z"
fill="currentColor">
</path>
</svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" role="img" focusable="false" aria-hidden="true">
<path
d="M2.21 10.47 5 9.36 7.25 15H8V2h2.5v10h1V0H14v12h1V1.5h2.5V12h1V4.5H21V16c0 4.42-3.58 8-8 8-3.26 0-6.19-1.99-7.4-5.02l-3.39-8.51z"
fill="currentColor">
</path>
</svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" role="img" focusable="false" aria-hidden="true">
<path d="M21 3H3v18h18V3zM7.5 18c-.83 0-1.5-.67-1.5-1.5S6.67 15 7.5 15s1.5.67 1.5 1.5S8.33 18 7.5 18zm0-9C6.67 9 6 8.33 6 7.5S6.67 6 7.5 6 9 6.67 9 7.5 8.33 9 7.5 9zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5 4.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm0-9c-.83 0-1.5-.67-1.5-1.5S15.67 6 16.5 6s1.5.67 1.5 1.5S17.33 9 16.5 9z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" role="img" focusable="false" aria-hidden="true">
<path d="M4 6H2v16h16v-2H4V6zm18-4H6v16h16V2zm-3 9h-4v4h-2v-4H9V9h4V5h2v4h4v2z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 263 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 -960 960 960" fill="none" role="img" focusable="false" aria-hidden="true">
<path d="M80-160v-80h800v80H80Zm80-160v-320h80v320h-80Zm160 0v-480h80v480h-80Zm160 0v-480h80v480h-80Zm280 0L600-600l70-40 160 280-70 40Z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" role="img" focusable="false" aria-hidden="true">
<path
d="M3 10h11v2H3v-2zm0-2h11V6H3v2zm0 8h7v-2H3v2zm15.01-3.13 1.41-1.41 2.12 2.12-1.41 1.41-2.12-2.12zm-.71.71-5.3 5.3V21h2.12l5.3-5.3-2.12-2.12z"
fill="currentColor">
</path>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" role="img" focusable="false" aria-hidden="true">
<path
d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"
fill="currentColor">
</path>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" role="img" focusable="false" aria-hidden="true">
<path d="M2.5 4v3h5v12h3V7h5V4h-13zm19 5h-9v3h3v7h3v-7h3V9z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" focusable="false" aria-hidden="true">
<path d="M17 10.5V6H3v12h14v-4.5l4 4v-11l-4 4Z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@@ -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,

View File

@@ -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 $('<div/>', {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;
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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"
);

View File

@@ -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}",
}
);
</%static:webpack>

View File

@@ -1,7 +1,7 @@
<% if (support_legend.show_legend) { %>
<span class="support-documentation">
<a class="support-documentation-link"
href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/create_exercises_and_tools.html#levels-of-support-for-tools" rel="noopener" target="_blank">
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 %>
</a>
<span class="support-documentation-level">

View File

@@ -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

View File

@@ -12,4 +12,4 @@ Glossary
More Documentation
==================
`Events in the Tracking Logs <https://edx.readthedocs.io/projects/devdata/en/stable/internal_data_formats/tracking_logs.html>`_
`Events in the Tracking Logs <https://docs.openedx.org/en/latest/developers/references/internal_data_formats/tracking_logs/index.html>`_

View File

@@ -37,7 +37,7 @@
<article class="response">
<h3>What web browser should I use?</h3>
<p>The Open edX platform works best with current versions of Chrome, Firefox or Safari, or with Internet Explorer version 9 and above.</p>
<p>See our <a href="https://edx.readthedocs.org/projects/open-edx-learner-guide/en/latest/front_matter/browsers.html">list of supported browsers</a> for the most up-to-date information.</p>
<p>See our <a href="https://docs.openedx.org/en/latest/developers/references/developer_guide/testing/browsers.html">list of supported browsers</a> for the most up-to-date information.</p>
</article>
<article class="response">

View File

@@ -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
****************

View File

@@ -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
=========

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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 <http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/grade_range.html>`_ and specify the Pass / Fail threshold (for example, a minimum of 50/100 is required to Pass).
- Course teams set the `grade range <https://docs.openedx.org/en/latest/educators/how-tos/grading/set_grade_range.html>`_ 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 <http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/configure_assignment_type.html>`_ 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 <https://docs.openedx.org/en/latest/educators/references/grading/gradebook_assignment_types.html>`_ 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 <http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/external_graders.html>`_
- **automatically scored, asynchronously** via an `external grader service <https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_external_graders.html>`_
- **manually scored**, such as for `Open Response Assessments <http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/open_response_assessments/OpenResponseAssessments.html>`_, where the calculation requires human input from either
- **manually scored**, such as for `Open Response Assessments <https://docs.openedx.org/en/latest/educators/navigation/components_activities.html#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 <http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/open_response_assessments/OpenResponseAssessments.html#how-scores-for-open-response-assessments-are-calculated>`_
- A problem's individual grading policy - as currently supported by `ORA's assessment configuration <https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_OpenResponseAssessments.html#how-scores-for-open-response-assessments-are-calculated>`_
Grade Overrides/Exceptions
--------------------------
Today, we support the following features to `adjust grades <https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-koa.master/student_progress/course_grades.html#adjust-grades-for-one-or-all-learners>`_, but don't have a general feature to override a grade for any xBlock:
Today, we support the following features to `adjust grades <https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#adjust-grades-for-one-or-all-learners>`_, but don't have a general feature to override a grade for any xBlock:
* In `ORA Studio settings <http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/open_response_assessments/Manage_ORA_Assignment.html#override-a-learner-s-assessment-grade>`_:
* In `ORA Studio settings <https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/Manage_ORA_Assignment.html#override-a-learner-assessment-grade>`_:
- override a learner's grade for an ORA2 block
* In LMS Instructor Dashboard or `Staff Debug Info <http://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/manage_live_course/staff_debug_info.html>`_:
* In LMS Instructor Dashboard or `Staff Debug Info <https://docs.openedx.org/en/latest/educators/references/data/staff_debug_info.html>`_:
- 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 <https://edx.readthed
- delete a student state for a problem
* In `Gradebook <https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/open-release-koa.master/student_progress/course_grades.html#adjust-grades-for-one-or-all-learners>`_:
* In `Gradebook <https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#adjust-grades-for-one-or-all-learners>`_:
- override a subsection grade for a learner
- override subsection grades in bulk (master's track only)

View File

@@ -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}",

View File

@@ -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.

View File

@@ -13,8 +13,8 @@
<li class="hint-item" id="hint-moreinfo" tabindex="-1">
<p>
<span class="bold">For detailed information, see
<a id="hint-link-first" href="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/completing_assignments/SFD_mathformatting.html#math-formatting" target="_blank" >Entering Mathematical and Scientific Expressions</a> in the
<a id="hint-link-second" href="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/index.html" target="_blank" >EdX Learner's Guide</a>.
<a id="hint-link-first" href="https://docs.openedx.org/en/latest/learners/completing_assignments/SFD_mathformatting.html#entering-math-expressions-in-assignments-or-the-calculator" target="_blank" >Entering Mathematical and Scientific Expressions</a> in the
<a id="hint-link-second" href="https://docs.openedx.org/en/latest/learners/index.html" target="_blank" >Open edX Learner's Guide</a>.
</span>
</p>
</li>

View File

@@ -24,10 +24,10 @@ from openedx.core.djangolib.markup import HTML, Text
<p class="sr">${_('Use the arrow keys to navigate the tips or use the tab key to return to the calculator')}</p>
<p>
<span class="bold">
${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('<a href="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/completing_assignments/SFD_mathformatting.html" target="_blank" >'),
${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('<a href="https://docs.openedx.org/en/latest/learners/completing_assignments/SFD_mathformatting.html#entering-math-expressions-in-assignments-or-the-calculator" target="_blank" >'),
math_link_end=HTML('</a>'),
guide_link_start=HTML('<a href="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/index.html" target="_blank">'),
guide_link_start=HTML('<a href="https://docs.openedx.org/en/latest/learners/index.html" target="_blank">'),
guide_link_end=HTML('</a>'),
)}
</span>

View File

@@ -8,7 +8,7 @@ from openedx.core.djangolib.markup import HTML, Text
<div class="accomplishment-support-print">
<p class="accomplishment-metadata-copy">
${Text(_("For tips and tricks on printing your certificate, view the {link_start}Web Certificates help documentation{link_end}.")).format(
link_start=HTML('<a target="_blank" href="https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/OpenSFD_certificates.html#print-a-web-certificate">'),
link_start=HTML('<a target="_blank" href="https://docs.openedx.org/en/latest/learners/OpenSFD_certificates.html#print-a-web-certificate">'),
link_end=HTML('</a>'),
)}
</p>

View File

@@ -17,7 +17,7 @@ show_link = group_configuration_url is not None
<p>
<span class="message-text">
${Text(_("This content experiment uses group configuration '{group_configuration_name}'.")).format(
group_configuration_name=Text(HTML("<a href='{}'>{}</a>")).format(group_configuration_url, user_partition.name) if show_link else user_partition.name
group_configuration_name=Text(HTML("<a href='{}' class='xblock-view-group-link'>{}</a>")).format(group_configuration_url, user_partition.name) if show_link else user_partition.name
)}
</span>
</p>

View File

@@ -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

View File

@@ -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(),

View File

@@ -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.

View File

@@ -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
]

View File

@@ -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:
"""

View File

@@ -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,
)
)

View File

@@ -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"]

View File

@@ -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/<container_key>/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/<container_key>/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/<container_key>/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/<container_key>/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,
)

View File

@@ -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.
"""

View File

@@ -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
)

View File

@@ -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 = "<html><b>Hello world!</b></html>"
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 = "<html><b>Hello world!</b></html>"
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)

View File

@@ -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,
)

View File

@@ -80,6 +80,8 @@ urlpatterns = [
path('containers/<lib_container_key:container_key>/', 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()),

View File

@@ -66,7 +66,7 @@ Glossary
plan on removing this term from this app's code to avoid confusion.
- **Section**: From our
`documentation <https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_sections.html#what-is-a-section>`__,
`documentation <https://docs.openedx.org/en/latest/educators/concepts/open_edx_platform/about_course_sections.html>`__,
“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 Developers
Guide <https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/>`__
Guide <https://docs.openedx.org/en/latest/developers/references/developer_guide/index.html>`__
for information on setting them up.
The Schedule app relies on ACE. When live, ACE sends emails to users

View File

@@ -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).
"""

View File

@@ -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

View File

@@ -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
"""

View File

@@ -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):
"""

View File

@@ -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
-----------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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
<https://edx.readthedocs.io/projects/edx-django-utils>.
<https://docs.openedx.org/projects/edx-django-utils/en/latest/>.
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
<https://edx.readthedocs.io/projects/edx-django-utils>.
<https://docs.openedx.org/projects/edx-django-utils/en/latest/>.
languages:
- python
patterns:

View File

@@ -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
--------------

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 '
'<a target="&#95;blank" href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/'
'course_features/teams/teams_setup.html#enable-and-configure-teams">teams '
'<a target="&#95;blank" href="https://docs.openedx.org/en/latest/educators/references/'
'advanced_features/teams_configuration_options.html>teams '
'configuration documentation</a> for help and examples.'
),
scope=Scope.settings,

View File

@@ -98,7 +98,7 @@ log = logging.getLogger(__name__)
DOCS_ANCHOR_TAG_OPEN = (
"<a rel='noopener' target='_blank' "
"href='https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/lti_component.html'>"
"href='https://docs.openedx.org/en/latest/educators/navigation/components_activities.html#lti-component'>"
)
BREAK_TAG = '<br />'

View File

@@ -42,7 +42,7 @@ data: |
<article class="response">
<h3>What web browser should I use?</h3>
<p>The Open edX platform works best with current versions of Chrome, Edge, Firefox, or Safari.</p>
<p>See our <a href="https://edx.readthedocs.org/projects/open-edx-learner-guide/en/latest/front_matter/browsers.html">list of supported browsers</a> for the most up-to-date information.</p>
<p>See our <a href="https://docs.openedx.org/en/latest/developers/references/developer_guide/testing/browsers.html">list of supported browsers</a> for the most up-to-date information.</p>
</article>
<article class="response">

View File

@@ -12,8 +12,8 @@ data: |
</p>
<p>
For more information, see
<a href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/circuit_schematic_builder.html" target="_blank">
Circuit Schematic Builder Problem</a> in <i>Building and Running an edX Course</i>.
<a href="https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_circuit_schematic_builder.html" target="_blank">
Circuit Schematic Builder Problem</a> in <i>Building and Running an Open edX Course</i>.
</p>
<p>
When you add the problem, be sure to select <strong>Settings</strong>

View File

@@ -23,8 +23,8 @@ data: |
click the "Show Answer" button.
</p>
<p>
For more information, see <a href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/custom_python.html" target="_blank">
Write-Your-Own-Grader Problem</a> in <i>Building and Running an edX Course</i>.
For more information, see <a href="https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/guide_custom_python_problem.html" target="_blank">
Write-Your-Own-Grader Problem</a> in <i>Building and Running an Open edX Course</i>.
</p>
<p>
When you add the problem, be sure to select <strong>Settings</strong>

View File

@@ -8,8 +8,8 @@ data: |
<p>In drag and drop problems, students respond to a question by dragging text or objects to a specific location on an image.</p>
<p>
For more information, see
<a href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/drag_and_drop_deprecated.html" target="_blank">
Drag and Drop Problem (Deprecated)</a> in <i>Building and Running an edX Course</i>.
<a href="https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/guide_drag_and_drop.html" target="_blank">
Drag and Drop Problem (Deprecated)</a> in <i>Building and Running an Open edX Course</i>.
</p>
<p>
When you add the problem, be sure to select <strong>Settings</strong>

View File

@@ -7,9 +7,9 @@ data: |
<p>
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
<a href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/image_mapped_input.html" target="_blank">Image Mapped Input Problem</a>
<a href="https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_image_mapped_input.html" target="_blank">Image Mapped Input Problem</a>
in
<i>Building and Running an edx Course</i>.
<i>Building and Running an Open edX Course</i>.
</p>
<p>When you add the problem, be sure to select
<strong>Settings</strong>

View File

@@ -20,13 +20,8 @@ data: |
</p>
<p>
For more information, see
<a href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/custom_javascript.html" target="_blank">
Custom JavaScript Problem</a> in <i>Building and Running an edX Course</i>.
</p>
<p>
JavaScript developers can also see
<a href="https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/extending_platform/javascript.html" target="_blank">
Custom JavaScript Applications</a> in the <i>EdX Developer's Guide</i>.
<a href="https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/custom_javascript.html" target="_blank">
Custom JavaScript Problem</a> in <i>Building and Running an Open edX Course</i>.
</p>
<p>
When you add the problem, be sure to select <strong>Settings</strong>

View File

@@ -96,8 +96,8 @@ data: |
</p>
<p>
For more information, see
<a href="https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/problem_in_latex.html" target="_blank">
Problem Written in LaTeX</a> in <i>Building and Running an edX Course</i>.
<a href="https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/create_problem_in_latex.html" target="_blank">
Problem Written in LaTeX</a> in <i>Building and Running an Open edX Course</i>.
</p>
<p>You can use the following example problems as models.</p>
<p><strong>Example Option Problem</strong></p>