From d5d83acfd5cb4fa886f2dd870fbc42e5a19beea4 Mon Sep 17 00:00:00 2001 From: Giovanni Cimolin da Silva <giovannicimolin@gmail.com> Date: Fri, 26 Mar 2021 14:49:00 -0300 Subject: [PATCH] feat: Improve grade publishing handling on LTI 1.3 This commit improves the functionality of the grade publishing method. Improvements: * Doesn't publish grade to LMS if this isn't a graded block. * Doesn't try to publish grades when LineItem is not linked (`resourceLinkId` is empty) * Improves logging and properly logs exceptions --- lti_consumer/signals.py | 59 ++++++++++--- .../tests/unit/plugin/test_views_lti_ags.py | 84 ++++++++++++------- 2 files changed, 100 insertions(+), 43 deletions(-) diff --git a/lti_consumer/signals.py b/lti_consumer/signals.py index 83f242f..b239349 100644 --- a/lti_consumer/signals.py +++ b/lti_consumer/signals.py @@ -1,6 +1,7 @@ """ LTI Consumer related Signal handlers """ +import logging from django.db.models.signals import post_save from django.dispatch import receiver @@ -9,21 +10,55 @@ from lti_consumer.models import LtiAgsScore from lti_consumer.plugin import compat +log = logging.getLogger(__name__) + + @receiver(post_save, sender=LtiAgsScore, dispatch_uid='publish_grade_on_score_update') def publish_grade_on_score_update(sender, instance, **kwargs): # pylint: disable=unused-argument """ Publish grade to xblock whenever score saved/updated and its grading_progress is set to FullyGraded. + + This method DOES NOT WORK on Studio, since it relies on APIs only available and configured + in the LMS. Trying to trigger this signal from Studio (from the Django-admin interface, for example) + throw an exception. """ - if instance.grading_progress == LtiAgsScore.FULLY_GRADED: - block = compat.load_block_as_anonymous_user(instance.line_item.resource_link_id) - if not block.is_past_due() or block.accept_grades_past_due: - user = compat.get_user_from_external_user_id(instance.user_id) - # check if score_given is larger than score_maximum - score = instance.score_given if instance.score_given < instance.score_maximum else instance.score_maximum - compat.publish_grade( - block, - user, - score, - instance.score_maximum, - comment=instance.comment, + # Before starting to publish grades to the LMS, check that: + # 1. The grade being submitted in the final one - `FullyGraded` + # 2. This LineItem is linked to a LMS grade - the `LtiResouceLinkId` field is set + # 3. There's a valid grade in this score - `scoreGiven` is set + if instance.grading_progress == LtiAgsScore.FULLY_GRADED \ + and instance.line_item.resource_link_id \ + and instance.score_given: + try: + # Load block using LMS APIs and check if the block is graded and still accept grades. + block = compat.load_block_as_anonymous_user(instance.line_item.resource_link_id) + if block.has_score and (not block.is_past_due() or block.accept_grades_past_due): + # Map external ID to platform user + user = compat.get_user_from_external_user_id(instance.user_id) + + # The LTI AGS spec allow tools to send grades higher than score maximum, so + # we have to cap the score sent to the gradebook to the maximum allowed value. + # Also, this is an normalized score ranging from 0 to 1. + score = min(instance.score_given, instance.score_maximum) / instance.score_maximum + + # Set module score using XBlock custom method to do so. + # This saves the score on both the XBlock's K/V store as well as in + # the LMS database. + log.info( + "Publishing LTI grade from block %s to LMS. User: %s (score: %s)", + block.location, + user, + score, + ) + block.set_user_module_score(user, score, block.max_score(), instance.comment) + + # This is a catch all exception to catch and log any issues related to loading the block + # from the modulestore and other LMS API calls + except Exception as exc: + log.exception( + "Error while publishing score %r to block %s to LMS: %s", + instance, + instance.line_item.resource_link_id, + exc, ) + raise exc diff --git a/lti_consumer/tests/unit/plugin/test_views_lti_ags.py b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py index 791c677..cff1caf 100644 --- a/lti_consumer/tests/unit/plugin/test_views_lti_ags.py +++ b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py @@ -422,44 +422,63 @@ class LtiAgsViewSetScoresTests(LtiAgsLineItemViewSetTestCase): @ddt.data( LtiAgsScore.PENDING, LtiAgsScore.PENDING_MANUAL, - LtiAgsScore.FULLY_GRADED, LtiAgsScore.FAILED, LtiAgsScore.NOT_READY, ) - def test_xblock_grade_publish_on_score_save(self, grading_progress): + def test_grade_publish_not_called_when_pending(self, grading_progress): """ - Test on LtiAgsScore save, if gradingProgress is Fully Graded then xblock grade should be submitted. + Check that the grade is not submmitted to LMS if the status is a pending one. """ - + # Set xblock attribute and make score request + self.xblock.has_score = True self._post_lti_score({ "gradingProgress": grading_progress, }) - if grading_progress == LtiAgsScore.FULLY_GRADED: - score = LtiAgsScore.objects.get(line_item=self.line_item, user_id=self.primary_user_id) + self._compat_mock.load_block_as_anonymous_user.assert_not_called() + self._compat_mock.get_user_from_external_user_id.assert_not_called() - self._compat_mock.publish_grade.assert_called_once() - self._compat_mock.get_user_from_external_user_id.assert_called_once() - self._compat_mock.load_block_as_anonymous_user.assert_called_once() + def test_xblock_grade_publish_on_score_save(self): + """ + Test that the grade is submitted when gradingProgress is `FullyGraded`. + """ + # Set up LMS mocks + self._compat_mock.load_block_as_anonymous_user.return_value = self.xblock + self._compat_mock.get_user_from_external_user_id.return_value = 'user_mock' + self.xblock.set_user_module_score = Mock() - call_args = self._compat_mock.publish_grade.call_args.args - call_kwargs = self._compat_mock.publish_grade.call_args.kwargs - self.assertEqual(call_args, (self.xblock, self._mock_user, score.score_given, score.score_maximum,)) - self.assertEqual(call_kwargs['comment'], score.comment) - else: - self._compat_mock.load_block_as_anonymous_user.assert_not_called() - self._compat_mock.get_user_from_external_user_id.assert_not_called() - self._compat_mock.publish_grade.assert_not_called() + # Set xblock attribute and make score request + self.xblock.has_score = True + self._post_lti_score({ + "gradingProgress": "FullyGraded", + }) + + # Check if publish grade was called + self.xblock.set_user_module_score.assert_called_once() + self._compat_mock.get_user_from_external_user_id.assert_called_once() + self._compat_mock.load_block_as_anonymous_user.assert_called_once() + + call_args = self.xblock.set_user_module_score.call_args.args + self.assertEqual(call_args, ('user_mock', 0.83, 1, 'This is exceptional work.')) def test_grade_publish_score_bigger_than_maximum(self): """ Test when given score is bigger than maximum score. """ + # Return block bypassing LMS API + self._compat_mock.load_block_as_anonymous_user.return_value = self.xblock + self._compat_mock.get_user_from_external_user_id.return_value = 'user_mock' + self.xblock.set_user_module_score = Mock() + + # Set block as graded + self.xblock.has_score = True + + # Post and retrieve score object self._post_lti_score({ "scoreGiven": 110, "scoreMaximum": 100, + "comment": "comment", }) - score = LtiAgsScore.objects.get(line_item=self.line_item, user_id=self.primary_user_id) # Check that the function was called as expected # and that the correct variables were passed as arguments @@ -490,35 +509,38 @@ class LtiAgsViewSetScoresTests(LtiAgsLineItemViewSetTestCase): """ Test grade publish after due date. Grade shouldn't publish """ + self.xblock.set_user_module_score = Mock() timezone_patcher.now.return_value = timezone.now() + timedelta(days=30) self._post_lti_score() + # Check that the block wasn't set if due date is past self._compat_mock.load_block_as_anonymous_user.assert_called_once() - self._compat_mock.get_user_from_external_user_id.assert_not_called() - self._compat_mock.publish_grade.assert_not_called() + self.xblock.set_user_module_score.assert_not_called() @patch('lti_consumer.lti_xblock.timezone') def test_xblock_grade_publish_accept_passed_due_date(self, timezone_patcher): """ Test grade publish after due date when accept_grades_past_due is True. Grade should publish. """ - xblock_attrs = { - 'accept_grades_past_due': True, - } - xblock_attrs.update(self.xblock_attributes) - xblock = make_xblock('lti_consumer', LtiConsumerXBlock, xblock_attrs) - self._compat_mock.load_block_as_anonymous_user.return_value = xblock + # Return block bypassing LMS API + self._compat_mock.load_block_as_anonymous_user.return_value = self.xblock + self._compat_mock.get_user_from_external_user_id.return_value = 'user_mock' + self.xblock.set_user_module_score = Mock() - timezone_patcher.now.return_value = timezone.now() + timedelta(days=30) + # Change block attribute + self.xblock.has_score = True + self.xblock.accept_grades_past_due = True + # Try sending grade after due date + timezone_patcher.now.return_value = timezone.now() + timedelta(days=30) self._post_lti_score() - self._compat_mock.load_block_as_anonymous_user.assert_called_once() - - self._compat_mock.get_user_from_external_user_id.assert_not_called() - self._compat_mock.publish_grade.assert_not_called() + # Check that the grade is published + self.xblock.set_user_module_score.assert_called_once() + call_args = self.xblock.set_user_module_score.call_args.args + self.assertEqual(call_args, ('user_mock', 0.83, 1, 'This is exceptional work.')) def test_create_multiple_scores_with_multiple_users(self): """ -- GitLab