diff --git a/lms/djangoapps/lti_provider/__init__.py b/lms/djangoapps/lti_provider/__init__.py index e69de29bb2..c95c036a80 100644 --- a/lms/djangoapps/lti_provider/__init__.py +++ b/lms/djangoapps/lti_provider/__init__.py @@ -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 diff --git a/lms/djangoapps/lti_provider/migrations/0002_create_lti_outcome_management.py b/lms/djangoapps/lti_provider/migrations/0002_create_lti_outcome_management.py new file mode 100644 index 0000000000..7728d2dd6e --- /dev/null +++ b/lms/djangoapps/lti_provider/migrations/0002_create_lti_outcome_management.py @@ -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'] diff --git a/lms/djangoapps/lti_provider/models.py b/lms/djangoapps/lti_provider/models.py index 50eec8f254..42cb2d4e76 100644 --- a/lms/djangoapps/lti_provider/models.py +++ b/lms/djangoapps/lti_provider/models.py @@ -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') diff --git a/lms/djangoapps/lti_provider/outcomes.py b/lms/djangoapps/lti_provider/outcomes.py new file mode 100644 index 0000000000..bcfd37a883 --- /dev/null +++ b/lms/djangoapps/lti_provider/outcomes.py @@ -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 diff --git a/lms/djangoapps/lti_provider/signature_validator.py b/lms/djangoapps/lti_provider/signature_validator.py index 59207ea9e0..de9f497946 100644 --- a/lms/djangoapps/lti_provider/signature_validator.py +++ b/lms/djangoapps/lti_provider/signature_validator.py @@ -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): diff --git a/lms/djangoapps/lti_provider/tasks.py b/lms/djangoapps/lti_provider/tasks.py new file mode 100644 index 0000000000..cf11489f5d --- /dev/null +++ b/lms/djangoapps/lti_provider/tasks.py @@ -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' + ) diff --git a/lms/djangoapps/lti_provider/tests/test_outcomes.py b/lms/djangoapps/lti_provider/tests/test_outcomes.py new file mode 100644 index 0000000000..3e645885c9 --- /dev/null +++ b/lms/djangoapps/lti_provider/tests/test_outcomes.py @@ -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 = """ + + + + V1.0 + 4560 + + {major_code} + status + Score for result_id is now 0.25 + 999999123 + replaceResult + + + + + + + + """ + + 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='success' + ): + """ + 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 = 'formatted' + 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='success' + 'failure' + ) + 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='failure' + ) + self.assertFalse(outcomes.check_replace_result_response(response)) diff --git a/lms/djangoapps/lti_provider/tests/test_views.py b/lms/djangoapps/lti_provider/tests/test_views.py index 2dfa8da53f..eb1cbe851e 100644 --- a/lms/djangoapps/lti_provider/tests/test_views.py +++ b/lms/djangoapps/lti_provider/tests/test_views.py @@ -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): """ diff --git a/lms/djangoapps/lti_provider/views.py b/lms/djangoapps/lti_provider/views.py index a959a92513..33ac680301 100644 --- a/lms/djangoapps/lti_provider/views.py +++ b/lms/djangoapps/lti_provider/views.py @@ -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):