diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 553b7c031566..bfd2d0090bd6 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -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, @@ -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): @@ -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) diff --git a/cms/djangoapps/contentstore/views/tests/test_import_export.py b/cms/djangoapps/contentstore/views/tests/test_import_export.py index 2f237986c9c0..076938532463 100644 --- a/cms/djangoapps/contentstore/views/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/views/tests/test_import_export.py @@ -3,6 +3,7 @@ """ import copy +import itertools import json import logging import os @@ -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 @@ -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. """ @@ -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): @@ -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) @@ -1099,7 +1108,25 @@ def assert_problem_definition(self, course_location, expected_problem_content): '\n
\n    x=10 print("hello \n")\n  
\n ' '\n
\n' - ] + ], + [ + '' + '' + '' + '

' + '""' + 'Text' + '

' + '
', + + '\n ' + '\n ' + '

' + '""' + 'Text' + '

\n' + '
\n' + ], ) @ddt.unpack def test_problem_content_on_course_export_import(self, problem_data, expected_problem_content): diff --git a/cms/envs/common.py b/cms/envs/common.py index ce4e36a6f4cf..6c1f0355a20d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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 @@ -818,6 +831,7 @@ EditInfoMixin, AuthoringMixin, ) +XBLOCK_EXTRA_MIXINS = () XBLOCK_SELECT_FUNCTION = prefer_xmodules diff --git a/cms/envs/production.py b/cms/envs/production.py index 6bc60b1d4ea5..3c6de5a5df72 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -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) diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index 7419a0ae1077..70ebcb6153cc 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -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], diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index cd86d6399795..08f25b3eed19 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -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; @@ -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, diff --git a/common/lib/xmodule/xmodule/assets/library_content/public/js/library_content_reset.js b/common/lib/xmodule/xmodule/assets/library_content/public/js/library_content_reset.js new file mode 100644 index 000000000000..81eb7d2105e7 --- /dev/null +++ b/common/lib/xmodule/xmodule/assets/library_content/public/js/library_content_reset.js @@ -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(); + }, + }); + }); +} diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 172128d907db..8414737a271b 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -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'\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""" - .*? | - | - | - <[a-z]*hint.*?>.*? + .*? | + .*? | + .*? | + .*? | + .*? | + <[a-z]*hint.*?>.*? """, re.DOTALL | re.VERBOSE), diff --git a/common/lib/xmodule/xmodule/library_content_module.py b/common/lib/xmodule/xmodule/library_content_module.py index c303f022c73a..29e772f8bee1 100644 --- a/common/lib/xmodule/xmodule/library_content_module.py +++ b/common/lib/xmodule/xmodule/library_content_module.py @@ -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 @@ -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"), @@ -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): @@ -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 @@ -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): @@ -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() diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index cb40f58aa416..7ec7fdf2d587 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -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) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 0e967acf057b..1279531d0928 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -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 diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index a3351277b3a6..6abd073ed869 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -2535,25 +2535,32 @@ def test_response_types_multiple_tags(self): def test_solutions_not_indexed(self): xml = textwrap.dedent(""" - -
-

Explanation

- -

This is what the 1st solution.

- -
-
- - -
-

Explanation

- -

This is the 2nd solution.

