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