diff --git a/Makefile b/Makefile
index cec2f69..8e84ba1 100644
--- a/Makefile
+++ b/Makefile
@@ -40,7 +40,7 @@ extract_translations: ## extract strings to be translated, outputting .po files
compile_translations: ## compile translation files, outputting .mo files for each supported language
cd $(WORKING_DIR) && i18n_tool generate -v
- python manage.py compilejsi18n --namespace DragAndDropI18N --output $(JS_TARGET)
+ python manage.py compilejsi18n --namespace MultiProblemI18N --output $(JS_TARGET)
detect_changed_source_translations:
cd $(WORKING_DIR) && i18n_tool changed
diff --git a/multi_problem_xblock/compat.py b/multi_problem_xblock/compat.py
index 2dc61e5..60b376f 100644
--- a/multi_problem_xblock/compat.py
+++ b/multi_problem_xblock/compat.py
@@ -10,6 +10,7 @@
def getLibraryContentBlock():
+ """Get LibraryContentBlock from edx-platform if possible"""
try:
from xmodule.library_content_block import LibraryContentBlock # pylint: disable=import-outside-toplevel
except ModuleNotFoundError:
diff --git a/multi_problem_xblock/multi_problem_xblock.py b/multi_problem_xblock/multi_problem_xblock.py
index e6c2e53..0bda69d 100644
--- a/multi_problem_xblock/multi_problem_xblock.py
+++ b/multi_problem_xblock/multi_problem_xblock.py
@@ -6,6 +6,8 @@
import logging
from copy import copy
+from lxml import etree
+from lxml.etree import XMLSyntaxError
from web_fragments.fragment import Fragment
from webob import Response
from xblock.completable import XBlockCompletionMode
@@ -287,14 +289,14 @@ def get_test_scores(self, _data, _suffix):
)
return Response(template, content_type='text/html')
- def student_view_data(self, context):
+ def student_view_data(self, context=None):
"""
Student view data for templates and javascript initialization
"""
fragment = Fragment()
items = []
child_context = {} if not context else copy(context)
- jump_to_id = context.get('jumpToId')
+ jump_to_id = child_context.get('jumpToId')
bookmarks_service = self.runtime.service(self, 'bookmarks')
total_problems = 0
completed_problems = 0
@@ -370,3 +372,36 @@ def publish_completion(self, progress: float):
completion_service = self.runtime.service(self, 'completion')
if completion_service and completion_service.completion_tracking_enabled():
self.runtime.publish(self, 'completion', {'completion': progress})
+
+ @classmethod
+ def definition_from_xml(cls, xml_object, system):
+ """Generate object from xml"""
+ 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 Multi Problem 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 = dict(xml_object.attrib.items())
+ return definition, children
+
+ def definition_to_xml(self, resource_fs):
+ """ Exports Library Content Block to XML """
+ xml_object = etree.Element('multi_problem')
+ for child in self.get_children():
+ self.runtime.add_block_as_child_node(child, xml_object)
+ # Set node attributes based on our fields.
+ for field_name, field in self.fields.items():
+ if field_name in ('children', 'parent', 'content'):
+ continue
+ if field.is_set_on(self):
+ xml_object.set(field_name, str(field.read_from(self)))
+ return xml_object
diff --git a/multi_problem_xblock/public/js/translations/en/text.js b/multi_problem_xblock/public/js/translations/en/text.js
index d8dca51..e36ca92 100644
--- a/multi_problem_xblock/public/js/translations/en/text.js
+++ b/multi_problem_xblock/public/js/translations/en/text.js
@@ -1,6 +1,6 @@
(function(global){
- var DragAndDropI18N = {
+ var MultiProblemI18N = {
init: function() {
@@ -10,14 +10,7 @@
const django = globals.django || (globals.django = {});
- django.pluralidx = function(n) {
- const v = (n != 1);
- if (typeof v === 'boolean') {
- return v ? 1 : 0;
- } else {
- return v;
- }
- };
+ django.pluralidx = function(count) { return (count == 1) ? 0 : 1; };
/* gettext library */
@@ -91,7 +84,15 @@
"DATE_INPUT_FORMATS": [
"%Y-%m-%d",
"%m/%d/%Y",
- "%m/%d/%y"
+ "%m/%d/%y",
+ "%b %d %Y",
+ "%b %d, %Y",
+ "%d %b %Y",
+ "%d %b, %Y",
+ "%B %d %Y",
+ "%B %d, %Y",
+ "%d %B %Y",
+ "%d %B, %Y"
],
"DECIMAL_SEPARATOR": ".",
"FIRST_DAY_OF_WEEK": 0,
@@ -135,7 +136,7 @@
}
};
- DragAndDropI18N.init();
- global.DragAndDropI18N = DragAndDropI18N;
+ MultiProblemI18N.init();
+ global.MultiProblemI18N = MultiProblemI18N;
}(this));
\ No newline at end of file
diff --git a/multi_problem_xblock/templates/html/multi_problem_xblock.html b/multi_problem_xblock/templates/html/multi_problem_xblock.html
index bc69ce7..9459798 100644
--- a/multi_problem_xblock/templates/html/multi_problem_xblock.html
+++ b/multi_problem_xblock/templates/html/multi_problem_xblock.html
@@ -15,7 +15,9 @@
- {% blocktrans %}{{ self.current_slide }} of {{ items|length }}{% endblocktrans %}
+ {% blocktrans with items_length=items|length current_slide=self.current_slide %}
+ {{ current_slide }} of {{ items_length }}
+ {% endblocktrans %}
{% if self.display_name %}
{{ self.display_name }}
diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py
index 5bb419f..c21e475 100644
--- a/tests/unit/test_basics.py
+++ b/tests/unit/test_basics.py
@@ -2,12 +2,11 @@
from unittest import mock
import ddt
-from sample_xblocks.basic.problem import ProblemBlock
-from multi_problem_xblock.compat import L_SHOWANSWER
+from multi_problem_xblock.compat import L_SHOWANSWER, L_ShowCorrectness
from multi_problem_xblock.multi_problem_xblock import DISPLAYFEEDBACK, SCORE_DISPLAY_FORMAT, MultiProblemBlock
-from ..utils import TestCaseMixin, instantiate_block
+from ..utils import SampleProblemBlock, TestCaseMixin, instantiate_block
@ddt.ddt
@@ -19,7 +18,7 @@ def setUp(self):
self.children = {}
for i in range(3):
usage_key = f'block-v1:edx+cs1+test+type@problem+block@{i}'
- problem_block = instantiate_block(ProblemBlock, fields={
+ problem_block = instantiate_block(SampleProblemBlock, fields={
'usage_key': usage_key,
})
self.children[usage_key] = problem_block
@@ -59,6 +58,7 @@ def assertPublishEvent(self, completion):
self.assertEqual(patched_publish.mock_calls, expected_calls)
def test_template_contents(self):
+ """Verify rendered template contents"""
context = {}
student_fragment = self.block.runtime.render(self.block, 'student_view', context)
self.assertIn(
@@ -68,6 +68,7 @@ def test_template_contents(self):
self.assertIn('
', student_fragment.content)
def test_student_view_data(self):
+ """Verify student data used in templates"""
_, template_context, js_context = self.block.student_view_data({})
items = template_context.pop('items')
self.assertEqual(template_context, {
@@ -86,3 +87,92 @@ def test_student_view_data(self):
})
for index, item in enumerate(items):
self.assertEqual(item['id'], self.children_ids[index])
+
+ def test_editor_saved(self):
+ """Verify whether child values are updated based on parent block"""
+ self.block.showanswer = L_SHOWANSWER.NEVER
+ self.block.display_feedback = DISPLAYFEEDBACK.END_OF_TEST
+ # Call editor_saved as this is called by cms xblock api before saving the block
+ self.block.editor_saved(None, None, None)
+ for child in self.block.get_children():
+ self.assertEqual(child.showanswer, L_SHOWANSWER.NEVER)
+ self.assertEqual(child.show_correctness, L_ShowCorrectness.NEVER)
+
+ # if display_feedback = immediately, child block showanswer should be set to always
+ self.block.display_feedback = DISPLAYFEEDBACK.IMMEDIATELY
+ self.block.editor_saved(None, None, None)
+ for child in self.block.get_children():
+ self.assertEqual(child.show_correctness, L_ShowCorrectness.ALWAYS)
+
+ def test_incomplete_overall_progress_handler(self):
+ """Check progress handler information when all problems are not completed"""
+ # Check progress handler when 2/3 problems are completed
+ self.block.children[self.children_ids[0]].is_submitted = lambda: True
+ self.block.children[self.children_ids[1]].is_submitted = lambda: True
+ self.block.children[self.children_ids[2]].is_submitted = lambda: False
+ res = self.call_handler('get_overall_progress', {}, method='GET')
+ self.assertEqual(res, {'overall_progress': int((2 / 3) * 100)})
+
+ def test_completed_overall_progress_handler(self):
+ """Check progress handler information when all problems are completed"""
+ self.block.publish_completion = mock.Mock()
+ # Set cut_off_score to 100%
+ self.block.cut_off_score = 1
+ # Check progress handler when 3/3 problems are completed and all are correct
+ for child in self.block.get_children():
+ child.is_submitted = lambda: True
+ child.is_correct = lambda: True
+ child.score = mock.Mock(raw_earned=1, raw_possible=1)
+ res = self.call_handler('get_overall_progress', {}, method='GET')
+ self.assertEqual(res, {'overall_progress': 100})
+ self.block.publish_completion.assert_called_once_with(1)
+
+ # Update one child to be incorrect
+ self.block.children[self.children_ids[2]].is_correct = lambda: False
+ self.block.children[self.children_ids[2]].score = mock.Mock(raw_earned=0, raw_possible=1)
+ res = self.call_handler('get_overall_progress', {}, method='GET')
+ self.assertEqual(res, {'overall_progress': 100})
+ # Completion should be reduced to 0.9 as the student score was less than required cut_off_score
+ self.block.publish_completion.assert_called_with(0.9)
+
+ def test_get_scores_when_incomplete(self):
+ """Test get_test_scores handler when all problems are not completed"""
+ for _, child in enumerate(self.block.get_children()):
+ child.is_submitted = lambda: False
+ res = self.call_handler('get_test_scores', {}, expect_json=False, method='GET')
+ self.assertEqual(res.status_code, 400)
+
+ def test_get_scores(self):
+ """Test get_test_scores handler"""
+ for index, child in enumerate(self.block.get_children()):
+ child.is_submitted = lambda: True
+ # Set last problem incorrect
+ child.score = mock.Mock(raw_earned=1 if index < 2 else 0, raw_possible=1)
+ child.is_correct = lambda: index < 2 # pylint: disable=cell-var-from-loop
+ res = self.call_handler('get_test_scores', {}, expect_json=False, method='GET')
+ self.assertIn('question2', res.text)
+ self.assertIn('answer2', res.text)
+ self.assertIn('correct_answer2', res.text)
+ self.assertIn('question1', res.text)
+ self.assertIn('answer1', res.text)
+ self.assertIn('question0', res.text)
+ self.assertIn('answer0', res.text)
+ self.assertIn('2/3', res.text)
+
+ def test_get_scores_in_percentage(self):
+ """Test get_test_scores handler returns percentage"""
+ self.block.score_display_format = SCORE_DISPLAY_FORMAT.PERCENTAGE
+ for index, child in enumerate(self.block.get_children()):
+ child.is_submitted = lambda: True
+ # Set last problem incorrect
+ child.score = mock.Mock(raw_earned=1 if index < 2 else 0, raw_possible=1)
+ child.is_correct = lambda: index < 2 # pylint: disable=cell-var-from-loop
+ res = self.call_handler('get_test_scores', {}, expect_json=False, method='GET')
+ self.assertIn('question2', res.text)
+ self.assertIn('answer2', res.text)
+ self.assertIn('correct_answer2', res.text)
+ self.assertIn('question1', res.text)
+ self.assertIn('answer1', res.text)
+ self.assertIn('question0', res.text)
+ self.assertIn('answer0', res.text)
+ self.assertIn('67%', res.text)
diff --git a/tests/utils.py b/tests/utils.py
index cf3ce89..1bf1430 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,8 +1,10 @@
import json
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, Mock, patch
+from sample_xblocks.basic.problem import ProblemBlock, String
from webob import Request
from workbench.runtime import WorkbenchRuntime
+from xblock.core import Scope
from xblock.field_data import DictFieldData
@@ -33,9 +35,24 @@ def instantiate_block(cls, fields=None):
block.runtime.get_block = lambda child_id: children[child_id]
block.usage_key.__str__.return_value = usage_key
block.usage_key.course_key.make_usage_key = lambda _, child_id: child_id
+ block.get_children = lambda: list(children.values())
return block
+class SampleProblemBlock(ProblemBlock):
+ question = String(scope=Scope.content)
+ showanswer = String(scope=Scope.settings, default="")
+ show_correctness = String(scope=Scope.settings, default="")
+ lcp = Mock(student_answers={1: 1})
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # Return incremental mock question answer text each time it is called.
+ self.lcp.find_question_label.side_effect = [f'question{x}' for x in range(3)]
+ self.lcp.find_answer_text.side_effect = [f'answer{x}' for x in range(3)]
+ self.lcp.find_correct_answer_text.side_effect = [f'correct_answer{x}' for x in range(3)]
+
+
class TestCaseMixin:
""" Helpful mixins for unittest TestCase subclasses """
maxDiff = None