[LTI Provider] Adding signals for scoring events
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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):
|
||||
|
||||
157
lms/djangoapps/courseware/tests/test_signals.py
Normal file
157
lms/djangoapps/courseware/tests/test_signals.py
Normal file
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user