Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cms/djangoapps/contentstore/views/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_http_methods
from edx_django_utils.plugins import pluggable_override
from edx_proctoring.api import (
does_backend_support_onboarding,
get_exam_by_content_id,
Expand Down Expand Up @@ -1117,6 +1118,7 @@ def _get_gating_info(course, xblock):
return info


@pluggable_override('OVERRIDE_CREATE_XBLOCK_INFO')
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False, # lint-amnesty, pylint: disable=too-many-statements
course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None,
user=None, course=None, is_concise=False):
Expand All @@ -1135,6 +1137,13 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F

In addition, an optional include_children_predicate argument can be provided to define whether or
not a particular xblock should have its children included.

You can customize the behavior of this function using the `OVERRIDE_CREATE_XBLOCK_INFO` pluggable override point.
For example:
>>> def create_xblock_info(default_fn, xblock, *args, **kwargs):
... xblock_info = default_fn(xblock, *args, **kwargs)
... xblock_info['icon'] = xblock.icon_override
... return xblock_info
"""
is_library_block = isinstance(xblock.location, LibraryUsageLocator)
is_xblock_unit = is_unit(xblock, parent_xblock)
Expand Down
39 changes: 33 additions & 6 deletions cms/djangoapps/contentstore/views/tests/test_import_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import copy
import itertools
import json
import logging
import os
Expand All @@ -16,6 +17,7 @@

import ddt
import lxml
from bson import ObjectId
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.test.utils import override_settings
Expand Down Expand Up @@ -928,7 +930,7 @@ def setUp(self):
self.source_course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
self.addCleanup(shutil.rmtree, self.export_dir, ignore_errors=True)

def _setup_source_course_with_library_content(self, publish=False):
def _setup_source_course_with_library_content(self, publish=False, version=None):
"""
Sets up course with library content.
"""
Expand All @@ -947,7 +949,9 @@ def _setup_source_course_with_library_content(self, publish=False):
parent_location=sequential.location,
display_name='Test Unit'
)
lc_block = self._add_library_content_block(vertical, self.lib_key, publish_item=publish)
lc_block = self._add_library_content_block(
vertical, self.lib_key, publish_item=publish, other_settings=dict(source_library_version=version)
)
self._refresh_children(lc_block)

def get_lib_content_block_children(self, block_location):
Expand Down Expand Up @@ -986,13 +990,18 @@ def assert_names(self, source_child_location, dest_child_location):
dest_child = self.store.get_item(dest_child_location)
self.assertEqual(source_child.display_name, dest_child.display_name)

@ddt.data(True, False)
def test_library_content_on_course_export_import(self, publish_item):
@ddt.data(*itertools.product([False, True], repeat=2))
@ddt.unpack
def test_library_content_on_course_export_import(self, publish_item, generate_version):
"""
Verify that library contents in destination and source courses are same after importing
the source course into destination course.

