[LTI Provider] Use LTI Outcome Service to pass back scores
This change allows the LTI provider to pass grades back to the campus LMS platform using the
LTI outcome service. For full details of the outcome service, see:
http://www.imsglobal.org/LTI/v1p1/ltiIMGv1p1.html
In brief, the LTI 1.1 spec defines an outcome service that can be offered by an LTI consumer.
The consumer determines whether a score should be returned (in Canvas, this means that the LTI
tool is used in an assignment, and the launch was performed by a student). If so, it sends
two additional parameters along with the LTI launch:
lis_outcome_service_url: the endpoint for the outcome service on the consumer;
lis_result_sourcedid: a unique identifier for the row in the gradebook (i.e. the tool/student/assignment combination).
The LTI Provider launch view detects the presence of these optional fields, and creates database
records for the specific Outcome Service and for the graded LTI launch. Later, when a score on
edX changes (identified using the signal mechanism from previous LTI Provider pull requests),
a Celery task is launched to pass the score back to the LTI consumer.
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