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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* 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(data) {
edx.HtmlUtils.setHtml(element, edx.HtmlUtils.HTML(data));
// Rebind the reset button for the block
XBlock.initializeBlock(element);
// Render the new set of problems (XBlocks)
$(".xblock", element).each(function(i, child) {
XBlock.initializeBlock(child);
});
},
});
});
}
36 changes: 35 additions & 1 deletion common/lib/xmodule/xmodule/library_content_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import random
from copy import copy
from gettext import ngettext
from rest_framework import status

import bleach
from django.conf import settings
Expand All @@ -21,7 +22,7 @@
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 @@ -176,6 +177,14 @@ def completion_mode(cls): # pylint: disable=no-self-argument
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 @@ -346,6 +355,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(json.dumps(self.student_view({}).content))

def _get_selected_child_blocks(self):
"""
Generator returning XBlock instances of the children selected for the
Expand Down Expand Up @@ -383,7 +413,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
47 changes: 46 additions & 1 deletion common/lib/xmodule/xmodule/tests/test_library_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@

Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
"""
from unittest.mock import Mock, patch
import ddt
from unittest.mock import MagicMock, Mock, patch

from bson.objectid import ObjectId
from fs.memoryfs import MemoryFS
from lxml import etree
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
Expand All @@ -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

Expand All @@ -30,6 +33,7 @@ class LibraryContentTest(MixedSplitTestCase):
"""
Base class for tests of LibraryContentBlock (library_content_block.py)
"""

def setUp(self):
super().setUp()

Expand Down Expand Up @@ -164,6 +168,7 @@ def test_xml_import_with_comments(self):
self._verify_xblock_properties(imported_lc_block)


@ddt.ddt
class LibraryContentBlockTestMixin:
"""
Basic unit tests for LibraryContentBlock
Expand Down Expand Up @@ -378,6 +383,45 @@ 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)
# Mock the student view to return an empty dict to be returned as response
self.lc_block.student_view = MagicMock()
self.lc_block.student_view.return_value.content = {}

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:
self.lc_block.student_view.assert_called_once_with({})
assert reset_problem.call_count == len(self.problem_types)
assert response.status_code == status.HTTP_200_OK
assert response.content_type == "text/html"
assert response.body == b"{}"
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):
Expand All @@ -396,6 +440,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')
Expand Down
11 changes: 11 additions & 0 deletions lms/static/sass/course/courseware/_courseware.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions lms/templates/vert_module.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ <h2 class="hd hd-2 unit-title">${unit_title}</h2>
% endfor
</div>

% if reset_button:
<div class="problem-reset-btn-wrapper">
<button type="button" class="problem-reset-btn btn-link" data-value="${_('Reset Problems')}"><span aria-hidden="true">${_('Reset Problems')}</span><span class="sr">${_("Reset Problems")}</span></button>
</div>
% endif

<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory">
DateUtilFactory.transform('.localized-datetime');
</%static:require_module_async>
Expand Down