If a library with the specified version does not exist in the modulestore, the import should not fail.
"""
self._setup_source_course_with_library_content(publish=publish_item)
self._setup_source_course_with_library_content(
publish=publish_item, version=str(ObjectId()) if generate_version else None
)

# Create a course to import source course.
dest_course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
Expand Down Expand Up @@ -1099,7 +1108,25 @@ def assert_problem_definition(self, course_location, expected_problem_content):

'<problem>\n <pre>\n <code>x=10 print("hello \n")</code>\n </pre>\n '
'<multiplechoiceresponse/>\n</problem>\n'
]
],
[
'<!-- Comment outside of the root (will be deleted). -->'
'<problem>'
'<!-- Valid comment -->'
'<p>'
'"<!-- String with non-XML structure: >< -->"'
'Text'
'</p>'
'</problem>',

'<problem>\n '
'<!-- Valid comment -->\n '
'<p>'
'"<!-- String with non-XML structure: >< -->"'
'Text'
'</p>\n'
'</problem>\n'
],
)
@ddt.unpack
def test_problem_content_on_course_export_import(self, problem_data, expected_problem_content):
Expand Down
14 changes: 14 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,19 @@
# in the LMS and CMS.
# .. toggle_tickets: 'https://github.com/open-craft/edx-platform/pull/429'
'DISABLE_UNENROLLMENT': False,

# .. toggle_name: MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
# .. toggle_description: If enabled, the Library Content Block is marked as complete when users view it.
# Otherwise (by default), all children of this block must be completed.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2021-07-25
# .. toggle_target_removal_date: None
# .. toggle_tickets: https://github.com/edx/edx-platform/pull/28268
# .. toggle_warnings: For consistency in user-experience, keep the value in sync with the setting of the same name
# in the LMS and CMS.
'MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW': False,
}

ENABLE_JASMINE = False
Expand Down Expand Up @@ -818,6 +831,7 @@
EditInfoMixin,
AuthoringMixin,
)
XBLOCK_EXTRA_MIXINS = ()

XBLOCK_SELECT_FUNCTION = prefer_xmodules

Expand Down
3 changes: 3 additions & 0 deletions cms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,6 @@ def get_env_setting(setting):
}

LOGO_IMAGE_EXTRA_TEXT = ENV_TOKENS.get('LOGO_IMAGE_EXTRA_TEXT', '')

############## XBlock extra mixins ############################
XBLOCK_MIXINS += tuple(XBLOCK_EXTRA_MIXINS)
13 changes: 13 additions & 0 deletions cms/static/js/views/modals/course_outline_modals.js
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,19 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
}, options));
},

/**
* This function allows comprehensive themes to create custom editors without adding boilerplate code.
*
* A simple example theme for this can be found at https://github.com/open-craft/custom-unit-icons-theme
**/
getCustomEditModal: function(tabs, editors, xblockInfo, options) {
return new SettingsXBlockModal($.extend({
tabs: tabs,
editors: editors,
model: xblockInfo
}, options));
},

getPublishModal: function(xblockInfo, options) {
return new PublishXBlockModal($.extend({
editors: [PublishEditor],
Expand Down
5 changes: 4 additions & 1 deletion cms/static/js/views/pages/course_outline.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ define([
collapsedClass: 'is-collapsed'
},

// Extracting this to a variable allows comprehensive themes to replace or extend `CourseOutlineView`.
outlineViewClass: CourseOutlineView,

initialize: function() {
var self = this;
this.initialState = this.options.initialState;
Expand Down Expand Up @@ -90,7 +93,7 @@ define([
this.highlightsEnableView.render();
}

this.outlineView = new CourseOutlineView({
this.outlineView = new this.outlineViewClass({
el: this.$('.outline'),
model: this.model,
isRoot: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* JavaScript for reset option that can be done on a randomized LibraryContentBlock */
function LibraryContentReset(runtime, element) {
$('.problem-reset-btn', element).click((e) => {
e.preventDefault();
$.post({
url: runtime.handlerUrl(element, 'reset_selected_children'),
success() {
location.reload();
},
});
});
}
18 changes: 13 additions & 5 deletions common/lib/xmodule/xmodule/capa_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,22 @@ def index_dictionary(self):
# Make optioninput's options index friendly by replacing the actual tag with the values
capa_content = re.sub(r'<optioninput options="\(([^"]+)\)".*?>\s*|\S*<\/optioninput>', r'\1', self.data)

# Removing solutions and hints, as well as script and style
# Remove the following tags with content that can leak hints or solutions:
# - `solution` (with optional attributes) and `solutionset`.
# - `targetedfeedback` (with optional attributes) and `targetedfeedbackset`.
# - `answer` (with optional attributes).
# - `script` (with optional attributes).
# - `style` (with optional attributes).
# - various types of hints (with optional attributes) and `hintpart`.
capa_content = re.sub(
re.compile(
r"""
<solution>.*?</solution> |
<script>.*?</script> |
<style>.*?</style> |
<[a-z]*hint.*?>.*?</[a-z]*hint>
<solution.*?>.*?</solution.*?> |
<targetedfeedback.*?>.*?</targetedfeedback.*?> |
<answer.*?>.*?</answer> |
<script.*?>.*?</script> |
<style.*?>.*?</style> |
<[a-z]*hint.*?>.*?</[a-z]*hint.*?>
""",
re.DOTALL |
re.VERBOSE),
Expand Down
70 changes: 64 additions & 6 deletions common/lib/xmodule/xmodule/library_content_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@
import random
from copy import copy
from gettext import ngettext
from rest_framework import status

import bleach
from django.conf import settings
from django.utils.decorators import classproperty
from lazy import lazy
from lxml import etree
from lxml.etree import XMLSyntaxError
from opaque_keys.edx.locator import LibraryLocator
from pkg_resources import resource_string
from web_fragments.fragment import Fragment
from webob import Response
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock
from xblock.fields import Integer, List, Scope, String
from xblock.fields import Integer, List, Scope, String, Boolean

from capa.responsetypes import registry
from xmodule.mako_module import MakoTemplateBlockBase
Expand Down Expand Up @@ -114,7 +118,18 @@ class LibraryContentBlock(

show_in_read_only_mode = True

completion_mode = XBlockCompletionMode.AGGREGATOR
# noinspection PyMethodParameters
@classproperty
def completion_mode(cls): # pylint: disable=no-self-argument
"""
Allow overriding the completion mode with a feature flag.