- -
-
- - + Test solution. + Test solution with attribute. + + Test solutionset. + Test solution within solutionset. + + + Test feedback. + Test feedback with attribute. + + Test FeedbackSet. + Test feedback within feedbackset. + + + Test answer. + Test answer with attribute. + + + + + + + + Test choicehint. + Test hint. + Test hintpart.
""") name = "Blank Common Capa Problem" @@ -2668,7 +2675,7 @@ def test_indexing_non_latin_problem(self): """) name = "Non latin Input" descriptor = self._create_descriptor(sample_text_input_problem_xml, name=name) - capa_content = " FX1_VAL='Καλημέρα' Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL " + capa_content = " Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL " descriptor_dict = descriptor.index_dictionary() assert descriptor_dict['content']['capa_content'] == smart_text(capa_content) diff --git a/common/lib/xmodule/xmodule/tests/test_library_content.py b/common/lib/xmodule/xmodule/tests/test_library_content.py index 8a287acf88f2..a2d057b984c3 100644 --- a/common/lib/xmodule/xmodule/tests/test_library_content.py +++ b/common/lib/xmodule/xmodule/tests/test_library_content.py @@ -3,6 +3,7 @@ Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`. """ +import ddt from unittest.mock import Mock, patch from bson.objectid import ObjectId @@ -11,6 +12,7 @@ from search.search_engine_base import SearchEngine from web_fragments.fragment import Fragment from xblock.runtime import Runtime as VanillaRuntime +from rest_framework import status from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE, LibraryContentBlock from xmodule.library_tools import LibraryToolsService @@ -20,6 +22,7 @@ from xmodule.tests import get_test_system from xmodule.validation import StudioValidationMessage from xmodule.x_module import AUTHOR_VIEW +from xmodule.capa_module import ProblemBlock from .test_course_module import DummySystem as TestImportSystem @@ -30,6 +33,7 @@ class LibraryContentTest(MixedSplitTestCase): """ Base class for tests of LibraryContentBlock (library_content_block.py) """ + def setUp(self): super().setUp() @@ -74,18 +78,14 @@ class TestLibraryContentExportImport(LibraryContentTest): """ Export and import tests for LibraryContentBlock """ + def setUp(self): + super().setUp() - maxDiff = None - - def test_xml_export_import_cycle(self): - """ - Test the export-import cycle. - """ # Children will only set after calling this. self.lc_block.refresh_children() - lc_block = self.store.get_item(self.lc_block.location) + self.lc_block = self.store.get_item(self.lc_block.location) - expected_olx = ( + self.expected_olx = ( '\n' ' \n' @@ -94,46 +94,81 @@ def test_xml_export_import_cycle(self): ' \n' '\n' ).format( - block=lc_block, + block=self.lc_block, ) - export_fs = MemoryFS() # Set the virtual FS to export the olx to. - lc_block.runtime._descriptor_system.export_fs = export_fs # pylint: disable=protected-access + self.export_fs = MemoryFS() + self.lc_block.runtime._descriptor_system.export_fs = self.export_fs # pylint: disable=protected-access + + # Prepare runtime for the import. + self.runtime = TestImportSystem(load_error_modules=True, course_id=self.lc_block.location.course_key) + self.runtime.resources_fs = self.export_fs + self.id_generator = Mock() # Export the olx. node = etree.Element("unknown_root") - lc_block.add_xml_to_node(node) + self.lc_block.add_xml_to_node(node) - # Read it back - with export_fs.open('{dir}/{file_name}.xml'.format( - dir=lc_block.scope_ids.usage_id.block_type, - file_name=lc_block.scope_ids.usage_id.block_id + def _verify_xblock_properties(self, imported_lc_block): + """ + Check the new XBlock has the same properties as the old one. + """ + assert imported_lc_block.display_name == self.lc_block.display_name + assert imported_lc_block.source_library_id == self.lc_block.source_library_id + assert imported_lc_block.source_library_version == self.lc_block.source_library_version + assert imported_lc_block.mode == self.lc_block.mode + assert imported_lc_block.max_count == self.lc_block.max_count + assert imported_lc_block.capa_type == self.lc_block.capa_type + assert len(imported_lc_block.children) == len(self.lc_block.children) + assert imported_lc_block.children == self.lc_block.children + + def test_xml_export_import_cycle(self): + """ + Test the export-import cycle. + """ + # Read back the olx. + with self.export_fs.open('{dir}/{file_name}.xml'.format( + dir=self.lc_block.scope_ids.usage_id.block_type, + file_name=self.lc_block.scope_ids.usage_id.block_id )) as f: exported_olx = f.read() # And compare. - assert exported_olx == expected_olx - - runtime = TestImportSystem(load_error_modules=True, course_id=lc_block.location.course_key) - runtime.resources_fs = export_fs + assert exported_olx == self.expected_olx # Now import it. olx_element = etree.fromstring(exported_olx) - id_generator = Mock() - imported_lc_block = LibraryContentBlock.parse_xml(olx_element, runtime, None, id_generator) + imported_lc_block = LibraryContentBlock.parse_xml(olx_element, self.runtime, None, self.id_generator) - # Check the new XBlock has the same properties as the old one. - assert imported_lc_block.display_name == lc_block.display_name - assert imported_lc_block.source_library_id == lc_block.source_library_id - assert imported_lc_block.source_library_version == lc_block.source_library_version - assert imported_lc_block.mode == lc_block.mode - assert imported_lc_block.max_count == lc_block.max_count - assert imported_lc_block.capa_type == lc_block.capa_type - assert len(imported_lc_block.children) == 4 - assert imported_lc_block.children == lc_block.children + self._verify_xblock_properties(imported_lc_block) + def test_xml_import_with_comments(self): + """ + Test that XML comments within LibraryContentBlock are ignored during the import. + """ + olx_with_comments = ( + '\n' + '\n' + '\n' + ' \n' + ' \n' + ' \n' + ' \n' + '\n' + ).format( + block=self.lc_block, + ) + + # Import the olx. + olx_element = etree.fromstring(olx_with_comments) + imported_lc_block = LibraryContentBlock.parse_xml(olx_element, self.runtime, None, self.id_generator) + self._verify_xblock_properties(imported_lc_block) + + +@ddt.ddt class LibraryContentBlockTestMixin: """ Basic unit tests for LibraryContentBlock @@ -348,6 +383,39 @@ def _change_count_and_refresh_children(self, count): assert len(selected) == count return selected + @ddt.data( + # User resets selected children with reset button on content block + (True, 8), + # User resets selected children without reset button on content block + (False, 8), + ) + @ddt.unpack + def test_reset_selected_children_capa_blocks(self, allow_resetting_children, max_count): + """ + Tests that the `reset_selected_children` method of a content block resets only + XBlocks that have a `reset_problem` attribute when `allow_resetting_children` is True + + This test block has 4 HTML XBlocks and 4 Problem XBlocks. Therefore, if we ensure + that the `reset_problem` has been called len(self.problem_types) times, then + it means that this is working correctly + """ + self.lc_block.allow_resetting_children = allow_resetting_children + self.lc_block.max_count = max_count + # Add some capa blocks + self._create_capa_problems() + self.lc_block.refresh_children() + self.lc_block = self.store.get_item(self.lc_block.location) + + with patch.object(ProblemBlock, 'reset_problem', return_value={'success': True}) as reset_problem: + response = self.lc_block.reset_selected_children(None, None) + + if allow_resetting_children: + assert reset_problem.call_count == len(self.problem_types) + assert response.status_code == status.HTTP_200_OK + else: + reset_problem.assert_not_called() + assert response.status_code == status.HTTP_400_BAD_REQUEST + @patch('xmodule.library_tools.SearchEngine.get_search_engine', Mock(return_value=None, autospec=True)) class TestLibraryContentBlockNoSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest): @@ -366,6 +434,7 @@ class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, Libra """ Tests for library container with mocked search engine response. """ + def _get_search_response(self, field_dictionary=None): """ Mocks search response as returned by search engine """ target_type = field_dictionary.get('problem_types') diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 84e6c6064155..f7329c188152 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -20,9 +20,7 @@ log = logging.getLogger(__name__) # assume all XML files are persisted as utf-8. -EDX_XML_PARSER = XMLParser(dtd_validation=False, load_dtd=False, - remove_comments=True, remove_blank_text=True, - encoding='utf-8') +EDX_XML_PARSER = XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding='utf-8') def name_to_pathname(name): diff --git a/common/static/common/js/discussion/views/discussion_inline_view.js b/common/static/common/js/discussion/views/discussion_inline_view.js index 8da4d74a8fba..7d87a69bcad8 100644 --- a/common/static/common/js/discussion/views/discussion_inline_view.js +++ b/common/static/common/js/discussion/views/discussion_inline_view.js @@ -154,6 +154,7 @@ }); this.threadView.render(); this.listenTo(this.threadView.showView, 'thread:_delete', this.navigateToAllPosts); + this.$(".forum-nav-thread[data-id='" + threadId + "']").removeClass('never-read'); this.threadListView.$el.addClass('is-hidden'); this.$('.inline-thread').removeClass('is-hidden'); }, diff --git a/common/static/common/js/spec/discussion/view/discussion_inline_view_spec.js b/common/static/common/js/spec/discussion/view/discussion_inline_view_spec.js index 4e6b0ee5a7e3..957ecf29719e 100644 --- a/common/static/common/js/spec/discussion/view/discussion_inline_view_spec.js +++ b/common/static/common/js/spec/discussion/view/discussion_inline_view_spec.js @@ -35,6 +35,7 @@ }); createTestView = function(test) { + var testView; var courseSettings = DiscussionSpecHelper.createTestCourseSettings({ groups: [ { @@ -62,7 +63,7 @@ children: [] } }); - var testView = new DiscussionInlineView({ + testView = new DiscussionInlineView({ el: $('.discussion-module') }); testView.render(); @@ -234,6 +235,22 @@ // Verify that the individual thread is no longer shown expect(testView.$('.group-visibility-label').length).toBe(0); }); + + it('marks a thread as read once it is opened', function() { + var testView = createTestView(this); + var thread; + showDiscussion(this, testView); + thread = testView.$('.forum-nav-thread'); + + // The thread is marked as unread. + expect(thread).toHaveClass('never-read'); + + // Navigate to the thread. + thread.find('.forum-nav-thread-link').click(); + + // The thread is no longer marked as unread. + expect(thread).not.toHaveClass('never-read'); + }); }); }); }()); diff --git a/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss b/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss index 79e0a7a08f5a..8bd18361d183 100644 --- a/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss +++ b/common/static/sass/edx-pattern-library-shims/_breadcrumbs.scss @@ -57,5 +57,10 @@ @include rtl { @include transform(rotateY(180deg)); } + + // Hide a trailing separator. + &:last-child { + display: none; + } } } diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 26cd63542a44..3b8c127b37ba 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -96,6 +96,7 @@ def enrolled_students_features(course_key, features): """ include_cohort_column = 'cohort' in features include_team_column = 'team' in features + include_city_column = 'city' in features include_enrollment_mode = 'enrollment_mode' in features include_verification_status = 'verification_status' in features include_program_enrollments = 'external_user_key' in features @@ -151,6 +152,13 @@ def extract_student(student, features): for meta_feature, meta_key in meta_features: student_dict[meta_feature] = meta_dict.get(meta_key) + # There are two separate places where the city value can be stored, + # one used by account settings and the other used by the registration form. + # If the account settings value (meta.city) is set, it takes precedence. + meta_city = meta_dict.get('city') + if include_city_column and meta_city: + student_dict['city'] = meta_city + if include_cohort_column: # Note that we use student.course_groups.all() here instead of # student.course_groups.filter(). The latter creates a fresh query, diff --git a/lms/envs/common.py b/lms/envs/common.py index 3ccb04ab601c..dfd5469fad12 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -932,7 +932,7 @@ # .. toggle_name: FEATURES['DISABLE_UNENROLLMENT'] # .. toggle_implementation: DjangoSetting # .. toggle_default: False - # .. toggle_description: Set to True to disable self-unenrollments via REST API. + # .. toggle_description: Set to True to disable self-unenrollments via REST API. # This also hides the "Unenroll" button on the Learner Dashboard. # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2021-10-11 @@ -953,6 +953,19 @@ # .. toggle_warnings: None # .. toggle_tickets: 'https://github.com/open-craft/edx-platform/pull/439' 'ENABLE_REDIRECT_UNAUTHENTICATED_USERS_TO_LOGIN': 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, } # Specifies extra XBlock fields that should available when requested via the Course Blocks API diff --git a/lms/static/js/views/fields.js b/lms/static/js/views/fields.js index db7befc82866..4a1f27f6db23 100644 --- a/lms/static/js/views/fields.js +++ b/lms/static/js/views/fields.js @@ -1,4 +1,5 @@ -(function(define, undefined) { +/* eslint no-underscore-dangle: ["error", { "allow": ["_super"] }] */ +(function(define, undef) { 'use strict'; define([ 'gettext', 'jquery', 'underscore', 'backbone', @@ -25,7 +26,7 @@ fieldType: 'generic', className: function() { - return 'u-field' + ' u-field-' + this.fieldType + ' u-field-' + this.options.valueAttribute; + return 'u-field u-field-' + this.fieldType + ' u-field-' + this.options.valueAttribute; }, tagName: 'div', @@ -100,21 +101,22 @@ return HtmlUtils.setHtml(this.$('.u-field-title'), title); }, - getMessage: function(message_status) { - if ((message_status + 'Message') in this) { - return this[message_status + 'Message'].call(this); + getMessage: function(messageStatus) { + if ((messageStatus + 'Message') in this) { + return this[messageStatus + 'Message'].call(this); } else if (this.showMessages) { - return HtmlUtils.joinHtml(this.indicators[message_status], this.messages[message_status]); + return HtmlUtils.joinHtml(this.indicators[messageStatus], this.messages[messageStatus]); } - return this.indicators[message_status]; + return this.indicators[messageStatus]; }, showHelpMessage: function(message) { + var msg = message; if (_.isUndefined(message) || _.isNull(message)) { - message = this.helpMessage; + msg = this.helpMessage; } this.$('.u-field-message-notification').html(''); - HtmlUtils.setHtml(this.$('.u-field-message-help'), message); + HtmlUtils.setHtml(this.$('.u-field-message-help'), msg); }, getNotificationMessage: function() { @@ -139,19 +141,19 @@ }, showSuccessMessage: function() { - var successMessage = this.getMessage('success'); + var context = Date.now(), + successMessage = this.getMessage('success'), + view = this; + this.showNotificationMessage(successMessage); if (this.options.refreshPageOnSave) { - if ("focusNextID" in this.options) { - $.cookie('focus_id', this.options.focusNextID ); + if ('focusNextID' in this.options) { + $.cookie('focus_id', this.options.focusNextID); } location.reload(true); } - var view = this; - - var context = Date.now(); this.lastSuccessMessageContext = context; setTimeout(function() { @@ -167,11 +169,14 @@ }, showErrorMessage: function(xhr) { + var errors, + validationErrorMessage, + message; if (xhr.status === 400) { try { - var errors = JSON.parse(xhr.responseText), - validationErrorMessage = errors.field_errors[this.options.valueAttribute].user_message, - message = HtmlUtils.joinHtml(this.indicators.validationError, validationErrorMessage); + errors = JSON.parse(xhr.responseText); + validationErrorMessage = errors.field_errors[this.options.valueAttribute].user_message; + message = HtmlUtils.joinHtml(this.indicators.validationError, validationErrorMessage); this.showNotificationMessage(message); } catch (error) { this.showNotificationMessage(this.getMessage('error')); @@ -202,19 +207,19 @@ }, saveAttributes: function(attributes, options) { + var view = this; + var defaultOptions = { + contentType: 'application/merge-patch+json', + patch: true, + wait: true, + success: function() { + view.saveSucceeded(); + }, + error: function(model, xhr) { + view.showErrorMessage(xhr); + } + }; if (this.persistChanges === true) { - var view = this; - var defaultOptions = { - contentType: 'application/merge-patch+json', - patch: true, - wait: true, - success: function() { - view.saveSucceeded(); - }, - error: function(model, xhr) { - view.showErrorMessage(xhr); - } - }; this.showInProgressMessage(); this.model.save(attributes, _.extend(defaultOptions, options)); } @@ -383,7 +388,12 @@ updateValueInField: function() { var value = (_.isUndefined(this.modelValue()) || _.isNull(this.modelValue())) ? '' : this.modelValue(); - this.$('.u-field-value input').val(value); + + var fieldHasFocus = (document.activeElement === this.$('.u-field-value input')[0]); + var fieldChanged = this.fieldValue() !== value; + if (!fieldHasFocus || !fieldChanged) { + this.$('.u-field-value input').val(value); + } }, saveValue: function() { @@ -419,7 +429,7 @@ editable: this.editable, title: this.options.title, screenReaderTitle: this.options.screenReaderTitle || this.options.title, - titleVisible: this.options.titleVisible !== undefined ? this.options.titleVisible : true, + titleVisible: this.options.titleVisible !== undef ? this.options.titleVisible : true, iconName: this.options.iconName, showBlankOption: (!this.options.required || !this.modelValueIsSet()), groupOptions: this.createGroupOptions(), @@ -468,8 +478,9 @@ }, displayValue: function(value) { + var option; if (value) { - var option = this.optionForValue(value); + option = this.optionForValue(value); return (option ? option[1] : ''); } else { return ''; @@ -477,11 +488,19 @@ }, updateValueInField: function() { + var value; // str + var fieldHasFocus; // bool + var fieldChanged; // bool if (this.editable !== 'never') { - this.$('.u-field-value select').val(this.modelValue() || ''); + value = this.modelValue() || ''; + fieldHasFocus = (document.activeElement === this.$('.u-field-value select')[0]); + fieldChanged = this.fieldValue() !== value; + if (!fieldHasFocus || !fieldChanged) { + this.$('.u-field-value select').val(value); + } } - var value = this.displayValue(this.modelValue() || ''); + value = this.displayValue(this.modelValue() || ''); if (this.modelValueIsSet() === false) { value = this.options.placeholderValue || ''; } @@ -613,24 +632,21 @@ 'aria-live': 'assertive', 'aria-atomic': true }); - } - else if (remainingCharCount < 60) { + } else if (remainingCharCount < 60) { $charCount.attr('aria-atomic', 'false'); - } - else if (remainingCharCount < 70) { + } else if (remainingCharCount < 70) { $charCount.attr({ 'aria-live': 'polite', 'aria-atomic': true }); } $charCount.text(curCharCount); - } }, adjustTextareaHeight: function() { - if (this.persistChanges === false) { return; } var textarea = this.$('textarea'); + if (this.persistChanges === false) { return; } textarea.css('height', 'auto').css('height', textarea.prop('scrollHeight') + 10); }, diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index 67a5a704050b..33f8dae798ad 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -635,6 +635,17 @@ html.video-fullscreen { border-bottom: 1px solid #ddd; margin-bottom: ($baseline*0.75); padding: 0 0 15px; + + .problem-reset-btn-wrapper { + position: relative; + .problem-reset-btn { + &:hover, + &:focus, + &:active { + color: $primary; + } + } + } } .vert > .xblock-student_view.is-hidden, diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 2e53b5611aec..9700101c9367 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -112,6 +112,48 @@ % endif + + ${HTML(fragment.foot_html())} @@ -195,7 +237,9 @@ % endif - ${sequence_title} + % if sequence_title: + ${sequence_title} + % endif diff --git a/lms/templates/preview_menu.html b/lms/templates/preview_menu.html index b9d5d223ddeb..fa26e6bc4f06 100644 --- a/lms/templates/preview_menu.html +++ b/lms/templates/preview_menu.html @@ -107,7 +107,7 @@ diff --git a/lms/templates/vert_module.html b/lms/templates/vert_module.html index 131bbfc8cadc..0e52e3c7f426 100644 --- a/lms/templates/vert_module.html +++ b/lms/templates/vert_module.html @@ -69,6 +69,12 @@

${unit_title}

% endfor +% if reset_button: +
+ +
+% endif + <%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> DateUtilFactory.transform('.localized-datetime'); diff --git a/openedx/core/lib/xblock_utils/__init__.py b/openedx/core/lib/xblock_utils/__init__.py index 0eaa4f437fad..6fba208f31cc 100644 --- a/openedx/core/lib/xblock_utils/__init__.py +++ b/openedx/core/lib/xblock_utils/__init__.py @@ -560,6 +560,6 @@ def get_icon(block): """ A function that returns the CSS class representing an icon to use for this particular XBlock (in the courseware navigation bar). Mostly used for Vertical/Unit XBlocks. - It can be overridden by setting `GET_UNIT_ICON_IMPL` to an alternative implementation. + It can be overridden by setting `OVERRIDE_GET_UNIT_ICON` to an alternative implementation. """ return block.get_icon_class() diff --git a/openedx/tests/completion_integration/test_services.py b/openedx/tests/completion_integration/test_services.py index fd23895d5a43..f5df16219843 100644 --- a/openedx/tests/completion_integration/test_services.py +++ b/openedx/tests/completion_integration/test_services.py @@ -7,6 +7,8 @@ from completion.models import BlockCompletion from completion.services import CompletionService from completion.test_utils import CompletionWaffleTestMixin +from django.conf import settings +from django.test import override_settings from opaque_keys.edx.keys import CourseKey from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -183,6 +185,19 @@ def test_can_mark_block_complete_on_view(self): assert self.completion_service.can_mark_block_complete_on_view(self.html) is True assert self.completion_service.can_mark_block_complete_on_view(self.problem) is False + @override_settings(FEATURES={**settings.FEATURES, 'MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW': True}) + def test_can_mark_library_content_complete_on_view(self): + library = LibraryFactory.create(modulestore=self.store) + lib_vertical = ItemFactory.create(parent=self.sequence, category='vertical', publish_item=False) + library_content_block = ItemFactory.create( + parent=lib_vertical, + category='library_content', + max_count=1, + source_library_id=str(library.location.library_key), + user_id=self.user.id, + ) + self.assertTrue(self.completion_service.can_mark_block_complete_on_view(library_content_block)) + def test_vertical_completion_with_library_content(self): library = LibraryFactory.create(modulestore=self.store) ItemFactory.create(parent=library, category='problem', publish_item=False, user_id=self.user.id) @@ -196,6 +211,9 @@ def test_vertical_completion_with_library_content(self): source_library_id=str(library.location.library_key), user_id=self.user.id, ) + # Library Content Block needs its children to be completed. + self.assertFalse(self.completion_service.can_mark_block_complete_on_view(library_content_block)) + library_content_block.refresh_children() lib_vertical = self.store.get_item(lib_vertical.location) self._bind_course_module(lib_vertical)