diff --git a/drag_and_drop_v2/drag_and_drop_v2.py b/drag_and_drop_v2/drag_and_drop_v2.py index 4c88ed1a7..8cf3924bd 100644 --- a/drag_and_drop_v2/drag_and_drop_v2.py +++ b/drag_and_drop_v2/drag_and_drop_v2.py @@ -294,101 +294,71 @@ def studio_submit(self, submissions, suffix=''): } @XBlock.json_handler - def do_attempt(self, attempt, suffix=''): - item = self._get_item_definition(attempt['val']) + def drop_item(self, item_attempt, suffix=''): + self._validate_drop_item(item_attempt) - state = None - zone = None - feedback = item['feedback']['incorrect'] - overall_feedback = None - is_correct = False - - if self._is_attempt_correct(attempt): # Student placed item in a correct zone - is_correct = True - feedback = item['feedback']['correct'] - state = { - 'zone': attempt['zone'], - 'correct': True, - 'x_percent': attempt['x_percent'], - 'y_percent': attempt['y_percent'], - } - - if state: - self.item_state[str(item['id'])] = state - zone = self._get_zone_by_uid(state['zone']) + if self.mode == self.ASSESSMENT_MODE: + return self._drop_item_assessment(item_attempt) + elif self.mode == self.STANDARD_MODE: + return self._drop_item_standard(item_attempt) else: - zone = self._get_zone_by_uid(attempt['zone']) - if not zone: - raise JsonHandlerError(400, "Item zone data invalid.") + raise JsonHandlerError(500, _("Unknown DnDv2 mode {mode} - course is misconfigured").format(self.mode)) - if self._is_finished(): - overall_feedback = self.data['feedback']['finish'] + @XBlock.json_handler + def do_attempt(self, data, suffix=''): + self._validate_attempt() - # don't publish the grade if the student has already completed the problem - if not self.completed: - if self._is_finished(): - self.completed = True - try: - self.runtime.publish(self, 'grade', { - 'value': self._get_grade(), - 'max_value': self.weight, - }) - except NotImplementedError: - # Note, this publish method is unimplemented in Studio runtimes, - # so we have to figure that we're running in Studio for now - pass + self.num_attempts += 1 + self._mark_complete_and_publish_grade() - self.runtime.publish(self, 'edx.drag_and_drop_v2.item.dropped', { - 'item_id': item['id'], - 'location': zone.get("title"), - 'location_id': zone.get("uid"), - 'is_correct': is_correct, - }) + __, placed, correct = self._get_item_raw_stats() + placed_ids, correct_ids = set(placed), set(correct) + misplaced_ids = placed_ids - correct_ids - if self.mode == self.ASSESSMENT_MODE: - # In assessment mode we don't send any feedback on drop. - result = {} + feedback_msgs = [_('Correctly placed {correct_count} items, misplaced {misplaced_count} items.').format( + correct_count=len(correct_ids), + misplaced_count=len(misplaced_ids) + )] + + if misplaced_ids: + feedback_msgs.append(_('Misplaced items were returned to item bank.')) else: - result = { - 'correct': is_correct, - 'finished': self._is_finished(), - 'overall_feedback': overall_feedback, - 'feedback': feedback - } + feedback_msgs.append(self.data['feedback']['finish']) - return result + for item_id in misplaced_ids: + del self.item_state[item_id] + + if not self.attemps_remain: + feedback_msgs.append(_('Final attempt was used, final score is {score}').format(score=self._get_grade())) + + return { + 'num_attempts': self.num_attempts, + 'misplaced_items': list(misplaced_ids), + 'feedback': '\n'.join(feedback_msgs), + 'attempts_remain': self.attemps_remain + } + + def _validate_attempt(self): + if self.mode != self.ASSESSMENT_MODE: + raise JsonHandlerError(400, _("do_attempt handler should only be called for assessment mode")) + if not self.attemps_remain: + raise JsonHandlerError(409, _("Max number of attempts reached")) + + @XBlock.json_handler + def publish_event(self, data, suffix=''): + try: + event_type = data.pop('event_type') + except KeyError: + return {'result': 'error', 'message': 'Missing event_type in JSON data'} + + self.runtime.publish(self, event_type, data) + return {'result': 'success'} @XBlock.json_handler def reset(self, data, suffix=''): self.item_state = {} return self._get_user_state() - def _is_attempt_correct(self, attempt): - """ - Check if the item was placed correctly. - """ - correct_zones = self._get_item_zones(attempt['val']) - return attempt['zone'] in correct_zones - - def _expand_static_url(self, url): - """ - This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the - only portable URL format for static files that works across export/import and reruns). - This method is unfortunately a bit hackish since XBlock does not provide a low-level API - for this. - """ - if hasattr(self.runtime, 'replace_urls'): - url = self.runtime.replace_urls('"{}"'.format(url))[1:-1] - elif hasattr(self.runtime, 'course_id'): - # edX Studio uses a different runtime for 'studio_view' than 'student_view', - # and the 'studio_view' runtime doesn't provide the replace_urls API. - try: - from static_replace import replace_static_urls # pylint: disable=import-error - url = replace_static_urls('"{}"'.format(url), None, course_id=self.runtime.course_id)[1:-1] - except ImportError: - pass - return url - @XBlock.json_handler def expand_static_url(self, url, suffix=''): """ AJAX-accessible handler for expanding URLs to static [image] files """ @@ -412,12 +382,120 @@ def default_background_image_url(self): """ The URL to the default background image, shown when no custom background is used """ return self.runtime.local_resource_url(self, "public/img/triangle.png") + @property + def attemps_remain(self): + return self.max_attempts is None or self.num_attempts < self.max_attempts + @XBlock.handler def get_user_state(self, request, suffix=''): """ GET all user-specific data, and any applicable feedback """ data = self._get_user_state() return webob.Response(body=json.dumps(data), content_type='application/json') + def _drop_item_standard(self, item_attempt): + item = self._get_item_definition(item_attempt['val']) + + is_correct = self._is_attempt_correct(item_attempt) # Student placed item in a correct zone + if is_correct: # In standard mode state is only updated when attempt is correct + self.item_state[str(item['id'])] = self._make_state_from_attempt(item_attempt, is_correct) + + self._mark_complete_and_publish_grade() + self._publish_item_dropped_event(item_attempt, is_correct) + + item_feedback_key = 'correct' if is_correct else 'incorrect' + item_feedback = item['feedback'][item_feedback_key] + overall_feedback = self.data['feedback']['finish'] if self._is_finished() else None + + return { + 'correct': is_correct, + 'finished': self._is_finished(), + 'overall_feedback': overall_feedback, + 'feedback': item_feedback + } + + def _drop_item_assessment(self, item_attempt): + if not self.attemps_remain: + raise JsonHandlerError(409, _("Max number of attempts reached")) + + item = self._get_item_definition(item_attempt['val']) + + is_correct = self._is_attempt_correct(item_attempt) + # State is always updated in assessment mode, to make do_attempt work correctly + self.item_state[str(item['id'])] = self._make_state_from_attempt(item_attempt, is_correct) + + self._publish_item_dropped_event(item_attempt, self._is_attempt_correct(item_attempt)) + + return {} + + def _validate_drop_item(self, item): + zone = self._get_zone_by_uid(item['zone']) + if not zone: + raise JsonHandlerError(400, "Item zone data invalid.") + + @staticmethod + def _make_state_from_attempt(attempt, correct): + return { + 'zone': attempt['zone'], + 'correct': correct, + 'x_percent': attempt['x_percent'], + 'y_percent': attempt['y_percent'], + } + + def _mark_complete_and_publish_grade(self): + # don't publish the grade if the student has already completed the problem + if not self.completed: + self.completed = self._is_finished() + self._publish_grade() + + def _publish_grade(self): + try: + self.runtime.publish(self, 'grade', { + 'value': self._get_grade(), + 'max_value': self.weight, + }) + except NotImplementedError: + # Note, this publish method is unimplemented in Studio runtimes, + # so we have to figure that we're running in Studio for now + pass + + def _publish_item_dropped_event(self, attempt, is_correct): + item = self._get_item_definition(attempt['val']) + # attempt should already be validated here - not doing the check for existing zone again + zone = self._get_zone_by_uid(attempt['zone']) + + self.runtime.publish(self, 'edx.drag_and_drop_v2.item.dropped', { + 'item_id': item['id'], + 'location': zone.get("title"), + 'location_id': zone.get("uid"), + 'is_correct': is_correct, + }) + + def _is_attempt_correct(self, attempt): + """ + Check if the item was placed correctly. + """ + correct_zones = self._get_item_zones(attempt['val']) + return attempt['zone'] in correct_zones + + def _expand_static_url(self, url): + """ + This is required to make URLs like '/static/dnd-test-image.png' work (note: that is the + only portable URL format for static files that works across export/import and reruns). + This method is unfortunately a bit hackish since XBlock does not provide a low-level API + for this. + """ + if hasattr(self.runtime, 'replace_urls'): + url = self.runtime.replace_urls('"{}"'.format(url))[1:-1] + elif hasattr(self.runtime, 'course_id'): + # edX Studio uses a different runtime for 'studio_view' than 'student_view', + # and the 'studio_view' runtime doesn't provide the replace_urls API. + try: + from static_replace import replace_static_urls # pylint: disable=import-error + url = replace_static_urls('"{}"'.format(url), None, course_id=self.runtime.course_id)[1:-1] + except ImportError: + pass + return url + def _get_user_state(self): """ Get all user-specific data, and any applicable feedback """ item_state = self._get_item_state() @@ -512,17 +590,22 @@ def _get_item_stats(self): Returns a tuple representing the number of correctly-placed items, and the total number of items that must be placed on the board (non-decoy items). """ - all_items = self.data['items'] - item_state = self._get_item_state() + required_items, _, correct_items = self._get_item_raw_stats() - required_items = [str(item['id']) for item in all_items if self._get_item_zones(item['id']) != []] - placed_items = [item for item in required_items if item in item_state] - correct_items = [item for item in placed_items if item_state[item]['correct']] + return len(correct_items), len(required_items) + + def _get_item_raw_stats(self): + """ + Returns a 3-tuple containing required, placed and correct items. + """ + all_items = [str(item['id']) for item in self.data['items']] + item_state = self._get_item_state() - required_count = len(required_items) - correct_count = len(correct_items) + required_items = [item_id for item_id in all_items if self._get_item_zones(int(item_id)) != []] + placed_items = [item_id for item_id in all_items if item_id in item_state] + correct_items = [item_id for item_id in placed_items if item_state[item_id]['correct']] - return correct_count, required_count + return required_items, placed_items, correct_items def _get_grade(self): """ @@ -539,16 +622,6 @@ def _is_finished(self): correct_count, required_count = self._get_item_stats() return correct_count == required_count - @XBlock.json_handler - def publish_event(self, data, suffix=''): - try: - event_type = data.pop('event_type') - except KeyError: - return {'result': 'error', 'message': 'Missing event_type in JSON data'} - - self.runtime.publish(self, event_type, data) - return {'result': 'success'} - def _get_unique_id(self): usage_id = self.scope_ids.usage_id try: diff --git a/drag_and_drop_v2/public/js/drag_and_drop.js b/drag_and_drop_v2/public/js/drag_and_drop.js index ce8c3d7e5..29678fee5 100644 --- a/drag_and_drop_v2/public/js/drag_and_drop.js +++ b/drag_and_drop_v2/public/js/drag_and_drop.js @@ -223,18 +223,16 @@ function DragAndDropTemplates(configuration) { var submitAnswerTemplate = function(ctx) { var attemptsUsedDisplay = (ctx.max_attempts && ctx.max_attempts > 0) ? 'inline': 'none'; - var button_enabled = ctx.items.some(function(item) {return item.is_placed;}) && - (ctx.max_attempts === null || ctx.max_attempts > ctx.num_attempts); return ( - h("section.action-toolbar-item.submit-answer", {}, [ - h("button.btn-brand.submit-answer-button", {disabled: !button_enabled}, gettext("Submit")), - h( - "span.attempts-used", {style: {display: attemptsUsedDisplay}}, - gettext("You have used {used} of {total} attempts.") - .replace("{used}", ctx.num_attempts).replace("{total}", ctx.max_attempts) - ) - ]) + h("section.action-toolbar-item.submit-answer", {}, [ + h("button.btn-brand.submit-answer-button", {disabled: ctx.disable_submit_button}, gettext("Submit")), + h( + "span.attempts-used", {style: {display: attemptsUsedDisplay}}, + gettext("You have used {used} of {total} attempts.") + .replace("{used}", ctx.num_attempts).replace("{total}", ctx.max_attempts) + ) + ]) ); }; @@ -388,6 +386,7 @@ function DragAndDropBlock(runtime, element, configuration) { // Set up event handlers: $(document).on('keydown mousedown touchstart', closePopup); + $element.on('click', '.submit-answer-button', doAttempt); $element.on('click', '.keyboard-help-button', showKeyboardHelp); $element.on('keydown', '.keyboard-help-button', function(evt) { runOnKey(evt, RET, showKeyboardHelp); @@ -786,7 +785,7 @@ function DragAndDropBlock(runtime, element, configuration) { if (!zone) { return; } - var url = runtime.handlerUrl(element, 'do_attempt'); + var url = runtime.handlerUrl(element, 'drop_item'); var data = { val: item_id, zone: zone, @@ -860,6 +859,44 @@ function DragAndDropBlock(runtime, element, configuration) { }); }; + var doAttempt = function(evt) { + evt.preventDefault(); + + $.ajax({ + type: 'POST', + url: runtime.handlerUrl(element, "do_attempt"), + data: '{}' + }).done(function(data){ + state.num_attempts = data.num_attempts; + state.overall_feedback = data.feedback; + if (data.attempts_remain) { + for (var i=0; i < data.misplaced_items.length; i++) { + var misplaced_item_id = data.misplaced_items[i]; + delete state.items[misplaced_item_id] + } + } + else { + state.finished = true; + } + applyState(); + focusFirstDraggable(); + }) + }; + + var canSubmitAttempt = function() { + return Object.keys(state.items).length > 0 && ( + configuration.max_attempts === null || configuration.max_attempts > state.num_attempts + ); + }; + + var canReset = function() { + return Object.keys(state.items).length > 0 && + ( + configuration.mode != DragAndDropBlock.ASSESSMENT_MODE || + canSubmitAttempt() + ) + }; + var render = function() { var items = configuration.items.map(function(item) { var item_user_state = state.items[item.id]; @@ -918,7 +955,8 @@ function DragAndDropBlock(runtime, element, configuration) { last_action_correct: state.last_action_correct, popup_html: state.feedback || '', feedback_html: $.trim(state.overall_feedback), - disable_reset_button: Object.keys(state.items).length == 0, + disable_reset_button: !canReset(), + disable_submit_button: !canSubmitAttempt() }; return renderView(context); diff --git a/tests/unit/test_advanced.py b/tests/unit/test_advanced.py index 3be85e7ea..ce9b75a37 100644 --- a/tests/unit/test_advanced.py +++ b/tests/unit/test_advanced.py @@ -62,10 +62,10 @@ class StandardModeFixture(BaseDragAndDropAjaxFixture): """ Common tests for drag and drop in standard mode """ - def test_do_attempt_wrong_with_feedback(self): + def test_drop_item_wrong_with_feedback(self): item_id, zone_id = 0, self.ZONE_2 data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} - res = self.call_handler('do_attempt', data) + res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, { "overall_feedback": None, "finished": False, @@ -73,10 +73,10 @@ def test_do_attempt_wrong_with_feedback(self): "feedback": self.FEEDBACK[item_id]["incorrect"] }) - def test_do_attempt_wrong_without_feedback(self): + def test_drop_item_wrong_without_feedback(self): item_id, zone_id = 2, self.ZONE_1 data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} - res = self.call_handler('do_attempt', data) + res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, { "overall_feedback": None, "finished": False, @@ -84,10 +84,10 @@ def test_do_attempt_wrong_without_feedback(self): "feedback": self.FEEDBACK[item_id]["incorrect"] }) - def test_do_attempt_correct(self): + def test_drop_item_correct(self): item_id, zone_id = 0, self.ZONE_1 data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} - res = self.call_handler('do_attempt', data) + res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, { "overall_feedback": None, "finished": False, @@ -103,23 +103,23 @@ def mock_publish(self, event, params): published_grades.append(params) self.block.runtime.publish = mock_publish - self.call_handler('do_attempt', { + self.call_handler(self.DROP_ITEM_HANDLER, { "val": 0, "zone": self.ZONE_1, "y_percent": "11%", "x_percent": "33%" }) self.assertEqual(1, len(published_grades)) self.assertEqual({'value': 0.5, 'max_value': 1}, published_grades[-1]) - self.call_handler('do_attempt', { + self.call_handler(self.DROP_ITEM_HANDLER, { "val": 1, "zone": self.ZONE_2, "y_percent": "90%", "x_percent": "42%" }) self.assertEqual(2, len(published_grades)) self.assertEqual({'value': 1, 'max_value': 1}, published_grades[-1]) - def test_do_attempt_final(self): + def test_drop_item_final(self): data = {"val": 0, "zone": self.ZONE_1, "x_percent": "33%", "y_percent": "11%"} - self.call_handler('do_attempt', data) + self.call_handler(self.DROP_ITEM_HANDLER, data) expected_state = { "items": { @@ -132,7 +132,7 @@ def test_do_attempt_final(self): self.assertEqual(expected_state, self.call_handler('get_user_state', method="GET")) data = {"val": 1, "zone": self.ZONE_2, "x_percent": "22%", "y_percent": "22%"} - res = self.call_handler('do_attempt', data) + res = self.call_handler(self.DROP_ITEM_HANDLER, data) self.assertEqual(res, { "overall_feedback": self.FINAL_FEEDBACK, "finished": True, @@ -160,10 +160,10 @@ class AssessmentModeFixture(BaseDragAndDropAjaxFixture): """ Common tests for drag and drop in assessment mode """ - def test_do_attempt_in_assessment_mode(self): + def test_drop_item_in_assessment_mode(self): item_id, zone_id = 0, self.ZONE_1 data = {"val": item_id, "zone": zone_id, "x_percent": "33%", "y_percent": "11%"} - res = self.call_handler('do_attempt', data) + res = self.call_handler(self.DROP_ITEM_HANDLER, data) # In assessment mode, the do_attempt doesn't return any data. self.assertEqual(res, {}) diff --git a/tests/unit/test_basics.py b/tests/unit/test_basics.py index b7c570980..70946dd1b 100644 --- a/tests/unit/test_basics.py +++ b/tests/unit/test_basics.py @@ -76,13 +76,13 @@ def assert_user_state_empty(): # Drag three items into the correct spot: data = {"val": 0, "zone": TOP_ZONE_ID, "x_percent": "33%", "y_percent": "11%"} - self.call_handler('do_attempt', data) + self.call_handler(self.DROP_ITEM_HANDLER, data) data = {"val": 1, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} - self.call_handler('do_attempt', data) + self.call_handler(self.DROP_ITEM_HANDLER, data) data = {"val": 2, "zone": BOTTOM_ZONE_ID, "x_percent": "99%", "y_percent": "95%"} - self.call_handler('do_attempt', data) + self.call_handler(self.DROP_ITEM_HANDLER, data) data = {"val": 3, "zone": MIDDLE_ZONE_ID, "x_percent": "67%", "y_percent": "80%"} - self.call_handler('do_attempt', data) + self.call_handler(self.DROP_ITEM_HANDLER, data) # Check the result: self.assertTrue(self.block.completed) diff --git a/tests/utils.py b/tests/utils.py index 2f6ce9036..da50e171a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -35,6 +35,9 @@ class TestCaseMixin(object): """ Helpful mixins for unittest TestCase subclasses """ maxDiff = None + DROP_ITEM_HANDLER = 'drop_item' + DO_ATTEMPT_HANDLER = 'do_attempt' + def patch_workbench(self): self.apply_patch( 'workbench.runtime.WorkbenchRuntime.local_resource_url',