From 318d8c0483dd46da19c1f4f31509f410385fcc3f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 22 Oct 2015 21:23:19 -0700 Subject: [PATCH 1/6] Fix: Step Builder grades did not appear --- problem_builder/mentoring.py | 32 +++++++------ problem_builder/tests/unit/test_common.py | 26 ++++++++++ ...t_mentoring.py => test_problem_builder.py} | 0 .../tests/unit/test_step_builder.py | 33 +++++++++++++ problem_builder/tests/unit/utils.py | 48 +++++++++++++++++++ 5 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 problem_builder/tests/unit/test_common.py rename problem_builder/tests/unit/{test_mentoring.py => test_problem_builder.py} (100%) create mode 100644 problem_builder/tests/unit/test_step_builder.py create mode 100644 problem_builder/tests/unit/utils.py diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py index 8024d8a7..8ac6b7df 100644 --- a/problem_builder/mentoring.py +++ b/problem_builder/mentoring.py @@ -99,6 +99,13 @@ class BaseMentoringBlock( scope=Scope.content, enforce_type=True ) + weight = Float( + display_name=_("Weight"), + help=_("Defines the maximum total grade of the block."), + default=1, + scope=Scope.settings, + enforce_type=True + ) # User state num_attempts = Integer( @@ -109,6 +116,7 @@ class BaseMentoringBlock( ) has_children = True + has_score = True # The Problem/Step Builder XBlocks produce scores. (Their children do not send scores to the LMS.) icon_class = 'problem' block_settings_key = 'mentoring' @@ -197,8 +205,11 @@ def publish_event(self, data, suffix=''): Publish data for analytics purposes """ event_type = data.pop('event_type') - self.runtime.publish(self, event_type, data) + if (event_type == 'grade'): + # This handler can be called from the browser. Don't allow the browser to submit arbitrary grades ;-) + raise JsonHandlerError(403, "Posting grade events from the browser is forbidden.") + self.runtime.publish(self, event_type, data) return {'result': 'ok'} def author_preview_view(self, context): @@ -214,6 +225,10 @@ def author_preview_view(self, context): self.include_theme_files(fragment) return fragment + def max_score(self): + """ Maximum score. We scale all scores to a maximum of 1.0 so this is always 1.0 """ + return 1.0 + class MentoringBlock(BaseMentoringBlock, StudioContainerXBlockMixin, StepParentMixin): """ @@ -262,13 +277,6 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerXBlockMixin, StepParentM ) # Settings - weight = Float( - display_name=_("Weight"), - help=_("Defines the maximum total grade of the block."), - default=1, - scope=Scope.settings, - enforce_type=True - ) display_name = String( display_name=_("Title (Display name)"), help=_("Title to display"), @@ -323,8 +331,6 @@ class MentoringBlock(BaseMentoringBlock, StudioContainerXBlockMixin, StepParentM 'display_submit', 'feedback_label', 'weight', 'extended_feedback' ) - has_score = True - @property def is_assessment(self): """ Checks if mentoring XBlock is in assessment mode """ @@ -377,10 +383,6 @@ def score(self): return Score(score, int(round(score * 100)), correct, incorrect, partially_correct) - def max_score(self): - """ Maximum score. We scale all scores to a maximum of 1.0 so this is always 1.0 """ - return 1.0 - def student_view(self, context): # Migrate stored data if necessary self.migrate_fields() @@ -848,7 +850,7 @@ class MentoringWithExplicitStepsBlock(BaseMentoringBlock, StudioContainerWithNes enforce_type=True ) - editable_fields = ('display_name', 'max_attempts', 'extended_feedback') + editable_fields = ('display_name', 'max_attempts', 'extended_feedback', 'weight') @lazy def question_ids(self): diff --git a/problem_builder/tests/unit/test_common.py b/problem_builder/tests/unit/test_common.py new file mode 100644 index 00000000..067e9523 --- /dev/null +++ b/problem_builder/tests/unit/test_common.py @@ -0,0 +1,26 @@ +""" +Tests common to Problem Builder and Step Builder +""" +import ddt +import unittest +from problem_builder.mentoring import MentoringBlock, MentoringWithExplicitStepsBlock +from xblock.core import XBlock + +from .utils import ScoresTestMixin, instantiate_block + + +@ddt.ddt +class TestBuilderBlocks(ScoresTestMixin, unittest.TestCase): + """ Unit tests for Problem Builder and Step Builder """ + + @ddt.data(MentoringBlock, MentoringWithExplicitStepsBlock) + def test_interface(self, block_cls): + """ + Basic tests of the block's public interface. + """ + self.assertTrue(issubclass(block_cls, XBlock)) + self.assertTrue(block_cls.has_children) + + block = instantiate_block(block_cls) + self.assertTrue(block.has_children) + self.assert_produces_scores(block) diff --git a/problem_builder/tests/unit/test_mentoring.py b/problem_builder/tests/unit/test_problem_builder.py similarity index 100% rename from problem_builder/tests/unit/test_mentoring.py rename to problem_builder/tests/unit/test_problem_builder.py diff --git a/problem_builder/tests/unit/test_step_builder.py b/problem_builder/tests/unit/test_step_builder.py new file mode 100644 index 00000000..b2ad3650 --- /dev/null +++ b/problem_builder/tests/unit/test_step_builder.py @@ -0,0 +1,33 @@ +import unittest +import ddt +from mock import Mock +from problem_builder.mentoring import MentoringWithExplicitStepsBlock + +from .utils import ScoresTestMixin, instantiate_block + + +@ddt.ddt +class TestStepBuilder(ScoresTestMixin, unittest.TestCase): + """ Unit tests for Step Builder (MentoringWithExplicitStepsBlock) """ + + def test_scores(self): + """ + Test that scores are emitted correctly. + """ + # Submit an empty block - score should be 0: + block = instantiate_block(MentoringWithExplicitStepsBlock) + with self.expect_score_event(block, score=0.0, max_score=1.0): + request = Mock(method="POST", body="{}") + block.publish_attempt(request, suffix=None) + + # Mock a block to contain an MCQ question, then submit it. Score should be 1: + block = instantiate_block(MentoringWithExplicitStepsBlock) + block.questions = [Mock(weight=1.0)] + block.questions[0].name = 'mcq1' + block.steps = [Mock( + student_results=[('mcq1', {'score': 1, 'status': 'correct'})] + )] + block.answer_mapper = lambda _status: None + with self.expect_score_event(block, score=1.0, max_score=1.0): + request = Mock(method="POST", body="{}") + block.publish_attempt(request, suffix=None) diff --git a/problem_builder/tests/unit/utils.py b/problem_builder/tests/unit/utils.py new file mode 100644 index 00000000..173cc620 --- /dev/null +++ b/problem_builder/tests/unit/utils.py @@ -0,0 +1,48 @@ +""" +Helper methods for testing Problem Builder / Step Builder blocks +""" +from contextlib import contextmanager +from mock import MagicMock, Mock, patch +from xblock.field_data import DictFieldData + + +class ScoresTestMixin(object): + """ + Mixin for tests that involve scores (grades) + """ + def assert_produces_scores(self, block): + """ + Test that the given XBlock instance meets the requirements of being able to report + scores to the edX LMS, and have them appear on the student's progress page. + """ + self.assertTrue(block.has_score) + self.assertTrue(type(block).has_score) + self.assertEqual(block.weight, 1.0) # Default weight should be 1 + self.assertIsInstance(block.max_score(), (int, float)) + + @contextmanager + def expect_score_event(self, block, score, max_score): + """ + Context manager. Expect that the given block instance will publish the given score. + """ + with patch.object(block.runtime, 'publish') as mocked_publish: + yield + + mocked_publish.assert_called_once_with(block, 'grade', {'value': score, 'max_value': max_score}) + + +def instantiate_block(cls, fields=None): + """ + Instantiate the given XBlock in a mock runtime. + """ + fields = fields or {} + children = fields.pop('children', {}) + field_data = DictFieldData(fields or {}) + block = cls( + runtime=Mock(), + field_data=field_data, + scope_ids=MagicMock() + ) + block.children = children + block.runtime.get_block = lambda child_id: children[child_id] + return block From b542f0c51a3570b80d3676e4fb30269cf8a5b9cc Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 22 Oct 2015 22:41:06 -0700 Subject: [PATCH 2/6] Fix: Refreshing the page during a second step builder attempt will display previous answers --- problem_builder/mentoring.py | 2 ++ problem_builder/templates/html/mcqblock.html | 2 +- problem_builder/templates/html/mrqblock.html | 2 +- problem_builder/templates/html/ratingblock.html | 4 ++-- problem_builder/tests/integration/test_step_builder.py | 3 +++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/problem_builder/mentoring.py b/problem_builder/mentoring.py index 8ac6b7df..1e652b54 100644 --- a/problem_builder/mentoring.py +++ b/problem_builder/mentoring.py @@ -958,6 +958,8 @@ def student_view(self, context): fragment = Fragment() children_contents = [] + context = context or {} + context['hide_prev_answer'] = True # For Step Builder, we don't show the users' old answers when they try again for child_id in self.children: child = self.runtime.get_block(child_id) if child is None: # child should not be None but it can happen due to bugs or permission issues diff --git a/problem_builder/templates/html/mcqblock.html b/problem_builder/templates/html/mcqblock.html index 95443bf5..14da13c0 100644 --- a/problem_builder/templates/html/mcqblock.html +++ b/problem_builder/templates/html/mcqblock.html @@ -10,7 +10,7 @@