This is a property, so it can be dynamically overridden in tests, as it is not evaluated at runtime.
"""
if settings.FEATURES.get('MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW', False):
return XBlockCompletionMode.COMPLETABLE

return XBlockCompletionMode.AGGREGATOR

display_name = String(
display_name=_("Display Name"),
Expand Down Expand Up @@ -163,6 +178,14 @@ class LibraryContentBlock(
default=[],
scope=Scope.user_state,
)
# This cannot be called `show_reset_button`, because children blocks inherit this as a default value.
allow_resetting_children = Boolean(
display_name=_("Show Reset Button"),
help=_("Determines whether a 'Reset Problems' button is shown, so users may reset their answers and reshuffle "
"selected items."),
scope=Scope.settings,
default=False
)

@property
def source_library_key(self):
Expand Down Expand Up @@ -333,6 +356,27 @@ def selected_children(self):

return self.selected

@XBlock.handler
def reset_selected_children(self, _, __):
"""
Resets the XBlock's state for a user.

This resets the state of all `selected` children and then clears the `selected` field
so that the new blocks are randomly chosen for this user.
"""
if not self.allow_resetting_children:
return Response('"Resetting selected children" is not allowed for this XBlock',
status=status.HTTP_400_BAD_REQUEST)

for block_type, block_id in self.selected_children():
block = self.runtime.get_block(self.location.course_key.make_usage_key(block_type, block_id))
if hasattr(block, 'reset_problem'):
block.reset_problem(None)
block.save()

self.selected = []
return Response()

def _get_selected_child_blocks(self):
"""
Generator returning XBlock instances of the children selected for the
Expand Down Expand Up @@ -370,7 +414,11 @@ def student_view(self, context): # lint-amnesty, pylint: disable=missing-functi
'show_bookmark_button': False,
'watched_completable_blocks': set(),
'completion_delay_ms': None,
'reset_button': self.allow_resetting_children,
}))

fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_reset.js'))
fragment.initialize_js('LibraryContentReset')
return fragment

def author_view(self, context):
Expand Down Expand Up @@ -664,10 +712,20 @@ def get_content_titles(self):

@classmethod
def definition_from_xml(cls, xml_object, system):
children = [
system.process_xml(etree.tostring(child)).scope_ids.usage_id
for child in xml_object.getchildren()
]
children = []

for child in xml_object.getchildren():
try:
children.append(system.process_xml(etree.tostring(child)).scope_ids.usage_id)
except (XMLSyntaxError, AttributeError):
msg = (
"Unable to load child when parsing Library Content Block. "
"This can happen when a comment is manually added to the course export."
)
logger.error(msg)
if system.error_tracker is not None:
system.error_tracker(msg)

definition = {
attr_name: json.loads(attr_value)
for attr_name, attr_value in xml_object.attrib.items()
Expand Down
3 changes: 1 addition & 2 deletions common/lib/xmodule/xmodule/modulestore/xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@
from .exceptions import ItemNotFoundError
from .inheritance import InheritanceKeyValueStore, compute_inherited_metadata, inheriting_field_data

edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True)

etree.set_default_parser(edx_xml_parser)

Expand Down
24 changes: 14 additions & 10 deletions common/lib/xmodule/xmodule/modulestore/xml_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,16 +883,20 @@ def _convert_ref_fields_to_new_namespace(reference):
if lib_content_block_already_published:
return block

# Update library content block's children on draft branch
with store.branch_setting(branch_setting=ModuleStoreEnum.Branch.draft_preferred):
LibraryToolsService(store, user_id).update_children(
block,
version=block.source_library_version,
)

# Publish it if importing the course for branch setting published_only.
if store.get_branch_setting() == ModuleStoreEnum.Branch.published_only:
store.publish(block.location, user_id)
try:
# Update library content block's children on draft branch
with store.branch_setting(branch_setting=ModuleStoreEnum.Branch.draft_preferred):
LibraryToolsService(store, user_id).update_children(
block,
version=block.source_library_version,
)
except ValueError as err:
# The specified library version does not exist.
log.error(err)
else:
# Publish it if importing the course for branch setting published_only.
if store.get_branch_setting() == ModuleStoreEnum.Branch.published_only:
store.publish(block.location, user_id)

return block

Expand Down
Loading