Merge pull request #8205 from mcgachey/mcgachey-lti-grade-passback
[LTI Provider] Use the LTI Outcome Service to pass back scores
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
The LTI Provider app gives a way to launch edX content via a campus LMS
|
||||
platform. LTI is a standard protocol for connecting educational tools, defined
|
||||
by IMS:
|
||||
http://www.imsglobal.org/toolsinteroperability2.cfm
|
||||
"""
|
||||
|
||||
# Import the tasks module to ensure that signal handlers are registered.
|
||||
import lms.djangoapps.lti_provider.tasks
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'OutcomeService'
|
||||
db.create_table('lti_provider_outcomeservice', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('lis_outcome_service_url', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
|
||||
('lti_consumer', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['lti_provider.LtiConsumer'])),
|
||||
))
|
||||
db.send_create_signal('lti_provider', ['OutcomeService'])
|
||||
|
||||
# Adding model 'GradedAssignment'
|
||||
db.create_table('lti_provider_gradedassignment', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
|
||||
('course_key', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
|
||||
('usage_key', self.gf('xmodule_django.models.UsageKeyField')(max_length=255, db_index=True)),
|
||||
('outcome_service', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['lti_provider.OutcomeService'])),
|
||||
('lis_result_sourcedid', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
))
|
||||
db.send_create_signal('lti_provider', ['GradedAssignment'])
|
||||
|
||||
# Adding unique constraint on 'GradedAssignment', fields ['outcome_service', 'lis_result_sourcedid']
|
||||
db.create_unique('lti_provider_gradedassignment', ['outcome_service_id', 'lis_result_sourcedid'])
|
||||
|
||||
# Adding field 'LtiConsumer.instance_guid'
|
||||
db.add_column('lti_provider_lticonsumer', 'instance_guid',
|
||||
self.gf('django.db.models.fields.CharField')(max_length=255, null=True),
|
||||
keep_default=False)
|
||||
|
||||
# Adding unique constraint on 'LtiConsumer', fields ['consumer_name']
|
||||
db.create_unique('lti_provider_lticonsumer', ['consumer_name'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'LtiConsumer', fields ['consumer_name']
|
||||
db.delete_unique('lti_provider_lticonsumer', ['consumer_name'])
|
||||
|
||||
# Removing unique constraint on 'GradedAssignment', fields ['outcome_service', 'lis_result_sourcedid']
|
||||
db.delete_unique('lti_provider_gradedassignment', ['outcome_service_id', 'lis_result_sourcedid'])
|
||||
|
||||
# Deleting model 'OutcomeService'
|
||||
db.delete_table('lti_provider_outcomeservice')
|
||||
|
||||
# Deleting model 'GradedAssignment'
|
||||
db.delete_table('lti_provider_gradedassignment')
|
||||
|
||||
# Deleting field 'LtiConsumer.instance_guid'
|
||||
db.delete_column('lti_provider_lticonsumer', 'instance_guid')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'lti_provider.gradedassignment': {
|
||||
'Meta': {'unique_together': "(('outcome_service', 'lis_result_sourcedid'),)", 'object_name': 'GradedAssignment'},
|
||||
'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'lis_result_sourcedid': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'outcome_service': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['lti_provider.OutcomeService']"}),
|
||||
'usage_key': ('xmodule_django.models.UsageKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'lti_provider.lticonsumer': {
|
||||
'Meta': {'object_name': 'LtiConsumer'},
|
||||
'consumer_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'consumer_name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
|
||||
'consumer_secret': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'instance_guid': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True'})
|
||||
},
|
||||
'lti_provider.outcomeservice': {
|
||||
'Meta': {'object_name': 'OutcomeService'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'lis_outcome_service_url': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
|
||||
'lti_consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['lti_provider.LtiConsumer']"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['lti_provider']
|
||||
@@ -8,10 +8,13 @@ changes. To do that,
|
||||
1. Go to the edx-platform dir
|
||||
2. ./manage.py lms schemamigration lti_provider --auto "description" --settings=devstack
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
import logging
|
||||
|
||||
from courseware.models import SCORE_CHANGED
|
||||
from xmodule_django.models import CourseKeyField, UsageKeyField
|
||||
|
||||
log = logging.getLogger("edx.lti_provider")
|
||||
|
||||
|
||||
class LtiConsumer(models.Model):
|
||||
@@ -20,30 +23,99 @@ class LtiConsumer(models.Model):
|
||||
specific settings, such as the OAuth key/secret pair and any LTI fields
|
||||
that must be persisted.
|
||||
"""
|
||||
consumer_name = models.CharField(max_length=255)
|
||||
consumer_name = models.CharField(max_length=255, unique=True)
|
||||
consumer_key = models.CharField(max_length=32, unique=True, db_index=True)
|
||||
consumer_secret = models.CharField(max_length=32, unique=True)
|
||||
instance_guid = models.CharField(max_length=255, null=True, unique=True)
|
||||
|
||||
@staticmethod
|
||||
def get_or_supplement(instance_guid, consumer_key):
|
||||
"""
|
||||
The instance_guid is the best way to uniquely identify an LTI consumer.
|
||||
However according to the LTI spec, the instance_guid field is optional
|
||||
and so cannot be relied upon to be present.
|
||||
|
||||
This method first attempts to find an LtiConsumer by instance_guid.
|
||||
Failing that, it tries to find a record with a matching consumer_key.
|
||||
This can be the case if the LtiConsumer record was created as the result
|
||||
of an LTI launch with no instance_guid.
|
||||
|
||||
If the instance_guid is now present, the LtiConsumer model will be
|
||||
supplemented with the instance_guid, to more concretely identify the
|
||||
consumer.
|
||||
|
||||
In practice, nearly all major LTI consumers provide an instance_guid, so
|
||||
the fallback mechanism of matching by consumer key should be rarely
|
||||
required.
|
||||
"""
|
||||
consumer = None
|
||||
if instance_guid:
|
||||
try:
|
||||
consumer = LtiConsumer.objects.get(instance_guid=instance_guid)
|
||||
except LtiConsumer.DoesNotExist:
|
||||
# The consumer may not exist, or its record may not have a guid
|
||||
pass
|
||||
|
||||
# Search by consumer key instead of instance_guid. If there is no
|
||||
# consumer with a matching key, the LTI launch does not have permission
|
||||
# to access the content.
|
||||
if not consumer:
|
||||
consumer = LtiConsumer.objects.get(
|
||||
consumer_key=consumer_key,
|
||||
instance_guid=instance_guid if instance_guid else None
|
||||
)
|
||||
|
||||
# Add the instance_guid field to the model if it's not there already.
|
||||
if instance_guid and not consumer.instance_guid:
|
||||
consumer.instance_guid = instance_guid
|
||||
consumer.save()
|
||||
return consumer
|
||||
|
||||
|
||||
@receiver(SCORE_CHANGED)
|
||||
def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument
|
||||
class OutcomeService(models.Model):
|
||||
"""
|
||||
Consume signals that indicate score changes.
|
||||
Model for a single outcome service associated with an LTI consumer. Note
|
||||
that a given consumer may have more than one outcome service URL over its
|
||||
lifetime, so we need to store the outcome service separately from the
|
||||
LtiConsumer model.
|
||||
|
||||
TODO: This function is a placeholder for integration with the LTI 1.1
|
||||
outcome service, which will follow in a separate change.
|
||||
An outcome service can be identified in two ways, depending on the
|
||||
information provided by an LTI launch. The ideal way to identify the service
|
||||
is by instance_guid, which should uniquely identify a consumer. However that
|
||||
field is optional in the LTI launch, and so if it is missing we can fall
|
||||
back on the consumer key (which should be created uniquely for each consumer
|
||||
although we don't have a technical way to guarantee that).
|
||||
|
||||
Some LTI-specified fields use the prefix lis_; this refers to the IMS
|
||||
Learning Information Services standard from which LTI inherits some
|
||||
properties
|
||||
"""
|
||||
message = """LTI Provider got score change event:
|
||||
points_possible: {}
|
||||
points_earned: {}
|
||||
user_id: {}
|
||||
course_id: {}
|
||||
usage_id: {}
|
||||
lis_outcome_service_url = models.CharField(max_length=255, unique=True)
|
||||
lti_consumer = models.ForeignKey(LtiConsumer)
|
||||
|
||||
|
||||
class GradedAssignment(models.Model):
|
||||
"""
|
||||
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),
|
||||
)
|
||||
Model representing a single launch of a graded assignment by an individual
|
||||
user. There will be a row created here only if the LTI consumer may require
|
||||
a result to be returned from the LTI launch (determined by the presence of
|
||||
the lis_result_sourcedid parameter in the launch POST). There will be only
|
||||
one row created for a given usage/consumer combination; repeated launches of
|
||||
the same content by the same user from the same LTI consumer will not add
|
||||
new rows to the table.
|
||||
|
||||
Some LTI-specified fields use the prefix lis_; this refers to the IMS
|
||||
Learning Information Services standard from which LTI inherits some
|
||||
properties
|
||||
"""
|
||||
user = models.ForeignKey(User, db_index=True)
|
||||
course_key = CourseKeyField(max_length=255, db_index=True)
|
||||
usage_key = UsageKeyField(max_length=255, db_index=True)
|
||||
outcome_service = models.ForeignKey(OutcomeService)
|
||||
lis_result_sourcedid = models.CharField(max_length=255, db_index=True)
|
||||
|
||||
class Meta(object):
|
||||
"""
|
||||
Uniqueness constraints.
|
||||
"""
|
||||
unique_together = ('outcome_service', 'lis_result_sourcedid')
|
||||
|
||||
166
lms/djangoapps/lti_provider/outcomes.py
Normal file
166
lms/djangoapps/lti_provider/outcomes.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Helper functions for managing interactions with the LTI outcomes service defined
|
||||
in LTI v1.1.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from lxml import etree
|
||||
from lxml.builder import ElementMaker
|
||||
import requests
|
||||
import requests_oauthlib
|
||||
import uuid
|
||||
|
||||
from lti_provider.models import GradedAssignment, OutcomeService
|
||||
|
||||
log = logging.getLogger("edx.lti_provider")
|
||||
|
||||
|
||||
def store_outcome_parameters(request_params, user, lti_consumer):
|
||||
"""
|
||||
Determine whether a set of LTI launch parameters contains information about
|
||||
an expected score, and if so create a GradedAssignment record. Create a new
|
||||
OutcomeService record if none exists for the tool consumer, and update any
|
||||
incomplete record with additional data if it is available.
|
||||
"""
|
||||
result_id = request_params.get('lis_result_sourcedid', None)
|
||||
|
||||
# We're only interested in requests that include a lis_result_sourcedid
|
||||
# parameter. An LTI consumer that does not send that parameter does not
|
||||
# expect scoring updates for that particular request.
|
||||
if result_id:
|
||||
result_service = request_params.get('lis_outcome_service_url', None)
|
||||
if not result_service:
|
||||
# TODO: There may be a way to recover from this error; if we know
|
||||
# the LTI consumer that the request comes from then we may be able
|
||||
# to figure out the result service URL. As it stands, though, this
|
||||
# is a badly-formed LTI request
|
||||
log.warn(
|
||||
"Outcome Service: lis_outcome_service_url parameter missing "
|
||||
"from scored assignment; we will be unable to return a score. "
|
||||
"Request parameters: %s",
|
||||
request_params
|
||||
)
|
||||
return
|
||||
|
||||
# Both usage and course ID parameters are supplied in the LTI launch URL
|
||||
usage_key = request_params['usage_key']
|
||||
course_key = request_params['course_key']
|
||||
|
||||
# Create a record of the outcome service if necessary
|
||||
outcomes, __ = OutcomeService.objects.get_or_create(
|
||||
lis_outcome_service_url=result_service,
|
||||
lti_consumer=lti_consumer
|
||||
)
|
||||
|
||||
GradedAssignment.objects.get_or_create(
|
||||
lis_result_sourcedid=result_id,
|
||||
course_key=course_key,
|
||||
usage_key=usage_key,
|
||||
user=user,
|
||||
outcome_service=outcomes
|
||||
)
|
||||
|
||||
|
||||
def generate_replace_result_xml(result_sourcedid, score):
|
||||
"""
|
||||
Create the XML document that contains the new score to be sent to the LTI
|
||||
consumer. The format of this message is defined in the LTI 1.1 spec.
|
||||
"""
|
||||
# Pylint doesn't recognize members in the LXML module
|
||||
# pylint: disable=no-member
|
||||
elem = ElementMaker(nsmap={None: 'http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0'})
|
||||
xml = elem.imsx_POXEnvelopeRequest(
|
||||
elem.imsx_POXHeader(
|
||||
elem.imsx_POXRequestHeaderInfo(
|
||||
elem.imsx_version('V1.0'),
|
||||
elem.imsx_messageIdentifier(str(uuid.uuid4()))
|
||||
)
|
||||
),
|
||||
elem.imsx_POXBody(
|
||||
elem.replaceResultRequest(
|
||||
elem.resultRecord(
|
||||
elem.sourcedGUID(
|
||||
elem.sourcedId(result_sourcedid)
|
||||
),
|
||||
elem.result(
|
||||
elem.resultScore(
|
||||
elem.language('en'),
|
||||
elem.textString(str(score))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return etree.tostring(xml, xml_declaration=True, encoding='UTF-8')
|
||||
|
||||
|
||||
def sign_and_send_replace_result(assignment, xml):
|
||||
"""
|
||||
Take the XML document generated in generate_replace_result_xml, and sign it
|
||||
with the consumer key and secret assigned to the consumer. Send the signed
|
||||
message to the LTI consumer.
|
||||
"""
|
||||
outcome_service = assignment.outcome_service
|
||||
consumer = outcome_service.lti_consumer
|
||||
consumer_key = consumer.consumer_key
|
||||
consumer_secret = consumer.consumer_secret
|
||||
|
||||
# Calculate the OAuth signature for the replace_result message.
|
||||
# TODO: According to the LTI spec, there should be an additional
|
||||
# oauth_body_hash field that contains a digest of the replace_result
|
||||
# message. Testing with Canvas throws an error when this field is included.
|
||||
# This code may need to be revisited once we test with other LMS platforms,
|
||||
# and confirm whether there's a bug in Canvas.
|
||||
oauth = requests_oauthlib.OAuth1(consumer_key, consumer_secret)
|
||||
|
||||
headers = {'content-type': 'application/xml'}
|
||||
response = requests.post(
|
||||
assignment.outcome_service.lis_outcome_service_url,
|
||||
data=xml,
|
||||
auth=oauth,
|
||||
headers=headers
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def check_replace_result_response(response):
|
||||
"""
|
||||
Parse the response sent by the LTI consumer after an score update message
|
||||
has been processed. Return True if the message was properly received, or
|
||||
False if not. The format of this message is defined in the LTI 1.1 spec.
|
||||
"""
|
||||
# Pylint doesn't recognize members in the LXML module
|
||||
# pylint: disable=no-member
|
||||
if response.status_code != 200:
|
||||
log.error(
|
||||
"Outcome service response: Unexpected status code %s",
|
||||
response.status_code
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
xml = response.content
|
||||
root = etree.fromstring(xml)
|
||||
except etree.ParseError as ex:
|
||||
log.error("Outcome service response: Failed to parse XML: %s\n %s", ex, xml)
|
||||
return False
|
||||
|
||||
major_codes = root.xpath(
|
||||
'//ns:imsx_codeMajor',
|
||||
namespaces={'ns': 'http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0'})
|
||||
if len(major_codes) != 1:
|
||||
log.error(
|
||||
"Outcome service response: Expected exactly one imsx_codeMajor field in response. Received %s",
|
||||
major_codes
|
||||
)
|
||||
return False
|
||||
|
||||
if major_codes[0].text != 'success':
|
||||
log.error(
|
||||
"Outcome service response: Unexpected major code: %s.",
|
||||
major_codes[0].text
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -2,8 +2,6 @@
|
||||
Subclass of oauthlib's RequestValidator that checks an OAuth signature.
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from oauthlib.oauth1 import SignatureOnlyEndpoint
|
||||
from oauthlib.oauth1 import RequestValidator
|
||||
|
||||
@@ -91,7 +89,7 @@ class SignatureValidator(RequestValidator):
|
||||
"""
|
||||
try:
|
||||
return LtiConsumer.objects.get(consumer_key=client_key).consumer_secret
|
||||
except ObjectDoesNotExist:
|
||||
except LtiConsumer.DoesNotExist:
|
||||
return None
|
||||
|
||||
def verify(self, request):
|
||||
|
||||
98
lms/djangoapps/lti_provider/tasks.py
Normal file
98
lms/djangoapps/lti_provider/tasks.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Asynchronous tasks for the LTI provider app.
|
||||
"""
|
||||
|
||||
from django.dispatch import receiver
|
||||
import logging
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from courseware.models import SCORE_CHANGED
|
||||
from lms import CELERY_APP
|
||||
from lti_provider.models import GradedAssignment
|
||||
import lti_provider.outcomes
|
||||
from lti_provider.views import parse_course_and_usage_keys
|
||||
|
||||
log = logging.getLogger("edx.lti_provider")
|
||||
|
||||
|
||||
@receiver(SCORE_CHANGED)
|
||||
def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Consume signals that indicate score changes. See the definition of
|
||||
courseware.models.SCORE_CHANGED for a description of the signal.
|
||||
"""
|
||||
points_possible = kwargs.get('points_possible', None)
|
||||
points_earned = kwargs.get('points_earned', None)
|
||||
user_id = kwargs.get('user_id', None)
|
||||
course_id = kwargs.get('course_id', None)
|
||||
usage_id = kwargs.get('usage_id', None)
|
||||
|
||||
if None not in (points_earned, points_possible, user_id, course_id, user_id):
|
||||
send_outcome.delay(
|
||||
points_possible,
|
||||
points_earned,
|
||||
user_id,
|
||||
course_id,
|
||||
usage_id
|
||||
)
|
||||
else:
|
||||
log.error(
|
||||
"Outcome Service: Required signal parameter is None. "
|
||||
"points_possible: %s, points_earned: %s, user_id: %s, "
|
||||
"course_id: %s, usage_id: %s",
|
||||
points_possible, points_earned, user_id, course_id, usage_id
|
||||
)
|
||||
|
||||
|
||||
@CELERY_APP.task
|
||||
def send_outcome(points_possible, points_earned, user_id, course_id, usage_id):
|
||||
"""
|
||||
Calculate the score for a given user in a problem and send it to the
|
||||
appropriate LTI consumer's outcome service.
|
||||
"""
|
||||
course_key, usage_key = parse_course_and_usage_keys(course_id, usage_id)
|
||||
assignments = GradedAssignment.objects.filter(
|
||||
user=user_id, course_key=course_key, usage_key=usage_key
|
||||
)
|
||||
|
||||
# Calculate the user's score, on a scale of 0.0 - 1.0.
|
||||
score = float(points_earned) / float(points_possible)
|
||||
|
||||
# There may be zero or more assignment records. We would expect for there
|
||||
# to be zero if the user/course/usage combination does not relate to a
|
||||
# previous graded LTI launch. This can happen if an LTI consumer embeds some
|
||||
# gradable content in a context that doesn't require a score (maybe by
|
||||
# including an exercise as a sample that students may complete but don't
|
||||
# count towards their grade).
|
||||
# There could be more than one GradedAssignment record if the same content
|
||||
# is embedded more than once in a single course. This would be a strange
|
||||
# course design on the consumer's part, but we handle it by sending update
|
||||
# messages for all launches of the content.
|
||||
for assignment in assignments:
|
||||
xml = lti_provider.outcomes.generate_replace_result_xml(
|
||||
assignment.lis_result_sourcedid, score
|
||||
)
|
||||
try:
|
||||
response = lti_provider.outcomes.sign_and_send_replace_result(assignment, xml)
|
||||
except RequestException:
|
||||
# failed to send result. 'response' is None, so more detail will be
|
||||
# logged at the end of the method.
|
||||
response = None
|
||||
log.exception("Outcome Service: Error when sending result.")
|
||||
|
||||
# If something went wrong, make sure that we have a complete log record.
|
||||
# That way we can manually fix things up on the campus system later if
|
||||
# necessary.
|
||||
if not (response and lti_provider.outcomes.check_replace_result_response(response)):
|
||||
log.error(
|
||||
"Outcome Service: Failed to update score on LTI consumer. "
|
||||
"User: %s, course: %s, usage: %s, score: %s, possible: %s "
|
||||
"status: %s, body: %s",
|
||||
user_id,
|
||||
course_key,
|
||||
usage_key,
|
||||
points_earned,
|
||||
points_possible,
|
||||
response,
|
||||
response.text if response else 'Unknown'
|
||||
)
|
||||
368
lms/djangoapps/lti_provider/tests/test_outcomes.py
Normal file
368
lms/djangoapps/lti_provider/tests/test_outcomes.py
Normal file
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Tests for the LTI outcome service handlers, both in outcomes.py and in tasks.py
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from lxml import etree
|
||||
from mock import patch, MagicMock, ANY
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
from lti_provider.models import GradedAssignment, LtiConsumer, OutcomeService
|
||||
import lti_provider.outcomes as outcomes
|
||||
import lti_provider.tasks as tasks
|
||||
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
|
||||
|
||||
|
||||
class StoreOutcomeParametersTest(TestCase):
|
||||
"""
|
||||
Tests for the store_outcome_parameters method in outcomes.py
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(StoreOutcomeParametersTest, self).setUp()
|
||||
self.user = UserFactory.create()
|
||||
self.course_key = CourseLocator(
|
||||
org='some_org',
|
||||
course='some_course',
|
||||
run='some_run'
|
||||
)
|
||||
self.usage_key = BlockUsageLocator(
|
||||
course_key=self.course_key,
|
||||
block_type='problem',
|
||||
block_id='block_id'
|
||||
)
|
||||
self.consumer = LtiConsumer(
|
||||
consumer_name='consumer',
|
||||
consumer_key='consumer_key',
|
||||
consumer_secret='secret'
|
||||
)
|
||||
self.consumer.save()
|
||||
|
||||
def get_valid_request_params(self):
|
||||
"""
|
||||
Returns a dictionary containing a complete set of required LTI
|
||||
parameters.
|
||||
"""
|
||||
return {
|
||||
'lis_result_sourcedid': 'sourcedid',
|
||||
'lis_outcome_service_url': 'http://example.com/service_url',
|
||||
'oauth_consumer_key': 'consumer_key',
|
||||
'tool_consumer_instance_guid': 'tool_instance_guid',
|
||||
'usage_key': self.usage_key,
|
||||
'course_key': self.course_key,
|
||||
}
|
||||
|
||||
def test_graded_assignment_created(self):
|
||||
params = self.get_valid_request_params()
|
||||
with self.assertNumQueries(4):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
assignment = GradedAssignment.objects.get(
|
||||
lis_result_sourcedid=params['lis_result_sourcedid']
|
||||
)
|
||||
self.assertEqual(assignment.course_key, self.course_key)
|
||||
self.assertEqual(assignment.usage_key, self.usage_key)
|
||||
self.assertEqual(assignment.user, self.user)
|
||||
|
||||
def test_outcome_service_created(self):
|
||||
params = self.get_valid_request_params()
|
||||
with self.assertNumQueries(4):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
outcome = OutcomeService.objects.get(
|
||||
lti_consumer=self.consumer
|
||||
)
|
||||
self.assertEqual(outcome.lti_consumer, self.consumer)
|
||||
|
||||
def test_graded_assignment_references_outcome_service(self):
|
||||
params = self.get_valid_request_params()
|
||||
with self.assertNumQueries(4):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
outcome = OutcomeService.objects.get(
|
||||
lti_consumer=self.consumer
|
||||
)
|
||||
assignment = GradedAssignment.objects.get(
|
||||
lis_result_sourcedid=params['lis_result_sourcedid']
|
||||
)
|
||||
self.assertEqual(assignment.outcome_service, outcome)
|
||||
|
||||
def test_no_duplicate_graded_assignments(self):
|
||||
params = self.get_valid_request_params()
|
||||
with self.assertNumQueries(4):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
with self.assertNumQueries(2):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
assignments = GradedAssignment.objects.filter(
|
||||
lis_result_sourcedid=params['lis_result_sourcedid']
|
||||
)
|
||||
self.assertEqual(len(assignments), 1)
|
||||
|
||||
def test_no_duplicate_outcome_services(self):
|
||||
params = self.get_valid_request_params()
|
||||
with self.assertNumQueries(4):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
with self.assertNumQueries(2):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
outcome_services = OutcomeService.objects.filter(
|
||||
lti_consumer=self.consumer
|
||||
)
|
||||
self.assertEqual(len(outcome_services), 1)
|
||||
|
||||
def test_no_db_update_for_ungraded_assignment(self):
|
||||
params = self.get_valid_request_params()
|
||||
del params['lis_result_sourcedid']
|
||||
with self.assertNumQueries(0):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
|
||||
def test_no_db_update_for_bad_request(self):
|
||||
params = self.get_valid_request_params()
|
||||
del params['lis_outcome_service_url']
|
||||
with self.assertNumQueries(0):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
|
||||
def test_db_record_created_without_consumer_id(self):
|
||||
params = self.get_valid_request_params()
|
||||
del params['tool_consumer_instance_guid']
|
||||
with self.assertNumQueries(4):
|
||||
outcomes.store_outcome_parameters(params, self.user, self.consumer)
|
||||
self.assertEqual(GradedAssignment.objects.count(), 1)
|
||||
self.assertEqual(OutcomeService.objects.count(), 1)
|
||||
|
||||
|
||||
class SignAndSendReplaceResultTest(TestCase):
|
||||
"""
|
||||
Tests for the sign_and_send_replace_result method in outcomes.py
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(SignAndSendReplaceResultTest, self).setUp()
|
||||
self.course_key = CourseLocator(
|
||||
org='some_org',
|
||||
course='some_course',
|
||||
run='some_run'
|
||||
)
|
||||
self.usage_key = BlockUsageLocator(
|
||||
course_key=self.course_key,
|
||||
block_type='problem',
|
||||
block_id='block_id'
|
||||
)
|
||||
self.user = UserFactory.create()
|
||||
consumer = LtiConsumer(
|
||||
consumer_name='consumer',
|
||||
consumer_key='consumer_key',
|
||||
consumer_secret='secret'
|
||||
)
|
||||
consumer.save()
|
||||
outcome = OutcomeService(
|
||||
lis_outcome_service_url='http://example.com/service_url',
|
||||
lti_consumer=consumer,
|
||||
)
|
||||
outcome.save()
|
||||
self.assignment = GradedAssignment(
|
||||
user=self.user,
|
||||
course_key=self.course_key,
|
||||
usage_key=self.usage_key,
|
||||
outcome_service=outcome,
|
||||
lis_result_sourcedid='sourcedid',
|
||||
)
|
||||
self.assignment.save()
|
||||
|
||||
@patch('requests.post', return_value='response')
|
||||
def test_sign_and_send_replace_result(self, post_mock):
|
||||
response = outcomes.sign_and_send_replace_result(self.assignment, 'xml')
|
||||
post_mock.assert_called_with(
|
||||
'http://example.com/service_url',
|
||||
data='xml',
|
||||
auth=ANY,
|
||||
headers={'content-type': 'application/xml'}
|
||||
)
|
||||
self.assertEqual(response, 'response')
|
||||
|
||||
|
||||
class SendOutcomeTest(TestCase):
|
||||
"""
|
||||
Tests for the send_outcome method in tasks.py
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(SendOutcomeTest, self).setUp()
|
||||
self.course_key = CourseLocator(
|
||||
org='some_org',
|
||||
course='some_course',
|
||||
run='some_run'
|
||||
)
|
||||
self.usage_key = BlockUsageLocator(
|
||||
course_key=self.course_key,
|
||||
block_type='problem',
|
||||
block_id='block_id'
|
||||
)
|
||||
self.user = UserFactory.create()
|
||||
self.points_possible = 10
|
||||
self.points_earned = 3
|
||||
self.generate_xml_mock = self.setup_patch(
|
||||
'lti_provider.outcomes.generate_replace_result_xml',
|
||||
'replace result XML'
|
||||
)
|
||||
self.replace_result_mock = self.setup_patch(
|
||||
'lti_provider.outcomes.sign_and_send_replace_result',
|
||||
'replace result response'
|
||||
)
|
||||
self.check_result_mock = self.setup_patch(
|
||||
'lti_provider.outcomes.check_replace_result_response',
|
||||
True
|
||||
)
|
||||
consumer = LtiConsumer(
|
||||
consumer_name='Lti Consumer Name',
|
||||
consumer_key='consumer_key',
|
||||
consumer_secret='consumer_secret',
|
||||
instance_guid='tool_instance_guid'
|
||||
)
|
||||
consumer.save()
|
||||
outcome = OutcomeService(
|
||||
lis_outcome_service_url='http://example.com/service_url',
|
||||
lti_consumer=consumer
|
||||
)
|
||||
outcome.save()
|
||||
self.assignment = GradedAssignment(
|
||||
user=self.user,
|
||||
course_key=self.course_key,
|
||||
usage_key=self.usage_key,
|
||||
outcome_service=outcome,
|
||||
lis_result_sourcedid='sourcedid',
|
||||
)
|
||||
self.assignment.save()
|
||||
|
||||
def setup_patch(self, function_name, return_value):
|
||||
"""
|
||||
Patch a method 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 test_send_outcome(self):
|
||||
tasks.send_outcome(
|
||||
self.points_possible,
|
||||
self.points_earned,
|
||||
self.user.id,
|
||||
unicode(self.course_key),
|
||||
unicode(self.usage_key)
|
||||
)
|
||||
self.generate_xml_mock.assert_called_once_with('sourcedid', 0.3)
|
||||
self.replace_result_mock.assert_called_once_with(self.assignment, 'replace result XML')
|
||||
|
||||
|
||||
class XmlHandlingTest(TestCase):
|
||||
"""
|
||||
Tests for the generate_replace_result_xml and check_replace_result_response
|
||||
methods in outcomes.py
|
||||
"""
|
||||
|
||||
response_xml = """
|
||||
<imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
|
||||
<imsx_POXHeader>
|
||||
<imsx_POXResponseHeaderInfo>
|
||||
<imsx_version>V1.0</imsx_version>
|
||||
<imsx_messageIdentifier>4560</imsx_messageIdentifier>
|
||||
<imsx_statusInfo>
|
||||
{major_code}
|
||||
<imsx_severity>status</imsx_severity>
|
||||
<imsx_description>Score for result_id is now 0.25</imsx_description>
|
||||
<imsx_messageRefIdentifier>999999123</imsx_messageRefIdentifier>
|
||||
<imsx_operationRefIdentifier>replaceResult</imsx_operationRefIdentifier>
|
||||
</imsx_statusInfo>
|
||||
</imsx_POXResponseHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>
|
||||
<replaceResultResponse/>
|
||||
</imsx_POXBody>
|
||||
</imsx_POXEnvelopeResponse>
|
||||
"""
|
||||
|
||||
result_id = 'result_id'
|
||||
score = 0.25
|
||||
|
||||
@patch('uuid.uuid4', return_value='random_uuid')
|
||||
def test_replace_result_message_uuid(self, _uuid_mock):
|
||||
# Pylint doesn't recognize members in the LXML module
|
||||
# pylint: disable=no-member
|
||||
xml = outcomes.generate_replace_result_xml(self.result_id, self.score)
|
||||
tree = etree.fromstring(xml)
|
||||
message_id = tree.xpath(
|
||||
'//ns:imsx_messageIdentifier',
|
||||
namespaces={'ns': 'http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0'}
|
||||
)
|
||||
self.assertEqual(len(message_id), 1)
|
||||
self.assertEqual(message_id[0].text, 'random_uuid')
|
||||
|
||||
def test_replace_result_sourced_id(self):
|
||||
# pylint: disable=no-member
|
||||
xml = outcomes.generate_replace_result_xml(self.result_id, self.score)
|
||||
tree = etree.fromstring(xml)
|
||||
sourced_id = tree.xpath(
|
||||
'/ns:imsx_POXEnvelopeRequest/ns:imsx_POXBody/ns:replaceResultRequest/'
|
||||
'ns:resultRecord/ns:sourcedGUID/ns:sourcedId',
|
||||
namespaces={'ns': 'http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0'}
|
||||
)
|
||||
self.assertEqual(len(sourced_id), 1)
|
||||
self.assertEqual(sourced_id[0].text, 'result_id')
|
||||
|
||||
def test_replace_result_score(self):
|
||||
# pylint: disable=no-member
|
||||
xml = outcomes.generate_replace_result_xml(self.result_id, self.score)
|
||||
tree = etree.fromstring(xml)
|
||||
xml_score = tree.xpath(
|
||||
'/ns:imsx_POXEnvelopeRequest/ns:imsx_POXBody/ns:replaceResultRequest/'
|
||||
'ns:resultRecord/ns:result/ns:resultScore/ns:textString',
|
||||
namespaces={'ns': 'http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0'}
|
||||
)
|
||||
self.assertEqual(len(xml_score), 1)
|
||||
self.assertEqual(xml_score[0].text, '0.25')
|
||||
|
||||
def create_response_object(
|
||||
self, status, xml,
|
||||
major_code='<imsx_codeMajor>success</imsx_codeMajor>'
|
||||
):
|
||||
"""
|
||||
Returns an XML document containing a successful replace_result response.
|
||||
"""
|
||||
response = MagicMock()
|
||||
response.status_code = status
|
||||
response.content = xml.format(major_code=major_code).encode('ascii', 'ignore')
|
||||
return response
|
||||
|
||||
def test_response_with_correct_xml(self):
|
||||
xml = self.response_xml
|
||||
response = self.create_response_object(200, xml)
|
||||
self.assertTrue(outcomes.check_replace_result_response(response))
|
||||
|
||||
def test_response_with_bad_status_code(self):
|
||||
response = self.create_response_object(500, '')
|
||||
self.assertFalse(outcomes.check_replace_result_response(response))
|
||||
|
||||
def test_response_with_invalid_xml(self):
|
||||
xml = '<badly>formatted</xml>'
|
||||
response = self.create_response_object(200, xml)
|
||||
self.assertFalse(outcomes.check_replace_result_response(response))
|
||||
|
||||
def test_response_with_multiple_status_fields(self):
|
||||
response = self.create_response_object(
|
||||
200, self.response_xml,
|
||||
major_code='<imsx_codeMajor>success</imsx_codeMajor>'
|
||||
'<imsx_codeMajor>failure</imsx_codeMajor>'
|
||||
)
|
||||
self.assertFalse(outcomes.check_replace_result_response(response))
|
||||
|
||||
def test_response_with_no_status_field(self):
|
||||
response = self.create_response_object(
|
||||
200, self.response_xml,
|
||||
major_code=''
|
||||
)
|
||||
self.assertFalse(outcomes.check_replace_result_response(response))
|
||||
|
||||
def test_response_with_failing_status_field(self):
|
||||
response = self.create_response_object(
|
||||
200, self.response_xml,
|
||||
major_code='<imsx_codeMajor>failure</imsx_codeMajor>'
|
||||
)
|
||||
self.assertFalse(outcomes.check_replace_result_response(response))
|
||||
@@ -6,9 +6,9 @@ from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from mock import patch, MagicMock
|
||||
|
||||
from lti_provider import views
|
||||
from lti_provider import views, models
|
||||
from lti_provider.signature_validator import SignatureValidator
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@@ -23,8 +23,15 @@ LTI_DEFAULT_PARAMS = {
|
||||
'oauth_nonce': u'OAuth Nonce',
|
||||
}
|
||||
|
||||
COURSE_KEY = CourseKey.from_string('some/course/id')
|
||||
USAGE_KEY = UsageKey.from_string('i4x://some/course/problem/uuid').map_into_course(COURSE_KEY)
|
||||
LTI_OPTIONAL_PARAMS = {
|
||||
'lis_result_sourcedid': u'result sourcedid',
|
||||
'lis_outcome_service_url': u'outcome service URL',
|
||||
'tool_consumer_instance_guid': u'consumer instance guid'
|
||||
}
|
||||
|
||||
COURSE_KEY = CourseLocator(org='some_org', course='some_course', run='some_run')
|
||||
USAGE_KEY = BlockUsageLocator(course_key=COURSE_KEY, block_type='problem', block_id='block_id')
|
||||
|
||||
COURSE_PARAMS = {
|
||||
'course_key': COURSE_KEY,
|
||||
'usage_key': USAGE_KEY
|
||||
@@ -67,6 +74,12 @@ class LtiLaunchTest(TestCase):
|
||||
super(LtiLaunchTest, self).setUp()
|
||||
# Always accept the OAuth signature
|
||||
SignatureValidator.verify = MagicMock(return_value=True)
|
||||
self.consumer = models.LtiConsumer(
|
||||
consumer_name='consumer',
|
||||
consumer_key=LTI_DEFAULT_PARAMS['oauth_consumer_key'],
|
||||
consumer_secret='secret'
|
||||
)
|
||||
self.consumer.save()
|
||||
|
||||
@patch('lti_provider.views.render_courseware')
|
||||
def test_valid_launch(self, render):
|
||||
@@ -77,6 +90,20 @@ class LtiLaunchTest(TestCase):
|
||||
views.lti_launch(request, unicode(COURSE_KEY), unicode(USAGE_KEY))
|
||||
render.assert_called_with(request, ALL_PARAMS)
|
||||
|
||||
@patch('lti_provider.views.render_courseware')
|
||||
@patch('lti_provider.views.store_outcome_parameters')
|
||||
def test_outcome_service_registered(self, store_params, _render):
|
||||
"""
|
||||
Verifies that the LTI launch succeeds when passed a valid request.
|
||||
"""
|
||||
request = build_launch_request()
|
||||
views.lti_launch(
|
||||
request,
|
||||
unicode(COURSE_PARAMS['course_key']),
|
||||
unicode(COURSE_PARAMS['usage_key'])
|
||||
)
|
||||
store_params.assert_called_with(ALL_PARAMS, request.user, self.consumer)
|
||||
|
||||
def launch_with_missing_parameter(self, missing_param):
|
||||
"""
|
||||
Helper method to remove a parameter from the LTI launch and call the view
|
||||
@@ -121,6 +148,33 @@ class LtiLaunchTest(TestCase):
|
||||
for key in views.REQUIRED_PARAMETERS:
|
||||
self.assertEqual(session[key], request.POST[key], key + ' not set in the session')
|
||||
|
||||
@patch('lti_provider.views.lti_run')
|
||||
def test_optional_parameters_in_session(self, _run):
|
||||
"""
|
||||
Verifies that the outcome-related optional LTI parameters are properly
|
||||
stored in the session
|
||||
"""
|
||||
request = build_launch_request()
|
||||
request.POST.update(LTI_OPTIONAL_PARAMS)
|
||||
views.lti_launch(
|
||||
request,
|
||||
unicode(COURSE_PARAMS['course_key']),
|
||||
unicode(COURSE_PARAMS['usage_key'])
|
||||
)
|
||||
session = request.session[views.LTI_SESSION_KEY]
|
||||
self.assertEqual(
|
||||
session['lis_result_sourcedid'], u'result sourcedid',
|
||||
'Result sourcedid not set in the session'
|
||||
)
|
||||
self.assertEqual(
|
||||
session['lis_outcome_service_url'], u'outcome service URL',
|
||||
'Outcome service URL not set in the session'
|
||||
)
|
||||
self.assertEqual(
|
||||
session['tool_consumer_instance_guid'], u'consumer instance guid',
|
||||
'Consumer instance GUID not set in the session'
|
||||
)
|
||||
|
||||
def test_redirect_for_non_authenticated_user(self):
|
||||
"""
|
||||
Verifies that if the lti_launch view is called by an unauthenticated
|
||||
@@ -149,6 +203,15 @@ class LtiRunTest(TestCase):
|
||||
Tests for the lti_run view
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(LtiRunTest, self).setUp()
|
||||
consumer = models.LtiConsumer(
|
||||
consumer_name='consumer',
|
||||
consumer_key=LTI_DEFAULT_PARAMS['oauth_consumer_key'],
|
||||
consumer_secret='secret'
|
||||
)
|
||||
consumer.save()
|
||||
|
||||
@patch('lti_provider.views.render_courseware')
|
||||
def test_valid_launch(self, render):
|
||||
"""
|
||||
|
||||
@@ -14,6 +14,8 @@ from courseware.access import has_access
|
||||
from courseware.courses import get_course_with_access
|
||||
from courseware.module_render import get_module_by_usage_id
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from lti_provider.outcomes import store_outcome_parameters
|
||||
from lti_provider.models import LtiConsumer
|
||||
from lti_provider.signature_validator import SignatureValidator
|
||||
from lms_xblock.runtime import unquote_slashes
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
@@ -29,6 +31,11 @@ REQUIRED_PARAMETERS = [
|
||||
'oauth_nonce'
|
||||
]
|
||||
|
||||
OPTIONAL_PARAMETERS = [
|
||||
'lis_result_sourcedid', 'lis_outcome_service_url',
|
||||
'tool_consumer_instance_guid'
|
||||
]
|
||||
|
||||
LTI_SESSION_KEY = 'lti_provider_parameters'
|
||||
|
||||
|
||||
@@ -61,12 +68,17 @@ def lti_launch(request, course_id, usage_id):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
# Check the OAuth signature on the message
|
||||
if not SignatureValidator().verify(request):
|
||||
try:
|
||||
if not SignatureValidator().verify(request):
|
||||
return HttpResponseForbidden()
|
||||
except LtiConsumer.DoesNotExist:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
params = get_required_parameters(request.POST)
|
||||
if not params:
|
||||
return HttpResponseBadRequest()
|
||||
params.update(get_optional_parameters(request.POST))
|
||||
|
||||
# Store the course, and usage ID in the session to prevent privilege
|
||||
# escalation if a staff member in one course tries to access material in
|
||||
# another.
|
||||
@@ -118,6 +130,15 @@ def lti_run(request):
|
||||
# Remove the parameters from the session to prevent replay
|
||||
del request.session[LTI_SESSION_KEY]
|
||||
|
||||
# Store any parameters required by the outcome service in order to report
|
||||
# scores back later. We know that the consumer exists, since the record was
|
||||
# used earlier to verify the oauth signature.
|
||||
lti_consumer = LtiConsumer.get_or_supplement(
|
||||
params.get('tool_consumer_instance_guid', None),
|
||||
params['oauth_consumer_key']
|
||||
)
|
||||
store_outcome_parameters(params, request.user, lti_consumer)
|
||||
|
||||
return render_courseware(request, params)
|
||||
|
||||
|
||||
@@ -143,6 +164,19 @@ def get_required_parameters(dictionary, additional_params=None):
|
||||
return params
|
||||
|
||||
|
||||
def get_optional_parameters(dictionary):
|
||||
"""
|
||||
Extract all optional LTI parameters from a dictionary. This method does not
|
||||
fail if any parameters are missing.
|
||||
|
||||
:param dictionary: A dictionary containing zero or more optional parameters.
|
||||
:return: A new dictionary containing all optional parameters from the
|
||||
original dictionary, or an empty dictionary if no optional parameters
|
||||
were present.
|
||||
"""
|
||||
return {key: dictionary[key] for key in OPTIONAL_PARAMETERS if key in dictionary}
|
||||
|
||||
|
||||
def restore_params_from_session(request):
|
||||
"""
|
||||
Fetch the parameters that were stored in the session by an LTI launch, and
|
||||
@@ -157,7 +191,10 @@ def restore_params_from_session(request):
|
||||
return None
|
||||
session_params = request.session[LTI_SESSION_KEY]
|
||||
additional_params = ['course_key', 'usage_key']
|
||||
return get_required_parameters(session_params, additional_params)
|
||||
for key in REQUIRED_PARAMETERS + additional_params:
|
||||
if key not in session_params:
|
||||
return None
|
||||
return session_params
|
||||
|
||||
|
||||
def render_courseware(request, lti_params):
|
||||
|
||||
Reference in New Issue
Block a user