Commit 73b854e4 by Phil McGachey

[LTI Provider] Adding signals for scoring events

parent 38b61fd8
......@@ -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):
......
"""
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),
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment