diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 43832bfaa7..030eecbfdc 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -12,16 +12,22 @@ file and check it in at the same time as your model changes. To do that, ASSUMPTIONS: modules have unique IDs, even across different module_types """ +import logging + from django.contrib.auth.models import User from django.conf import settings from django.db import models from django.db.models.signals import post_save -from django.dispatch import receiver +from django.dispatch import receiver, Signal from model_utils.models import TimeStampedModel +from student.models import user_by_anonymous_id +from submissions.models import score_set, score_reset from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField # pylint: disable=import-error +log = logging.getLogger("edx.courseware") + class StudentModule(models.Model): """ @@ -248,3 +254,101 @@ class StudentFieldOverride(TimeStampedModel): field = models.CharField(max_length=255) value = models.TextField(default='null') + + +# Signal that indicates that a user's score for a problem has been updated. +# This signal is generated when a scoring event occurs either within the core +# platform or in the Submissions module. Note that this signal will be triggered +# regardless of the new and previous values of the score (i.e. it may be the +# case that this signal is generated when a user re-attempts a problem but +# receives the same score). +SCORE_CHANGED = Signal( + providing_args=[ + 'points_possible', # Maximum score available for the exercise + 'points_earned', # Score obtained by the user + 'user_id', # Integer User ID + 'course_id', # Unicode string representing the course + 'usage_id' # Unicode string indicating the courseware instance + ] +) + + +@receiver(score_set) +def submissions_score_set_handler(sender, **kwargs): # pylint: disable=unused-argument + """ + Consume the score_set signal defined in the Submissions API, and convert it + to a SCORE_CHANGED signal defined in this module. Converts the unicode keys + for user, course and item into the standard representation for the + SCORE_CHANGED signal. + + This method expects that the kwargs dictionary will contain the following + entries (See the definition of score_set): + - 'points_possible': integer, + - 'points_earned': integer, + - 'anonymous_user_id': unicode, + - 'course_id': unicode, + - 'item_id': unicode + """ + points_possible = kwargs.get('points_possible', None) + points_earned = kwargs.get('points_earned', None) + course_id = kwargs.get('course_id', None) + usage_id = kwargs.get('item_id', None) + user = None + if 'anonymous_user_id' in kwargs: + user = user_by_anonymous_id(kwargs.get('anonymous_user_id')) + + # If any of the kwargs were missing, at least one of the following values + # will be None. + if all((user, points_possible, points_earned, course_id, usage_id)): + SCORE_CHANGED.send( + sender=None, + points_possible=points_possible, + points_earned=points_earned, + user_id=user.id, + course_id=course_id, + usage_id=usage_id + ) + else: + log.exception( + u"Failed to process score_set signal from Submissions API. " + "points_possible: %s, points_earned: %s, user: %s, course_id: %s, " + "usage_id: %s", points_possible, points_earned, user, course_id, usage_id + ) + + +@receiver(score_reset) +def submissions_score_reset_handler(sender, **kwargs): # pylint: disable=unused-argument + """ + Consume the score_reset signal defined in the Submissions API, and convert + it to a SCORE_CHANGED signal indicating that the score has been set to 0/0. + Converts the unicode keys for user, course and item into the standard + representation for the SCORE_CHANGED signal. + + This method expects that the kwargs dictionary will contain the following + entries (See the definition of score_reset): + - 'anonymous_user_id': unicode, + - 'course_id': unicode, + - 'item_id': unicode + """ + course_id = kwargs.get('course_id', None) + usage_id = kwargs.get('item_id', None) + user = None + if 'anonymous_user_id' in kwargs: + user = user_by_anonymous_id(kwargs.get('anonymous_user_id')) + + # If any of the kwargs were missing, at least one of the following values + # will be None. + if all((user, course_id, usage_id)): + SCORE_CHANGED.send( + sender=None, + points_possible=0, + points_earned=0, + user_id=user.id, + course_id=course_id, + usage_id=usage_id + ) + else: + log.exception( + u"Failed to process score_reset signal from Submissions API. " + "user: %s, course_id: %s, usage_id: %s", user, course_id, usage_id + ) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 73d0187af3..d1375c2474 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -30,6 +30,7 @@ from capa.xqueue_interface import XQueueInterface from courseware.access import has_access, get_user_role from courseware.masquerade import setup_masquerade from courseware.model_data import FieldDataCache, DjangoKeyValueStore +from courseware.models import SCORE_CHANGED from courseware.entrance_exams import ( get_entrance_exam_score, user_must_complete_entrance_exam @@ -450,6 +451,17 @@ def get_module_system_for_user(user, field_data_cache, descriptor.location, ) + # Send a signal out to any listeners who are waiting for score change + # events. + SCORE_CHANGED.send( + sender=None, + points_possible=event['max_value'], + points_earned=event['value'], + user_id=user_id, + course_id=unicode(course_id), + usage_id=unicode(descriptor.location) + ) + def publish(block, event_type, event): """A function that allows XModules to publish events.""" if event_type == 'grade': diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 63f0be68bf..b8f4bebc93 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1246,6 +1246,20 @@ class TestXmoduleRuntimeEvent(TestSubmittingProblems): self.assertIsNone(student_module.grade) self.assertIsNone(student_module.max_grade) + @patch('courseware.module_render.SCORE_CHANGED.send') + def test_score_change_signal(self, send_mock): + """Test that a Django signal is generated when a score changes""" + self.set_module_grade_using_publish(self.grade_dict) + expected_signal_kwargs = { + 'sender': None, + 'points_possible': self.grade_dict['max_value'], + 'points_earned': self.grade_dict['value'], + 'user_id': self.student_user.id, + 'course_id': unicode(self.course.id), + 'usage_id': unicode(self.problem.location) + } + send_mock.assert_called_with(**expected_signal_kwargs) + @attr('shard_1') class TestRebindModule(TestSubmittingProblems): diff --git a/lms/djangoapps/courseware/tests/test_signals.py b/lms/djangoapps/courseware/tests/test_signals.py new file mode 100644 index 0000000000..7ff4a3a136 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_signals.py @@ -0,0 +1,157 @@ +""" +Tests for the score change signals defined in the courseware models module. +""" + +from django.test import TestCase +from mock import patch, MagicMock + +from courseware.models import submissions_score_set_handler, submissions_score_reset_handler + +SUBMISSION_SET_KWARGS = { + 'points_possible': 10, + 'points_earned': 5, + 'anonymous_user_id': 'anonymous_id', + 'course_id': 'CourseID', + 'item_id': 'i4x://org/course/usage/123456' +} + +SUBMISSION_RESET_KWARGS = { + 'anonymous_user_id': 'anonymous_id', + 'course_id': 'CourseID', + 'item_id': 'i4x://org/course/usage/123456' +} + + +class SubmissionSignalRelayTest(TestCase): + """ + Tests to ensure that the courseware module correctly catches score_set and + score_reset signals from the Submissions API and recasts them as LMS + signals. This ensures that listeners in the LMS only have to handle one type + of signal for all scoring events. + """ + + def setUp(self): + """ + Configure mocks for all the dependencies of the render method + """ + super(SubmissionSignalRelayTest, self).setUp() + self.signal_mock = self.setup_patch('courseware.models.SCORE_CHANGED.send', None) + self.user_mock = MagicMock() + self.user_mock.id = 42 + self.get_user_mock = self.setup_patch('courseware.models.user_by_anonymous_id', self.user_mock) + + def setup_patch(self, function_name, return_value): + """ + Patch a function with a given return value, and return the mock + """ + mock = MagicMock(return_value=return_value) + new_patch = patch(function_name, new=mock) + new_patch.start() + self.addCleanup(new_patch.stop) + return mock + + def setup_patch_with_mock(self, function_name, mock): + """ + Patch a function with a given mock + """ + new_patch = patch(function_name, new=mock) + new_patch.start() + self.addCleanup(new_patch.stop) + return mock + + def test_score_set_signal_handler(self): + """ + Ensure that, on receipt of a score_set signal from the Submissions API, + the courseware model correctly converts it to a score_changed signal + """ + submissions_score_set_handler(None, **SUBMISSION_SET_KWARGS) + expected_set_kwargs = { + 'sender': None, + 'points_possible': 10, + 'points_earned': 5, + 'user_id': 42, + 'course_id': 'CourseID', + 'usage_id': 'i4x://org/course/usage/123456' + } + self.signal_mock.assert_called_once_with(**expected_set_kwargs) + + def test_score_set_user_conversion(self): + """ + Ensure that the score_set handler properly calls the + user_by_anonymous_id method to convert from an anonymized ID to a user + object + """ + submissions_score_set_handler(None, **SUBMISSION_SET_KWARGS) + self.get_user_mock.assert_called_once_with('anonymous_id') + + def test_score_set_missing_kwarg(self): + """ + Ensure that, on receipt of a score_set signal from the Submissions API + that does not have the correct kwargs, the courseware model does not + generate a signal. + """ + for missing in SUBMISSION_SET_KWARGS: + kwargs = SUBMISSION_SET_KWARGS.copy() + del kwargs[missing] + + submissions_score_set_handler(None, **kwargs) + self.signal_mock.assert_not_called() + + def test_score_set_bad_user(self): + """ + Ensure that, on receipt of a score_set signal from the Submissions API + that has an invalid user ID, the courseware model does not generate a + signal. + """ + self.get_user_mock = self.setup_patch('courseware.models.user_by_anonymous_id', None) + submissions_score_set_handler(None, **SUBMISSION_SET_KWARGS) + self.signal_mock.assert_not_called() + + def test_score_reset_signal_handler(self): + """ + Ensure that, on receipt of a score_reset signal from the Submissions + API, the courseware model correctly converts it to a score_changed + signal + """ + submissions_score_reset_handler(None, **SUBMISSION_RESET_KWARGS) + expected_reset_kwargs = { + 'sender': None, + 'points_possible': 0, + 'points_earned': 0, + 'user_id': 42, + 'course_id': 'CourseID', + 'usage_id': 'i4x://org/course/usage/123456' + } + self.signal_mock.assert_called_once_with(**expected_reset_kwargs) + + def test_score_reset_user_conversion(self): + """ + Ensure that the score_reset handler properly calls the + user_by_anonymous_id method to convert from an anonymized ID to a user + object + """ + submissions_score_reset_handler(None, **SUBMISSION_RESET_KWARGS) + self.get_user_mock.assert_called_once_with('anonymous_id') + + def test_score_reset_missing_kwarg(self): + """ + Ensure that, on receipt of a score_reset signal from the Submissions API + that does not have the correct kwargs, the courseware model does not + generate a signal. + """ + for missing in SUBMISSION_RESET_KWARGS: + kwargs = SUBMISSION_RESET_KWARGS.copy() + del kwargs[missing] + + submissions_score_reset_handler(None, **kwargs) + self.signal_mock.assert_not_called() + + def test_score_reset_bad_user(self): + """ + Ensure that, on receipt of a score_reset signal from the Submissions API + that has an invalid user ID, the courseware model does not generate a + signal. + """ + self.get_user_mock = self.setup_patch('courseware.models.user_by_anonymous_id', None) + submissions_score_reset_handler(None, **SUBMISSION_RESET_KWARGS) + self.signal_mock.assert_not_called() diff --git a/lms/djangoapps/lti_provider/models.py b/lms/djangoapps/lti_provider/models.py index cd4d065d29..8b0e929659 100644 --- a/lms/djangoapps/lti_provider/models.py +++ b/lms/djangoapps/lti_provider/models.py @@ -2,6 +2,9 @@ Database models for the LTI provider feature. """ from django.db import models +from django.dispatch import receiver + +from courseware.models import SCORE_CHANGED class LtiConsumer(models.Model): @@ -12,3 +15,27 @@ class LtiConsumer(models.Model): """ key = models.CharField(max_length=32, unique=True, db_index=True) secret = models.CharField(max_length=32, unique=True) + + +@receiver(SCORE_CHANGED) +def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument + """ + Consume signals that indicate score changes. + + TODO: This function is a placeholder for integration with the LTI 1.1 + outcome service, which will follow in a separate change. + """ + message = """LTI Provider got score change event: + points_possible: {} + points_earned: {} + user_id: {} + course_id: {} + usage_id: {} + """ + print message.format( + kwargs.get('points_possible', None), + kwargs.get('points_earned', None), + kwargs.get('user_id', None), + kwargs.get('course_id', None), + kwargs.get('usage_id', None), + )