From 6d613f9d4e1fd5b935f554b19537e835f818366c Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 14:09:50 -0500 Subject: [PATCH 1/5] Enable un-authenticated handler urls Updates to depend on the latest version of XBlock, which includes support for service-to-service (thirdparty) handler urls, which aren't authenticated with a user (unlike handler requests coming from the xblock client-side javascript). Co-author: Ned Batchelder --- cms/djangoapps/contentstore/views/preview.py | 2 +- common/lib/xmodule/xmodule/tests/__init__.py | 2 +- common/lib/xmodule/xmodule/x_module.py | 1 + lms/djangoapps/courseware/module_render.py | 31 ++++++++++---- .../courseware/tests/test_module_render.py | 4 +- lms/lib/xblock/runtime.py | 40 +++++++++++++++---- lms/urls.py | 4 +- requirements/edx/github.txt | 2 +- 8 files changed, 65 insertions(+), 21 deletions(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 6e86a6485b..ab1375554d 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -115,7 +115,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method """ An XModule ModuleSystem for use in Studio previews """ - def handler_url(self, block, handler_name, suffix='', query=''): + def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False): return handler_prefix(block, handler_name, suffix) + '?' + query diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 90c7f45009..bfb5bd7f93 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -42,7 +42,7 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method """ ModuleSystem for testing """ - def handler_url(self, block, handler, suffix='', query=''): + def handler_url(self, block, handler, suffix='', query='', thirdparty=False): return str(block.scope_ids.usage_id) + '/' + handler + '/' + suffix + '?' + query diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index d39095586c..d4b7c01439 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -403,6 +403,7 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me data is a dictionary-like object with the content of the request""" return u"" + @XBlock.handler def xmodule_handler(self, request, suffix=None): """ XBlock handler that wraps `handle_ajax` diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 1539b883d3..f4bb129e3d 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import Http404 from django.http import HttpResponse -from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.csrf import csrf_exempt, csrf_protect from capa.xqueue_interface import XQueueInterface from courseware.access import has_access @@ -482,6 +482,14 @@ def xqueue_callback(request, course_id, userid, mod_id, dispatch): return HttpResponse("") +@csrf_exempt +def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix=None): + """ + Entry point for unauthenticated XBlock handlers. + """ + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user) + + def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): """ Generic view for extensions. This is where AJAX calls go. @@ -496,6 +504,17 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): the location and course_id do not identify a valid module, the module is not accessible by the user, or the module raises NotFoundError. If the module raises any other error, it will escape this function. + """ + if not request.user.is_authenticated(): + raise PermissionDenied + + return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user) + + +def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user): + """ + Invoke an XBlock handler, either authenticated or not. + """ location = unquote_slashes(usage_id) @@ -503,9 +522,6 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): if not Location.is_valid(location): raise Http404("Invalid location") - if not request.user.is_authenticated(): - raise PermissionDenied - # Check submitted files files = request.FILES or {} error_msg = _check_files_limits(files) @@ -525,15 +541,14 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None): field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course_id, - request.user, + user, descriptor ) - - instance = get_module(request.user, request, location, field_data_cache, course_id, grade_bucket_type='ajax') + instance = get_module(user, request, location, field_data_cache, course_id, grade_bucket_type='ajax') if instance is None: # Either permissions just changed, or someone is trying to be clever # and load something they shouldn't have access to. - log.debug("No module %s for user %s -- access denied?", location, request.user) + log.debug("No module %s for user %s -- access denied?", location, user) raise Http404 req = django_to_webob_request(request) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 3f4f3b673b..38f42d1f49 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -185,9 +185,11 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase): return mock_file def test_invalid_location(self): + request = self.request_factory.post('dummy_url', data={'position': 1}) + request.user = self.mock_user with self.assertRaises(Http404): render.handle_xblock_callback( - None, + request, 'dummy/course/id', 'invalid Location', 'dummy_handler' diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index cecdd4d121..4fa48be83a 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -58,11 +58,31 @@ def unquote_slashes(text): return re.sub(r'(;;|;_)', _unquote_slashes, text) -def handler_url(course_id, block, handler, suffix='', query=''): +def handler_url(course_id, block, handler, suffix='', query='', thirdparty=False): """ - Return an xblock handler url for the specified course, block and handler + Return an XBlock handler url for the specified course, block and handler. + + If handler is an empty string, this function is being used to create a + prefix of the general URL, which is assumed to be followed by handler name + and suffix. + + If handler is specified, then it is checked for being a valid handler + function, and ValueError is raised if not. + """ - return reverse('xblock_handler', kwargs={ + view_name = 'xblock_handler' + if handler: + # Be sure this is really a handler. + func = getattr(block, handler, None) + if not func: + raise ValueError("{!r} is not a function name".format(handler)) + if not getattr(func, "_is_xblock_handler", False): + raise ValueError("{!r} is not a handler name".format(handler)) + + if thirdparty: + view_name = 'xblock_handler_noauth' + + return reverse(view_name, kwargs={ 'course_id': course_id, 'usage_id': quote_slashes(str(block.scope_ids.usage_id)), 'handler': handler, @@ -72,8 +92,11 @@ def handler_url(course_id, block, handler, suffix='', query=''): def handler_prefix(course_id, block): """ - Returns a prefix for use by the javascript handler_url function. - The prefix is a valid handler url the handler name is appended to it. + Returns a prefix for use by the Javascript handler_url function. + + The prefix is a valid handler url after the handler name is slash-appended + to it. + """ return handler_url(course_id, block, '').rstrip('/') @@ -86,10 +109,11 @@ class LmsHandlerUrls(object): This must be mixed in to a runtime that already accepts and stores a course_id """ - - def handler_url(self, block, handler_name, suffix='', query=''): # pylint: disable=unused-argument + # pylint: disable=unused-argument + # pylint: disable=no-member + def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False): """See :method:`xblock.runtime:Runtime.handler_url`""" - return handler_url(self.course_id, block, handler_name, suffix='', query='') # pylint: disable=no-member + return handler_url(self.course_id, block, handler_name, suffix='', query='', thirdparty=thirdparty) class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method diff --git a/lms/urls.py b/lms/urls.py index 4aec95d01f..6de00a7411 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -178,7 +178,9 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xblock/(?P[^/]*)/handler/(?P[^/]*)(?:/(?P.*))?$', 'courseware.module_render.handle_xblock_callback', name='xblock_handler'), - + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/xblock/(?P[^/]*)/handler_noauth/(?P[^/]*)(?:/(?P.*))?$', + 'courseware.module_render.handle_xblock_callback_noauth', + name='xblock_handler_noauth'), # Software Licenses diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 26d12f2af9..3f00a7c915 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -15,7 +15,7 @@ -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: --e git+https://github.com/edx/XBlock.git@d6d2fc91#egg=XBlock +-e git+https://github.com/edx/XBlock.git@341d162f353289cfd3974a4f4f9354ce81ab60db#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.2.6#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.1.4#egg=js_test_tool From 9b6edea4069b21b50c3b87e26d5774a204184de6 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 14:19:23 -0500 Subject: [PATCH 2/5] Rename the LTIModuleDescriptor to LTIDescriptor to follow convention --- common/lib/xmodule/setup.py | 2 +- common/lib/xmodule/xmodule/lti_module.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index c55ff4a348..acb58aa39c 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -39,7 +39,7 @@ XMODULES = [ "hidden = xmodule.hidden_module:HiddenDescriptor", "raw = xmodule.raw_module:RawDescriptor", "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", - "lti = xmodule.lti_module:LTIModuleDescriptor", + "lti = xmodule.lti_module:LTIDescriptor", ] setup( diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 7fa30b4885..cfa9b2e281 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -302,7 +302,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} return params -class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): +class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): """ Descriptor for LTI Xmodule. """ From 1205173d6f8b3d88e718b8e86da1c44e4b87a4b2 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 15:18:24 -0500 Subject: [PATCH 3/5] Add a per-course anonymous student id This does not yet replace the existing per-student anonymous id, but is intended to do so in the future. Co-author: Alexander Kryklia Co-author: Ned Batchelder Co-author: Oleg Marchev Co-author: Valera Rozuvan Co-author: polesye --- .../commands/anonymized_id_mapping.py | 22 +- ...e_between_user_and_anonymous_student_id.py | 194 ++++++++++++++++++ common/djangoapps/student/models.py | 67 +++++- common/djangoapps/student/tests/tests.py | 38 +++- .../courseware/tests/test_module_render.py | 76 +++++++ 5 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index f1ed5bdef9..3c066121c5 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -1,21 +1,19 @@ # -*- coding: utf-8 -*- -"""Dump username,unique_id_for_user pairs as CSV. +"""Dump username, per-student anonymous id, and per-course anonymous id triples as CSV. Give instructors easy access to the mapping from anonymized IDs to user IDs with a simple Django management command to generate a CSV mapping. To run, use the following: -rake django-admin[anonymized_id_mapping,x,y,z] - -[Naturally, substitute the appropriate values for x, y, and z. (I.e., - lms, dev, and MITx/6.002x/Circuits)]""" +./manage.py lms anonymized_id_mapping COURSE_ID +""" import csv from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user class Command(BaseCommand): @@ -52,9 +50,17 @@ class Command(BaseCommand): try: with open(output_filename, 'wb') as output_file: csv_writer = csv.writer(output_file) - csv_writer.writerow(("User ID", "Anonymized user ID")) + csv_writer.writerow(( + "User ID", + "Per-Student anonymized user ID", + "Per-course anonymized user id" + )) for student in students: - csv_writer.writerow((student.id, unique_id_for_user(student))) + csv_writer.writerow(( + student.id, + anonymous_id_for_user(student, ''), + anonymous_id_for_user(student, course_id) + )) except IOError: raise CommandError("Error writing to file: %s" % output_filename) diff --git a/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py new file mode 100644 index 0000000000..e086496bfc --- /dev/null +++ b/common/djangoapps/student/migrations/0029_add_lookup_table_between_user_and_anonymous_student_id.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +import 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 'AnonymousUserId' + db.create_table('student_anonymoususerid', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('anonymous_user_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=16)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + )) + db.send_create_signal('student', ['AnonymousUserId']) + + + def backwards(self, orm): + # Deleting model 'AnonymousUserId' + db.delete_table('student_anonymoususerid') + + + 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'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '16'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 6774632634..15cc802b07 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -25,6 +25,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver import django.dispatch from django.forms import ModelForm, forms +from django.core.exceptions import ObjectDoesNotExist from course_modes.models import CourseMode import lms.lib.comment_client as cc @@ -42,6 +43,63 @@ log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") +class AnonymousUserId(models.Model): + """ + This table contains user, course_Id and anonymous_user_id + + Purpose of this table is to provide user by anonymous_user_id. + + We are generating anonymous_user_id using md5 algorithm, so resulting length will always be 16 bytes. + http://docs.python.org/2/library/md5.html#md5.digest_size + """ + user = models.ForeignKey(User, db_index=True) + anonymous_user_id = models.CharField(unique=True, max_length=16) + course_id = models.CharField(db_index=True, max_length=255) + unique_together = (user, course_id) + + +def anonymous_id_for_user(user, course_id): + """ + Return a unique id for a (user, course) pair, suitable for inserting + into e.g. personalized survey links. + + If user is an `AnonymousUser`, returns `None` + """ + # This part is for ability to get xblock instance in xblock_noauth handlers, where user is unauthenticated. + if user.is_anonymous(): + return None + + # include the secret key as a salt, and to make the ids unique across different LMS installs. + hasher = hashlib.md5() + hasher.update(settings.SECRET_KEY) + hasher.update(str(user.id)) + hasher.update(course_id) + + return AnonymousUserId.objects.get_or_create( + defaults={'anonymous_user_id': hasher.hexdigest()}, + user=user, + course_id=course_id + )[0].anonymous_user_id + + +def user_by_anonymous_id(id): + """ + Return user by anonymous_user_id using AnonymousUserId lookup table. + + Do not raise `django.ObjectDoesNotExist` exception, + if there is no user for anonymous_student_id, + because this function will be used inside xmodule w/o django access. + """ + + if id is None: + return None + + try: + return User.objects.get(anonymoususerid__anonymous_user_id=id) + except ObjectDoesNotExist: + return None + + class UserStanding(models.Model): """ This table contains a student's account's status. @@ -624,12 +682,9 @@ def unique_id_for_user(user): Return a unique id for a user, suitable for inserting into e.g. personalized survey links. """ - # include the secret key as a salt, and to make the ids unique across - # different LMS installs. - h = hashlib.md5() - h.update(settings.SECRET_KEY) - h.update(str(user.id)) - return h.hexdigest() + # Setting course_id to '' makes it not affect the generated hash, + # and thus produce the old per-student anonymous id + return anonymous_id_for_user(user, '') # TODO: Should be renamed to generic UserGroup, and possibly diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 1e7ee4baa8..f6f32d81b3 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -15,7 +15,7 @@ from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.test.client import RequestFactory -from django.contrib.auth.models import User +from django.contrib.auth.models import User, AnonymousUser from django.contrib.auth.hashers import UNUSABLE_PASSWORD from django.contrib.auth.tokens import default_token_generator from django.utils.http import int_to_base36 @@ -28,7 +28,7 @@ from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from mock import Mock, patch, sentinel from textwrap import dedent -from student.models import unique_id_for_user, CourseEnrollment +from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, change_enrollment, complete_course_mode_info) from student.tests.factories import UserFactory, CourseModeFactory @@ -501,3 +501,37 @@ class PaidRegistrationTest(ModuleStoreTestCase): self.assertEqual(response.content, reverse('shoppingcart.views.show_cart')) self.assertTrue(shoppingcart.models.PaidCourseRegistration.contained_in_order( shoppingcart.models.Order.get_cart_for_user(self.user), self.course.id)) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class AnonymousLookupTable(TestCase): + """ + Tests for anonymous_id_functions + """ + # arbitrary constant + COURSE_SLUG = "100" + COURSE_NAME = "test_course" + COURSE_ORG = "EDX" + + def setUp(self): + self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG) + self.assertIsNotNone(self.course) + self.user = UserFactory() + CourseModeFactory.create( + course_id=self.course.id, + mode_slug='honor', + mode_display_name='Honor Code', + ) + patcher = patch('student.models.server_track') + self.mock_server_track = patcher.start() + self.addCleanup(patcher.stop) + + def test_for_unregistered_user(self): # same path as for logged out user + self.assertEqual(None, anonymous_id_for_user(AnonymousUser(), self.course.id)) + self.assertIsNone(user_by_anonymous_id(None)) + + def test_roundtrip_for_logged_user(self): + enrollment = CourseEnrollment.enroll(self.user, self.course.id) + anonymous_id = anonymous_id_for_user(self.user, self.course.id) + real_user = user_by_anonymous_id(anonymous_id) + self.assertEqual(self.user, real_user) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 38f42d1f49..a156b0aea3 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -1,6 +1,7 @@ """ Test for lms courseware app, module render unit """ +from ddt import ddt, data from mock import MagicMock, patch, Mock import json @@ -11,10 +12,14 @@ from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings +from xblock.field_data import FieldData +from xblock.runtime import Runtime +from xblock.fields import ScopeIds from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.x_module import XModuleDescriptor import courseware.module_render as render from courseware.tests.tests import LoginEnrollmentTestCase from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE @@ -515,3 +520,74 @@ class TestHtmlModifiers(ModuleStoreTestCase): 'Staff Debug', result_fragment.content ) + +PER_COURSE_ANONYMIZED_DESCRIPTORS = () + +PER_STUDENT_ANONYMIZED_DESCRIPTORS = [ + class_ for (name, class_) in XModuleDescriptor.load_classes() + if not issubclass(class_, PER_COURSE_ANONYMIZED_DESCRIPTORS) +] + + +@ddt +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestAnonymousStudentId(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test that anonymous_student_id is set correctly across a variety of XBlock types + """ + + def setUp(self): + self.user = UserFactory() + + @patch('courseware.module_render.has_access', Mock(return_value=True)) + def _get_anonymous_id(self, course_id, xblock_class): + location = Location('dummy_org', 'dummy_course', 'dummy_category', 'dummy_name') + descriptor = Mock( + spec=xblock_class, + _field_data=Mock(spec=FieldData), + location=location, + static_asset_path=None, + runtime=Mock( + spec=Runtime, + resources_fs=None, + mixologist=Mock(_mixins=()) + ), + scope_ids=Mock(spec=ScopeIds), + ) + if hasattr(xblock_class, 'module_class'): + descriptor.module_class = xblock_class.module_class + + return render.get_module_for_descriptor_internal( + self.user, + descriptor, + Mock(spec=FieldDataCache), + course_id, + Mock(), # Track Function + Mock(), # XQueue Callback Url Prefix + ).xmodule_runtime.anonymous_student_id + + @data(*PER_STUDENT_ANONYMIZED_DESCRIPTORS) + def test_per_student_anonymized_id(self, descriptor_class): + for course_id in ('MITx/6.00x/2012_Fall', 'MITx/6.00x/2013_Spring'): + self.assertEquals( + # This value is set by observation, so that later changes to the student + # id computation don't break old data + '5afe5d9bb03796557ee2614f5c9611fb', + self._get_anonymous_id(course_id, descriptor_class) + ) + + @data(*PER_COURSE_ANONYMIZED_DESCRIPTORS) + def test_per_course_anonymized_id(self, descriptor_class): + self.assertEquals( + # This value is set by observation, so that later changes to the student + # id computation don't break old data + 'e3b0b940318df9c14be59acb08e78af5', + self._get_anonymous_id('MITx/6.00x/2012_Fall', descriptor_class) + ) + + self.assertEquals( + # This value is set by observation, so that later changes to the student + # id computation don't break old data + 'f82b5416c9f54b5ce33989511bb5ef2e', + self._get_anonymous_id('MITx/6.00x/2013_Spring', descriptor_class) + ) From 52ab2b1313a90aca2de7279e864049f4eeaed975 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 15:44:09 -0500 Subject: [PATCH 4/5] Make FieldDataCache use the user from scope_ids, rather than its pre-configured user Co-author: Ned Batchelder --- lms/djangoapps/courseware/model_data.py | 16 +++++++++++----- .../courseware/tests/test_model_data.py | 11 ++++++++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index da5d639bf0..fd7705affc 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -14,10 +14,11 @@ from .models import ( import logging from django.db import DatabaseError +from django.contrib.auth.models import User from xblock.runtime import KeyValueStore from xblock.exceptions import KeyValueMultiSaveError, InvalidScopeError -from xblock.fields import Scope +from xblock.fields import Scope, UserScope log = logging.getLogger(__name__) @@ -226,10 +227,15 @@ class FieldDataCache(object): if field_object is not None: return field_object + if key.scope.user == UserScope.ONE and not self.user.is_anonymous(): + # If we're getting user data, we expect that the key matches the + # user we were constructed for. + assert key.user_id == self.user.id + if key.scope == Scope.user_state: field_object, _ = StudentModule.objects.get_or_create( course_id=self.course_id, - student=self.user, + student=User.objects.get(id=key.user_id), module_state_key=key.block_scope_id.url(), defaults={ 'state': json.dumps({}), @@ -245,12 +251,12 @@ class FieldDataCache(object): field_object, _ = XModuleStudentPrefsField.objects.get_or_create( field_name=key.field_name, module_type=key.block_scope_id, - student=self.user, + student=User.objects.get(id=key.user_id), ) elif key.scope == Scope.user_info: field_object, _ = XModuleStudentInfoField.objects.get_or_create( field_name=key.field_name, - student=self.user, + student=User.objects.get(id=key.user_id), ) cache_key = self._cache_key_from_kvs_key(key) @@ -347,7 +353,7 @@ class DjangoKeyValueStore(KeyValueStore): # the list of successful saves saved_fields.extend([field.field_name for field in field_objects[field_object]]) except DatabaseError: - log.error('Error saving fields %r', field_objects[field_object]) + log.exception('Error saving fields %r', field_objects[field_object]) raise KeyValueMultiSaveError(saved_fields) def delete(self, key): diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index bde57c1553..2286506465 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -40,11 +40,14 @@ def mock_descriptor(fields=[]): location = partial(Location, 'i4x', 'edX', 'test_course', 'problem') course_id = 'edX/test_course/test' +# The user ids here are 1 because we make a student in the setUp functions, and +# they get an id of 1. There's an assertion in setUp to ensure that assumption +# is still true. user_state_summary_key = partial(DjangoKeyValueStore.Key, Scope.user_state_summary, None, location('def_id')) settings_key = partial(DjangoKeyValueStore.Key, Scope.settings, None, location('def_id')) -user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 'user', location('def_id')) -prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 'user', 'MockProblemModule') -user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 'user', None) +user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 1, location('def_id')) +prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 1, 'MockProblemModule') +user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 1, None) class StudentModuleFactory(cmfStudentModuleFactory): @@ -76,6 +79,7 @@ class TestStudentModuleStorage(TestCase): def setUp(self): student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'})) self.user = student_module.student + self.assertEqual(self.user.id, 1) # check our assumption hard-coded in the key functions above. self.field_data_cache = FieldDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user) self.kvs = DjangoKeyValueStore(self.field_data_cache) @@ -152,6 +156,7 @@ class TestStudentModuleStorage(TestCase): class TestMissingStudentModule(TestCase): def setUp(self): self.user = UserFactory.create(username='user') + self.assertEqual(self.user.id, 1) # check our assumption hard-coded in the key functions above. self.field_data_cache = FieldDataCache([mock_descriptor()], course_id, self.user) self.kvs = DjangoKeyValueStore(self.field_data_cache) From 11bbf4c18221f2b6361701436b6b2cb17faf2f30 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 26 Nov 2013 15:47:35 -0500 Subject: [PATCH 5/5] Add grading functionality to LTI xmodule Co-author: Alexander Kryklia Co-author: Ned Batchelder Co-author: Oleg Marchev Co-author: Valera Rozuvan Co-author: polesye [BLD-384] --- CHANGELOG.rst | 4 + common/lib/xmodule/xmodule/lti_module.py | 429 ++++++++++++++---- .../xmodule/xmodule/tests/test_lti_unit.py | 251 ++++++++++ common/lib/xmodule/xmodule/x_module.py | 5 +- docs/course_authors/source/create_lti.rst | 97 ++++ docs/course_authors/source/index.rst | 9 +- docs/data/source/index.rst | 1 + .../courseware/features/lti.feature | 40 +- lms/djangoapps/courseware/features/lti.py | 109 ++++- .../courseware/features/lti_setup.py | 1 + .../mock_lti_server/mock_lti_server.py | 181 +++++++- .../mock_lti_server/server_start.py | 25 + .../mock_lti_server/test_mock_lti_server.py | 66 ++- lms/djangoapps/courseware/module_render.py | 26 +- .../{test_lti.py => test_lti_integration.py} | 29 +- .../courseware/tests/test_module_render.py | 7 +- rakelib/docs.rake | 2 +- 17 files changed, 1123 insertions(+), 159 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_lti_unit.py create mode 100644 docs/course_authors/source/create_lti.rst create mode 100644 lms/djangoapps/courseware/mock_lti_server/server_start.py rename lms/djangoapps/courseware/tests/{test_lti.py => test_lti_integration.py} (77%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4bc37482c8..b0239dc86b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,10 @@ LMS: Add feature for providing background grade report generation via Celery instructor task, with reports uploaded to S3. Feature is visible on the beta instructor dashboard. LMS-58 +Blades: Added grading support for LTI module. LTI providers can now grade +student's work and send edX scores. OAuth1 based authentication +implemented. BLD-384. + LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid across all runs with the same course name. Old group membership will still work across runs, but new beta-testers will only be added to a single course run. diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index cfa9b2e281..12bca979db 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -1,21 +1,60 @@ """ -Module that allows to insert LTI tools to page. +Learning Tools Interoperability (LTI) module. -Protocol is oauth1, LTI version is 1.1.1: -http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html + +Resources +--------- + +Theoretical background and detailed specifications of LTI can be found on: + + http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html + +This module is based on the version 1.1.1 of the LTI specifications by the +IMS Global authority. For authentication, it uses OAuth1. + +When responding back to the LTI tool provider, we must issue a correct +response. Types of responses and their message payload is available at: + + Table A1.2 Interpretation of the 'CodeMajor/severity' matrix. + http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html + +A resource to test the LTI protocol (PHP realization): + + http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php + + +What is supported: +------------------ + +1.) Display of simple LTI in iframe or a new window. +2.) Multiple LTI components on a single page. +3.) The use of multiple LTI providers per course. +4.) Use of advanced LTI component that provides back a grade. + a.) The LTI provider sends back a grade to a specified URL. + b.) Currently only action "update" is supported. "Read", and "delete" + actions initially weren't required. """ import logging import oauthlib.oauth1 +from oauthlib.oauth1.rfc5849 import signature +import hashlib +import base64 import urllib +import textwrap +from lxml import etree +from webob import Response +import mock +from xml.sax.saxutils import escape from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor -from xmodule.x_module import XModule +from xmodule.x_module import XModule, module_attr from xmodule.course_module import CourseDescriptor from pkg_resources import resource_string -from xblock.core import String, Scope, List -from xblock.fields import Boolean +from xblock.core import String, Scope, List, XBlock +from xblock.fields import Boolean, Float + log = logging.getLogger(__name__) @@ -29,8 +68,8 @@ class LTIFields(object): Fields to define and obtain LTI tool from provider are set here, except credentials, which should be set in course settings:: - `lti_id` is id to connect tool with credentials in course settings. - `launch_url` is launch url of tool. + `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) + `launch_url` is launch URL of tool. `custom_parameters` are additional parameters to navigate to proper book and book page. For example, for Vitalsource provider, `launch_url` should be @@ -40,7 +79,7 @@ class LTIFields(object): vbid=put_book_id_here book_location=page/put_page_number_here - Default non-empty url for `launch_url` is needed due to oauthlib demand (url scheme should be presented):: + Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 """ @@ -48,13 +87,15 @@ class LTIFields(object): launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings) custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings) open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings) + graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings) + weight = Float(help="Weight for student grades.", default=1.0, scope=Scope.settings) class LTIModule(LTIFields, XModule): - ''' + """ Module provides LTI integration to course. - Except usual xmodule structure it proceeds with oauth signing. + Except usual Xmodule structure it proceeds with OAuth signing. How it works:: 1. Get credentials from course settings. @@ -71,14 +112,14 @@ class LTIModule(LTIFields, XModule): role *+ all custom parameters* - These parameters should be encoded and signed by *oauth1* together with + These parameters should be encoded and signed by *OAuth1* together with `launch_url` and *POST* request type. 3. Signing proceeds with client key/secret pair obtained from course settings. That pair should be obtained from LTI provider and set into course settings by course author. - After that signature and other oauth data are generated. + After that signature and other OAuth data are generated. - Oauth data which is generated after signing is usual:: + OAuth data which is generated after signing is usual:: oauth_callback oauth_nonce @@ -89,47 +130,47 @@ class LTIModule(LTIFields, XModule): 4. All that data is passed to form and sent to LTI provider server by browser via - autosubmit via javascript. + autosubmit via JavaScript. Form example::
- - - - - - - - - - - - - - + action="${launch_url}" + name="ltiLaunchForm-${element_id}" + class="ltiLaunchForm" + method="post" + target="ltiLaunchFrame-${element_id}" + encType="application/x-www-form-urlencoded" + > + + + + + + + + + + + + + + - - - + + + - -
+ + - 5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures. + 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. If signatures are correct, LTI provider redirects iframe source to LTI tool web page, and LTI tool is rendered to iframe inside course. Otherwise error message from LTI provider is generated. - ''' + """ js = {'js': [resource_string(__name__, 'js/src/lti/lti.js')]} css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]} @@ -180,21 +221,7 @@ class LTIModule(LTIFields, XModule): "tool_consumer_instance_contact_email", ] - # Obtains client_key and client_secret credentials from current course: - course_id = self.course_id - course_location = CourseDescriptor.id_to_location(course_id) - course = self.descriptor.runtime.modulestore.get_item(course_location) - client_key = client_secret = '' - - for lti_passport in course.lti_passports: - try: - lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] - except ValueError: - raise LTIError('Could not parse LTI passport: {0!r}. \ - Should be "id:key:secret" string.'.format(lti_passport)) - if lti_id == self.lti_id.strip(): - client_key, client_secret = key, secret - break + client_key, client_secret = self.get_client_key_secret() # parsing custom parameters to dict custom_parameters = {} @@ -214,12 +241,12 @@ class LTIModule(LTIFields, XModule): input_fields = self.oauth_params( custom_parameters, client_key, - client_secret + client_secret, ) context = { 'input_fields': input_fields, - # these params do not participate in oauth signing + # These parameters do not participate in OAuth signing. 'launch_url': self.launch_url.strip(), 'element_id': self.location.html_id(), 'element_class': self.category, @@ -229,12 +256,57 @@ class LTIModule(LTIFields, XModule): return self.system.render_template('lti.html', context) + def get_user_id(self): + user_id = self.runtime.anonymous_student_id + assert user_id is not None + return unicode(urllib.quote(user_id)) + + def get_outcome_service_url(self): + """ + Return URL for storing grades. + """ + uri = 'http://{host}{path}'.format( + host=self.system.hostname, + path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?') + ) + return uri + + def get_resource_link_id(self): + """ + This is an opaque unique identifier that the TC guarantees will be unique + within the TC for every placement of the link. + + If the tool / activity is placed multiple times in the same context, + each of those placements will be distinct. + + This value will also change if the item is exported from one system or + context and imported into another system or context. + + This parameter is required. + """ + return unicode(urllib.quote(self.id)) + + def get_lis_result_sourcedid(self): + """ + This field contains an identifier that indicates the LIS Result Identifier (if any) + associated with this launch. This field identifies a unique row and column within the + TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id. + This value may change for a particular resource_link_id / user_id from one launch to the next. + The TP should only retain the most recent value for this field for a particular resource_link_id / user_id. + This field is generally optional, but is required for grading. + + context_id is - is an opaque identifier that uniquely identifies the context that contains + the link being launched. + lti_id should be context_id by meaning. + """ + return u':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id())) + + def oauth_params(self, custom_parameters, client_key, client_secret): """ - Signs request and returns signature and oauth parameters. + Signs request and returns signature and OAuth parameters. `custom_paramters` is dict of parsed `custom_parameter` field - `client_key` and `client_secret` are LTI tool credentials. Also *anonymous student id* is passed to template and therefore to LTI provider. @@ -245,22 +317,23 @@ class LTIModule(LTIFields, XModule): client_secret=unicode(client_secret) ) - user_id = self.runtime.anonymous_student_id - assert user_id is not None - - # must have parameters for correct signing from LTI: + # Must have parameters for correct signing from LTI: body = { - u'user_id': user_id, + u'user_id': self.get_user_id(), u'oauth_callback': u'about:blank', - u'lis_outcome_service_url': '', - u'lis_result_sourcedid': '', u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', - u'role': u'student' + u'role': u'student', + + # Parameters required for grading: + u'resource_link_id': self.get_resource_link_id(), + u'lis_outcome_service_url': self.get_outcome_service_url(), + u'lis_result_sourcedid': self.get_lis_result_sourcedid(), + } - # appending custom parameter for signing + # Appending custom parameter for signing. body.update(custom_parameters) headers = { @@ -274,9 +347,9 @@ class LTIModule(LTIFields, XModule): http_method=u'POST', body=body, headers=headers) - except ValueError: # scheme not in url - #https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 - #Stubbing headers for now: + except ValueError: # Scheme not in url. + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 + # Stubbing headers for now: headers = { u'Content-Type': u'application/x-www-form-urlencoded', u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \ @@ -284,7 +357,7 @@ oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC- oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} params = headers['Authorization'] - # parse headers to pass to template as part of context: + # Parse headers to pass to template as part of context: params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')]) params[u'oauth_nonce'] = params[u'OAuth oauth_nonce'] @@ -297,13 +370,217 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} # So we need to decode signature back: params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8') - # add lti parameters to oauth parameters for sending in form + # Add LTI parameters to OAuth parameters for sending in form. params.update(body) return params + def max_score(self): + return self.weight + + + @XBlock.handler + def grade_handler(self, request, dispatch): + """ + This is called by courseware.module_render, to handle an AJAX call. + + Used only for grading. Returns XML response. + + Example of request body from LTI provider:: + + + + + + V1.0 + 528243ba5241b + + + + + + + feb-123-456-2929::28883 + + + + en-us + 0.4 + + + + + + + + Example of correct/incorrect answer XML body:: see response_xml_template. + """ + response_xml_template = textwrap.dedent(""" + + + + + V1.0 + {imsx_messageIdentifier} + + {imsx_codeMajor} + status + {imsx_description} + + + + + + {response} + + """) + # Returns when `action` is unsupported. + # Supported actions: + # - replaceResultRequest. + unsupported_values = { + 'imsx_codeMajor': 'unsupported', + 'imsx_description': 'Target does not support the requested operation.', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } + # Returns if: + # - score is out of range; + # - can't parse response from TP; + # - can't verify OAuth signing or OAuth signing is incorrect. + failure_values = { + 'imsx_codeMajor': 'failure', + 'imsx_description': 'The request has failed.', + 'imsx_messageIdentifier': 'unknown', + 'response': '' + } + + try: + imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) + except Exception: + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + # Verify OAuth signing. + try: + self.verify_oauth_body_sign(request) + except (ValueError, LTIError): + failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) + return Response(response_xml_template.format(**failure_values), content_type="application/xml") + + + real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1])) + if action == 'replaceResultRequest': + self.system.publish( + event={ + 'event_name': 'grade', + 'value': score * self.max_score(), + 'max_value': self.max_score(), + }, + custom_user=real_user + ) + + values = { + 'imsx_codeMajor': 'success', + 'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score), + 'imsx_messageIdentifier': escape(imsx_messageIdentifier), + 'response': '' + } + return Response(response_xml_template.format(**values), content_type="application/xml") + + unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) + return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') + + + @classmethod + def parse_grade_xml_body(cls, body): + """ + Parses XML from request.body and returns parsed data + + XML body should contain nsmap with namespace, that is specified in LTI specs. + + Returns tuple: imsx_messageIdentifier, sourcedId, score, action + + Raises Exception if can't parse. + """ + lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" + namespaces = {'def': lti_spec_namespace} + + data = body.strip().encode('utf-8') + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(data, parser=parser) + + imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text + score = root.xpath("//def:textString", namespaces=namespaces)[0].text + action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '') + # Raise exception if score is not float or not in range 0.0-1.0 regarding spec. + score = float(score) + if not 0 <= score <= 1: + raise LTIError + + return imsx_messageIdentifier, sourcedId, score, action + + def verify_oauth_body_sign(self, request): + """ + Verify grade request from LTI provider using OAuth body signing. + + Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html:: + + This specification extends the OAuth signature to include integrity checks on HTTP request bodies + with content types other than application/x-www-form-urlencoded. + + Arguments: + request: DjangoWebobRequest. + + Raises: + LTIError if request is incorrect. + """ + + client_key, client_secret = self.get_client_key_secret() + + headers = { + 'Authorization':unicode(request.headers.get('Authorization')), + 'Content-Type': 'application/x-www-form-urlencoded', + } + + sha1 = hashlib.sha1() + sha1.update(request.body) + oauth_body_hash = base64.b64encode(sha1.hexdigest()) + + oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) + oauth_headers =dict(oauth_params) + oauth_signature = oauth_headers.pop('oauth_signature') + + mock_request = mock.Mock( + uri=unicode(urllib.unquote(request.url)), + http_method=unicode(request.method), + params=oauth_headers.items(), + signature=oauth_signature + ) + if (oauth_body_hash != oauth_headers.get('oauth_body_hash') or + not signature.verify_hmac_sha1(mock_request, client_secret)): + raise LTIError + + def get_client_key_secret(self): + """ + Obtains client_key and client_secret credentials from current course. + """ + course_id = self.course_id + course_location = CourseDescriptor.id_to_location(course_id) + course = self.descriptor.runtime.modulestore.get_item(course_location) + + for lti_passport in course.lti_passports: + try: + lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] + except ValueError: + raise LTIError('Could not parse LTI passport: {0!r}. \ + Should be "id:key:secret" string.'.format(lti_passport)) + if lti_id == self.lti_id.strip(): + return key, secret + return '', '' class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor): """ Descriptor for LTI Xmodule. """ + has_score = True module_class = LTIModule + grade_handler = module_attr('grade_handler') diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py new file mode 100644 index 0000000000..b79bec16cc --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -0,0 +1,251 @@ +# -*- coding: utf-8 -*- +"""Test for LTI Xmodule functional logic.""" + +from mock import Mock, patch, PropertyMock +import textwrap +from lxml import etree +from webob.request import Request +from copy import copy +import urllib + +from xmodule.lti_module import LTIDescriptor + +from . import LogicTest + + +class LTIModuleTest(LogicTest): + """Logic tests for LTI module.""" + descriptor_class = LTIDescriptor + + def setUp(self): + super(LTIModuleTest, self).setUp() + self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} + self.request_body_xml_template = textwrap.dedent(""" + + + + + V1.0 + {messageIdentifier} + + + + <{action}> + + + {sourcedId} + + + + en-us + {grade} + + + + + + + """) + self.system.get_real_user = Mock() + self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret')) + self.system.publish = Mock() + + self.user_id = self.xmodule.runtime.anonymous_student_id + self.lti_id = self.xmodule.lti_id + self.module_id = '//MITx/999/lti/' + + sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id)) + + self.DEFAULTS = { + 'sourcedId': sourcedId, + 'action': 'replaceResultRequest', + 'grade': '0.5', + 'messageIdentifier': '528243ba5241b', + } + + def get_request_body(self, params={}): + data = copy(self.DEFAULTS) + + data.update(params) + return self.request_body_xml_template.format(**data) + + def get_response_values(self, response): + parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') + root = etree.fromstring(response.body.strip(), parser=parser) + lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" + namespaces = {'def': lti_spec_namespace} + + code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text + description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text + messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text + imsx_POXBody = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] + + try: + action = imsx_POXBody.getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '') + except Exception: + action = None + + return { + 'code_major': code_major, + 'description': description, + 'messageIdentifier': messageIdentifier, + 'action': action + } + + def test_authorization_header_not_present(self): + """ + Request has no Authorization header. + This is an unknown service request, i.e., it is not a part of the original service specification. + """ + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + + self.assertEqual(response.status_code, 200) + self.assertDictEqual(expected_response, real_response) + + def test_authorization_header_empty(self): + """ + Request Authorization header has no value. + This is an unknown service request, i.e., it is not a part of the original service specification. + """ + request = Request(self.environ) + request.authorization = "bad authorization header" + request.body = self.get_request_body() + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + + self.assertEqual(response.status_code, 200) + self.assertDictEqual(expected_response, real_response) + + def test_grade_not_in_range(self): + """ + Grade returned from Tool Provider is outside the range 0.0-1.0. + """ + self.xmodule.verify_oauth_body_sign = Mock() + request = Request(self.environ) + request.body = self.get_request_body(params={'grade': '10'}) + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': 'unknown', + } + + self.assertEqual(response.status_code, 200) + self.assertDictEqual(expected_response, real_response) + + def test_bad_grade_decimal(self): + """ + Grade returned from Tool Provider doesn't use a period as the decimal point. + """ + self.xmodule.verify_oauth_body_sign = Mock() + request = Request(self.environ) + request.body = self.get_request_body(params={'grade': '0,5'}) + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'failure', + 'description': 'The request has failed.', + 'messageIdentifier': 'unknown', + } + + self.assertEqual(response.status_code, 200) + self.assertDictEqual(expected_response, real_response) + + def test_unsupported_action(self): + """ + Action returned from Tool Provider isn't supported. + `replaceResultRequest` is supported only. + """ + self.xmodule.verify_oauth_body_sign = Mock() + request = Request(self.environ) + request.body = self.get_request_body({'action': 'wrongAction'}) + response = self.xmodule.grade_handler(request, '') + real_response = self.get_response_values(response) + expected_response = { + 'action': None, + 'code_major': 'unsupported', + 'description': 'Target does not support the requested operation.', + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + + self.assertEqual(response.status_code, 200) + self.assertDictEqual(expected_response, real_response) + + def test_good_request(self): + """ + Response from Tool Provider is correct. + """ + self.xmodule.verify_oauth_body_sign = Mock() + request = Request(self.environ) + request.body = self.get_request_body() + response = self.xmodule.grade_handler(request, '') + code_major, description, messageIdentifier, action = self.get_response_values(response) + description_expected = 'Score for {sourcedId} is now {score}'.format( + sourcedId=self.DEFAULTS['sourcedId'], + score=self.DEFAULTS['grade'], + ) + real_response = self.get_response_values(response) + expected_response = { + 'action': 'replaceResultResponse', + 'code_major': 'success', + 'description': description_expected, + 'messageIdentifier': self.DEFAULTS['messageIdentifier'], + } + + self.assertEqual(response.status_code, 200) + self.assertDictEqual(expected_response, real_response) + + def test_user_id(self): + expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id)) + real_user_id = self.xmodule.get_user_id() + self.assertEqual(real_user_id, expected_user_id) + + def test_outcome_service_url(self): + expected_outcome_service_url = 'http://{host}{path}'.format( + host=self.xmodule.runtime.hostname, + path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?') + ) + + real_outcome_service_url = self.xmodule.get_outcome_service_url() + self.assertEqual(real_outcome_service_url, expected_outcome_service_url) + + def test_resource_link_id(self): + with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id: + mock_id.return_value = self.module_id + expected_resource_link_id = unicode(urllib.quote(self.module_id)) + real_resource_link_id = self.xmodule.get_resource_link_id() + self.assertEqual(real_resource_link_id, expected_resource_link_id) + + + def test_lis_result_sourcedid(self): + with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id: + mock_id.return_value = self.module_id + expected_sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id)) + real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid() + self.assertEqual(real_lis_result_sourcedid, expected_sourcedId) + + + def test_verify_oauth_body_sign(self): + pass + + def test_client_key_secret(self): + pass + diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index d4b7c01439..9154952ace 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me See the HTML module for a simple example. """ - has_score = descriptor_attr('has_score') _field_data_cache = descriptor_attr('_field_data_cache') _field_data = descriptor_attr('_field_data') @@ -968,7 +967,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs anonymous_student_id='', course_id=None, open_ended_grading_interface=None, s3_interface=None, cache=None, can_execute_unsafe_code=None, replace_course_urls=None, - replace_jump_to_id_urls=None, error_descriptor_class=None, **kwargs): + replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None, **kwargs): """ Create a closure around the system environment. @@ -1053,6 +1052,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs self.error_descriptor_class = error_descriptor_class self.xmodule_instance = None + self.get_real_user = get_real_user + def get(self, attr): """ provide uniform access to attributes (like etree).""" return self.__dict__.get(attr) diff --git a/docs/course_authors/source/create_lti.rst b/docs/course_authors/source/create_lti.rst new file mode 100644 index 0000000000..00ccb71c67 --- /dev/null +++ b/docs/course_authors/source/create_lti.rst @@ -0,0 +1,97 @@ +********************** +Create a LTI Component +********************** + +Description +=========== + +The LTI XModule is based on the `IMS Global Learning Tools Interoperability `_ Version 1.1.1 specifications. + +Enabling LTI +============ + +It is not available from the list of general components. To turn it on, add +"lti" to the "advanced_modules" key on the Advanced Settings page. + +The module supports 2 modes of operation. + +1. Simple display of external LTI content +2. Display of LTI content that will be graded by external provider + +In both cases, before an LTI component from an external provider can be +included in a unit, the following pieces of information must be known/decided +upon: + +**LTI id** [string] + Internal string representing the external LTI provider. Can contain multi- + case alphanumeric characters, and underscore. + +**Client key** [string] + Used for OAuth authentication. Issued by external LTI provider. + +**Client secret** [string] + Used for OAuth authentication. Issued by external LTI provider. + +LTI id is necessary to differentiate between multiple available external LTI +providers that are added to an edX course. + +The three fields above must be entered in "lti_passports" field in the format:: + + [ + "{lti_id}:{client_key}:{client_secret}" + ] + +Multiple external LTI providers are separated by commas:: + + [ + "{lti_id_1}:{client_key_1}:{client_secret_1}", + "{lti_id_2}:{client_key_2}:{client_secret_2}", + "{lti_id_3}:{client_key_3}:{client_secret_3}" + ] + +Adding LTI to a unit +==================== + +After LTI has been enabled, and an external provider has been registered, an +instance of it can be added to a unit. + +LTI will be available from the Advanced Component category. After adding an LTI +component to a unit, it can be configured by Editing it's settings (the Edit +dialog). The following settings are available: + +**Display Name** [string] + Title of the new LTI component instance + +**custom_parameters** [string] + With the "+ Add" button, multiple custom parameters can be + added. Basically, each individual external LTI provider can have a separate + format custom parameters. For example:: + + key=value + +**graded** [boolean] + Whether or not the grade for this particular LTI instance problem will be + counted towards student's total grade. + +**launch_url** [string] + If `rgaded` above is set to `true`, then this must be + the URL that will be passed to the external LTI provider for it to respond with + a grade. + +**lti_id** [string] + Internal string representing the external LTI provider that + will be used to display content. The same as was entered on the Advanced + Settings page. + +**open_in_a_new_page** [boolean] + If set to `true`, a link will be present for the student + to click. When the link is clicked, a new window will open with the external + LTI content. If set to `false`, the external LTI content will be loaded in the + page in an iframe. + +**weight** [float] + If the problem will be graded by an external LTI provider, + the raw grade will be in the range [0.0, 1.0]. In order to change this range, + set the `weight`. The grade that will be stored is calculated by the formula:: + + stored_grade = raw_grade * weight diff --git a/docs/course_authors/source/index.rst b/docs/course_authors/source/index.rst index 5e17e39106..1708770eec 100755 --- a/docs/course_authors/source/index.rst +++ b/docs/course_authors/source/index.rst @@ -20,6 +20,7 @@ Contents create_video create_discussion create_html_component + create_lti create_problem set_content_releasedates establish_course_settings @@ -34,13 +35,13 @@ Contents checking_student_progress change_log - - - -Appendices + + + +Appendices ========== .. toctree:: diff --git a/docs/data/source/index.rst b/docs/data/source/index.rst index d1efb46902..7c1a1512f3 100644 --- a/docs/data/source/index.rst +++ b/docs/data/source/index.rst @@ -25,6 +25,7 @@ Specific Problem Types course_data_formats/drag_and_drop/drag_and_drop_input.rst course_data_formats/graphical_slider_tool/graphical_slider_tool.rst course_data_formats/poll_module/poll_module.rst + course_data_formats/lti_module/lti.rst course_data_formats/conditional_module/conditional_module.rst course_data_formats/word_cloud/word_cloud.rst course_data_formats/custom_response.rst diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index 8255e515b0..7f27a65b5a 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -2,27 +2,57 @@ Feature: LMS.LTI component As a student, I want to view LTI component in LMS. + #1 Scenario: LTI component in LMS with no launch_url is not rendered Given the course has correct LTI credentials - And the course has an LTI component with no_launch_url fields, new_page is false + And the course has an LTI component with no_launch_url fields: + | open_in_a_new_page | + | False | Then I view the LTI and error is shown + #2 Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly Given the course has correct LTI credentials - And the course has an LTI component with incorrect_lti_id fields, new_page is false + And the course has an LTI component with incorrect_lti_id fields: + | open_in_a_new_page | + | False | Then I view the LTI but incorrect_signature warning is rendered + #3 Scenario: LTI component in LMS is rendered incorrectly Given the course has incorrect LTI credentials - And the course has an LTI component with correct fields, new_page is false + And the course has an LTI component with correct fields: + | open_in_a_new_page | + | False | Then I view the LTI but incorrect_signature warning is rendered + #4 Scenario: LTI component in LMS is correctly rendered in new page Given the course has correct LTI credentials - And the course has an LTI component with correct fields, new_page is true + And the course has an LTI component with correct fields Then I view the LTI and it is rendered in new page + #5 Scenario: LTI component in LMS is correctly rendered in iframe Given the course has correct LTI credentials - And the course has an LTI component with correct fields, new_page is false + And the course has an LTI component with correct fields: + | open_in_a_new_page | + | False | Then I view the LTI and it is rendered in iframe + + #6 + Scenario: Graded LTI component in LMS is correctly works + Given the course has correct LTI credentials + And the course has an LTI component with correct fields: + | open_in_a_new_page | weight | is_graded | + | False | 10 | True | + And I submit answer to LTI question + And I click on the "Progress" tab + Then I see text "Problem Scores: 5/10" + And I see graph with total progress "5%" + Then I click on the "Instructor" tab + And I click on the "Gradebook" tab + And I see in the gradebook table that "HW" is "50" + And I see in the gradebook table that "Total" is "5" + + diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 58de82ded6..772877fa2a 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -1,6 +1,7 @@ #pylint: disable=C0111 import os + from django.contrib.auth.models import User from lettuce import world, step from lettuce.django import django_url @@ -86,36 +87,41 @@ def set_incorrect_lti_passport(_step): } i_am_registered_for_the_course(coursenum, metadata) - -@step('the course has an LTI component with (.*) fields, new_page is(.*)$') -def add_correct_lti_to_course(_step, fields, new_page): +@step('the course has an LTI component with (.*) fields(?:\:)?$') #, new_page is(.*), is_graded is(.*) +def add_correct_lti_to_course(_step, fields): category = 'lti' - lti_id = 'correct_lti_id' - launch_url = world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] + metadata = { + 'lti_id': 'correct_lti_id', + 'launch_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'], + } if fields.strip() == 'incorrect_lti_id': # incorrect fields - lti_id = 'incorrect_lti_id' + metadata.update({ + 'lti_id': 'incorrect_lti_id' + }) elif fields.strip() == 'correct': # correct fields pass elif fields.strip() == 'no_launch_url': - launch_url = u'' + metadata.update({ + 'launch_url': u'' + }) else: # incorrect parameter assert False - if new_page.strip().lower() == 'false': - new_page = False - else: # default is True - new_page = True + if _step.hashes: + metadata.update(_step.hashes[0]) world.scenario_dict['LTI'] = world.ItemFactory.create( parent_location=world.scenario_dict['SEQUENTIAL'].location, category=category, display_name='LTI', - metadata={ - 'lti_id': lti_id, - 'launch_url': launch_url, - 'open_in_a_new_page': new_page - } + metadata=metadata, ) + + setattr(world.scenario_dict['LTI'], 'TEST_BASE_PATH', '{host}:{port}'.format( + host=world.browser.host, + port=world.browser.port, + )) + course = world.scenario_dict["COURSE"] chapter_name = world.scenario_dict['SECTION'].display_name.replace( " ", "_") @@ -138,6 +144,20 @@ def create_course(course, metadata): # This also ensures that the necessary templates are loaded world.clear_courses() + weight = 0.1 + grading_policy = { + "GRADER": [ + { + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": weight + }, + ] + } + metadata.update(grading_policy) + # Create the course # We always use the same org and display name, # but vary the course identifier (e.g. 600x or 191x) @@ -145,18 +165,30 @@ def create_course(course, metadata): org='edx', number=course, display_name='Test Course', - metadata=metadata + metadata=metadata, + grading_policy={ + "GRADER": [ + { + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": weight + }, + ] + }, ) # Add a section to the course to contain problems world.scenario_dict['SECTION'] = world.ItemFactory.create( parent_location=world.scenario_dict['COURSE'].location, - display_name='Test Section' + display_name='Test Section', ) world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create( parent_location=world.scenario_dict['SECTION'].location, category='sequential', - display_name='Test Section') + display_name='Test Section', + metadata={'graded': True, 'format': 'Homework'}) def i_am_registered_for_the_course(course, metadata): @@ -170,6 +202,7 @@ def i_am_registered_for_the_course(course, metadata): # If the user is not already enrolled, enroll the user. CourseEnrollment.enroll(usr, course_id(course)) + world.add_to_course_staff('robot', world.scenario_dict['COURSE'].number) world.log_in(username='robot', password='test') @@ -196,3 +229,41 @@ def check_lti_popup(): world.browser.switch_to_window(parent_window) # Switch to the main window again +@step('I see text "([^"]*)"$') +def check_progress(_step, text): + assert world.browser.is_text_present(text) + + +@step('I see graph with total progress "([^"]*)"$') +def see_graph(_step, progress): + SELECTOR = 'grade-detail-graph' + node = world.browser.find_by_xpath('//div[@id="{parent}"]//div[text()="{progress}"]'.format( + parent=SELECTOR, + progress=progress, + )) + + assert node + + +@step('I see in the gradebook table that "([^"]*)" is "([^"]*)"$') +def see_value_in_the_gradebook(_step, label, text): + TABLE_SELECTOR = '.grade-table' + index = 0 + table_headers = world.css_find('{0} thead th'.format(TABLE_SELECTOR)) + + for i, element in enumerate(table_headers): + if element.text.strip() == label: + index = i + break; + + assert world.css_has_text('{0} tbody td'.format(TABLE_SELECTOR), text, index=index) + + +@step('I submit answer to LTI question$') +def click_grade(_step): + location = world.scenario_dict['LTI'].location.html_id() + iframe_name = 'ltiLaunchFrame-' + location + with world.browser.get_iframe(iframe_name) as iframe: + iframe.find_by_name('submit-button').first.click() + assert iframe.is_text_present('LTI consumer (edX) responded with XML content') + diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py index 0a6c4590dd..e86aa78ed2 100644 --- a/lms/djangoapps/courseware/features/lti_setup.py +++ b/lms/djangoapps/courseware/features/lti_setup.py @@ -30,6 +30,7 @@ def setup_mock_lti_server(): server_thread.daemon = True server_thread.start() + server.server_host = server_host server.oauth_settings = { 'client_key': 'test_client_key', 'client_secret': 'test_client_secret', diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py index 83208d0102..31637892b3 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -1,8 +1,16 @@ from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +from uuid import uuid4 +import textwrap import urlparse from oauthlib.oauth1.rfc5849 import signature +import oauthlib.oauth1 +import hashlib +import base64 import mock import sys +import requests +import textwrap + from logging import getLogger logger = getLogger(__name__) @@ -13,6 +21,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ''' protocol = "HTTP/1.0" + callback_url = None def log_message(self, format, *args): """Log an arbitrary message.""" @@ -23,24 +32,42 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): self.log_date_time_string(), format % args)) - def do_HEAD(self): - self._send_head() + def do_GET(self): + ''' + Handle a GET request from the client and sends response back. + ''' + + self.send_response(200, 'OK') + self.send_header('Content-type', 'html') + self.end_headers() + + response_str = """TEST TITLE + I have stored grades.""" + + self.wfile.write(response_str) + + self._send_graded_result() + + def do_POST(self): ''' Handle a POST request from the client and sends response back. ''' - self._send_head() - - post_dict = self._post_dict() # Retrieve the POST data + ''' logger.debug("LTI provider received POST request {} to path {}".format( - str(post_dict), + str(self.post_dict), self.path) ) # Log the request - - # Respond only to requests with correct lti endpoint: - if self._is_correct_lti_request(): + ''' + # Respond to grade request + if 'grade' in self.path and self._send_graded_result().status_code == 200: + status_message = 'LTI consumer (edX) responded with XML content:
' + self.server.grade_data['TC answer'] + self.server.grade_data['callback_url'] = None + # Respond to request with correct lti endpoint: + elif self._is_correct_lti_request(): + self.post_dict = self._post_dict() correct_keys = [ 'user_id', 'role', @@ -55,31 +82,41 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): 'oauth_callback', 'lis_outcome_service_url', 'lis_result_sourcedid', - 'launch_presentation_return_url' + 'launch_presentation_return_url', + # 'lis_person_sourcedid', optional, not used now. + 'resource_link_id', ] - - if sorted(correct_keys) != sorted(post_dict.keys()): + if sorted(correct_keys) != sorted(self.post_dict.keys()): status_message = "Incorrect LTI header" else: - params = {k: v for k, v in post_dict.items() if k != 'oauth_signature'} - if self.server.check_oauth_signature(params, post_dict['oauth_signature']): + params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'} + if self.server.check_oauth_signature(params, self.post_dict['oauth_signature']): status_message = "This is LTI tool. Success." else: status_message = "Wrong LTI signature" + # set data for grades + # what need to be stored as server data + self.server.grade_data = { + 'callback_url': self.post_dict["lis_outcome_service_url"], + 'sourcedId': self.post_dict['lis_result_sourcedid'] + } else: status_message = "Invalid request URL" + self._send_head() self._send_response(status_message) def _send_head(self): ''' Send the response code and MIME headers ''' + self.send_response(200) + ''' if self._is_correct_lti_request(): self.send_response(200) else: self.send_response(500) - + ''' self.send_header('Content-type', 'text/html') self.end_headers() @@ -100,18 +137,91 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): # the correct fields, it won't find them, # and will therefore send an error response return {} + try: + cookie = self.headers.getheader('cookie') + self.server.cookie = {k.strip(): v[0] for k, v in urlparse.parse_qs(cookie).items()} + except: + self.server.cookie = {} + referer = urlparse.urlparse(self.headers.getheader('referer')) + self.server.referer_host = "{}://{}".format(referer.scheme, referer.netloc) + self.server.referer_netloc = referer.netloc return post_dict + def _send_graded_result(self): + + values = { + 'textString': 0.5, + 'sourcedId': self.server.grade_data['sourcedId'], + 'imsx_messageIdentifier': uuid4().hex, + } + + payload = textwrap.dedent(""" + + + + + V1.0 + {imsx_messageIdentifier} / + + + + + + + {sourcedId} + + + + en-us + {textString} + + + + + + + """) + data = payload.format(**values) + # temporarily changed to get for easy view in browser + # get relative part, because host name is different in a) manual tests b) acceptance tests c) demos + relative_url = urlparse.urlparse(self.server.grade_data['callback_url']).path + url = self.server.referer_host + relative_url + + headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'} + + headers['Authorization'] = self.oauth_sign(url, data) + + response = requests.post( + url, + data=data, + headers=headers + ) + self.server.grade_data['TC answer'] = response.content + return response + def _send_response(self, message): ''' Send message back to the client ''' - response_str = """TEST TITLE - -

IFrame loaded

\ -

Server response is:

\ -

{}

- """.format(message) + + if self.server.grade_data['callback_url']: + response_str = """TEST TITLE + +

Graded IFrame loaded

\ +

Server response is:

\ +

{}

+
+ +
+ + """.format(message, url="http://%s:%s" % self.server.server_address) + else: + response_str = """TEST TITLE + +

IFrame loaded

\ +

Server response is:

\ +

{}

+ """.format(message) # Log the response logger.debug("LTI: sent response {}".format(response_str)) @@ -122,6 +232,34 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): '''If url to LTI tool is correct.''' return self.server.oauth_settings['lti_endpoint'] in self.path + def oauth_sign(self, url, body): + """ + Signs request and returns signed body and headers. + + """ + + client = oauthlib.oauth1.Client( + client_key=unicode(self.server.oauth_settings['client_key']), + client_secret=unicode(self.server.oauth_settings['client_secret']) + ) + headers = { + # This is needed for body encoding: + 'Content-Type': 'application/x-www-form-urlencoded', + } + + #Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html + sha1 = hashlib.sha1() + sha1.update(body) + oauth_body_hash = base64.b64encode(sha1.hexdigest()) + __, headers, __ = client.sign( + unicode(url.strip()), + http_method=u'POST', + body={u'oauth_body_hash': oauth_body_hash}, + headers=headers + ) + headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash) + return headers + class MockLTIServer(HTTPServer): ''' @@ -172,6 +310,5 @@ class MockLTIServer(HTTPServer): request.uri = unicode(url) request.http_method = u'POST' request.signature = unicode(client_signature) - return signature.verify_hmac_sha1(request, client_secret) diff --git a/lms/djangoapps/courseware/mock_lti_server/server_start.py b/lms/djangoapps/courseware/mock_lti_server/server_start.py new file mode 100644 index 0000000000..9f13e7982d --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/server_start.py @@ -0,0 +1,25 @@ +""" +Mock LTI server for manual testing. +""" + +import threading +from mock_lti_server import MockLTIServer + +server_port = 8034 +server_host = 'localhost' +address = (server_host, server_port) + +server = MockLTIServer(address) +server.oauth_settings = { + 'client_key': 'test_client_key', + 'client_secret': 'test_client_secret', + 'lti_base': 'http://{}:{}/'.format(server_host, server_port), + 'lti_endpoint': 'correct_lti_endpoint' +} +server.server_host = server_host + +try: + server.serve_forever() +except KeyboardInterrupt: + print('^C received, shutting down server') + server.socket.close() diff --git a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py index 99650d5faa..80dd3cc42f 100644 --- a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -1,12 +1,15 @@ """ Test for Mock_LTI_Server """ +import mock +from mock import Mock import unittest import threading +import textwrap import urllib +import requests from mock_lti_server import MockLTIServer -from nose.plugins.skip import SkipTest class MockLTIServerTest(unittest.TestCase): @@ -19,14 +22,9 @@ class MockLTIServerTest(unittest.TestCase): def setUp(self): - # This is a test of the test setup, - # so it does not need to run as part of the unit test suite - # You can re-enable it by commenting out the line below - # raise SkipTest - # Create the server server_port = 8034 - server_host = '127.0.0.1' + server_host = 'localhost' address = (server_host, server_port) self.server = MockLTIServer(address) self.server.oauth_settings = { @@ -45,12 +43,12 @@ class MockLTIServerTest(unittest.TestCase): # Stop the server, freeing up the port self.server.shutdown() - def test_request(self): + def test_wrong_signature(self): """ Tests that LTI server processes request with right program - path, and responses with incorrect signature. + path and responses with incorrect signature. """ - request = { + payload = { 'user_id': 'default_user_id', 'role': 'student', 'oauth_nonce': '', @@ -64,12 +62,46 @@ class MockLTIServerTest(unittest.TestCase): 'oauth_callback': 'about:blank', 'launch_presentation_return_url': '', 'lis_outcome_service_url': '', - 'lis_result_sourcedid': '' + 'lis_result_sourcedid': '', + 'resource_link_id':'', } - response_handle = urllib.urlopen( - self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'], - urllib.urlencode(request) - ) - response = response_handle.read() - self.assertTrue('Wrong LTI signature' in response) + uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'] + headers = {'referer': 'http://localhost:8000/'} + response = requests.post(uri, data=payload, headers=headers) + + self.assertTrue('Wrong LTI signature' in response.content) + + + def test_success_response_launch_lti(self): + """ + Success lti launch. + """ + + payload = { + 'user_id': 'default_user_id', + 'role': 'student', + 'oauth_nonce': '', + 'oauth_timestamp': '', + 'oauth_consumer_key': 'client_key', + 'lti_version': 'LTI-1p0', + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_version': '1.0', + 'oauth_signature': '', + 'lti_message_type': 'basic-lti-launch-request', + 'oauth_callback': 'about:blank', + 'launch_presentation_return_url': '', + 'lis_outcome_service_url': '', + 'lis_result_sourcedid': '', + 'resource_link_id':'', + "lis_outcome_service_url": '', + } + self.server.check_oauth_signature = Mock(return_value=True) + + uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'] + headers = {'referer': 'http://localhost:8000/'} + + response = requests.post(uri, data=payload, headers=headers) + + self.assertTrue('This is LTI tool. Success.' in response.content) + diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index f4bb129e3d..3b804c4ba9 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -24,7 +24,7 @@ from lms.lib.xblock.field_data import LmsFieldData from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes from mitxmako.shortcuts import render_to_string from psychometrics.psychoanalyze import make_psychometrics_data_update_handler -from student.models import unique_id_for_user +from student.models import anonymous_id_for_user, user_by_anonymous_id from util.json_request import JsonResponse from util.sandboxing import can_execute_unsafe_code from xblock.fields import Scope @@ -37,6 +37,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xblock +from xmodule.lti_module import LTIModule log = logging.getLogger(__name__) @@ -285,15 +286,20 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours position, wrap_xmodule_display, grade_bucket_type, static_asset_path) - def publish(event): + def publish(event, custom_user=None): """A function that allows XModules to publish events. This only supports grade changes right now.""" if event.get('event_name') != 'grade': return + if custom_user: + user_id = custom_user.id + else: + user_id = user.id + # Construct the key for the module key = KeyValueStore.Key( scope=Scope.user_state, - user_id=user.id, + user_id=user_id, block_scope_id=descriptor.location, field_name='grade' ) @@ -361,6 +367,17 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours if has_access(user, descriptor, 'staff', course_id): block_wrappers.append(partial(add_histogram, user)) + # These modules store data using the anonymous_student_id as a key. + # To prevent loss of data, we will continue to provide old modules with + # the per-student anonymized id (as we have in the past), + # while giving selected modules a per-course anonymized id. + # As we have the time to manually test more modules, we can add to the list + # of modules that get the per-course anonymized id. + if issubclass(getattr(descriptor, 'module_class', None), LTIModule): + anonymous_student_id = anonymous_id_for_user(user, course_id) + else: + anonymous_student_id = anonymous_id_for_user(user, '') + system = LmsModuleSystem( track_function=track_function, render_template=render_to_string, @@ -392,7 +409,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ), node_path=settings.NODE_PATH, publish=publish, - anonymous_student_id=unique_id_for_user(user), + anonymous_student_id=anonymous_student_id, course_id=course_id, open_ended_grading_interface=open_ended_grading_interface, s3_interface=s3_interface, @@ -401,6 +418,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours # TODO: When we merge the descriptor and module systems, we can stop reaching into the mixologist (cpennington) mixins=descriptor.runtime.mixologist._mixins, # pylint: disable=protected-access wrappers=block_wrappers, + get_real_user=user_by_anonymous_id, ) # pass position specified in URL to module through ModuleSystem diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti_integration.py similarity index 77% rename from lms/djangoapps/courseware/tests/test_lti.py rename to lms/djangoapps/courseware/tests/test_lti_integration.py index a176384fd1..9a70d217cc 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti_integration.py @@ -4,6 +4,9 @@ import oauthlib from . import BaseTestXmodule from collections import OrderedDict import mock +import urllib +from xmodule.lti_module import LTIModule +from mock import Mock class TestLTI(BaseTestXmodule): @@ -26,21 +29,33 @@ class TestLTI(BaseTestXmodule): mocked_signature_after_sign = u'my_signature%3D' mocked_decoded_signature = u'my_signature=' + lti_id = self.item_module.lti_id + module_id = unicode(urllib.quote(self.item_module.id)) + user_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id) + + sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id)) + + lis_outcome_service_url = 'http://{host}{path}'.format( + host=self.item_descriptor.xmodule_runtime.hostname, + path=self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'grade_handler', thirdparty=True).rstrip('/?') + ) self.correct_headers = { + u'user_id': user_id, u'oauth_callback': u'about:blank', - u'lis_outcome_service_url': '', - u'lis_result_sourcedid': '', u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', + u'role': u'student', + + u'resource_link_id': module_id, + u'lis_outcome_service_url': lis_outcome_service_url, + u'lis_result_sourcedid': sourcedId, u'oauth_nonce': mocked_nonce, u'oauth_timestamp': mocked_timestamp, u'oauth_consumer_key': u'', u'oauth_signature_method': u'HMAC-SHA1', u'oauth_version': u'1.0', - u'user_id': self.item_descriptor.xmodule_runtime.anonymous_student_id, - u'role': u'student', u'oauth_signature': mocked_decoded_signature } @@ -70,14 +85,16 @@ class TestLTI(BaseTestXmodule): Makes sure that all parameters extracted. """ generated_context = self.item_module.render('student_view').content + expected_context = { - 'input_fields': self.correct_headers, 'display_name': self.item_module.display_name, - 'element_class': self.item_module.location.category, + 'input_fields': self.correct_headers, + 'element_class': self.item_module.category, 'element_id': self.item_module.location.html_id(), 'launch_url': 'http://www.example.com', # default value 'open_in_a_new_page': True, } + self.assertEqual( generated_context, self.runtime.render_template('lti.html', expected_context), diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index a156b0aea3..649ad3c8ad 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -15,10 +15,11 @@ from django.test.utils import override_settings from xblock.field_data import FieldData from xblock.runtime import Runtime from xblock.fields import ScopeIds -from xmodule.modulestore.django import modulestore +from xmodule.lti_module import LTIDescriptor from xmodule.modulestore import Location -from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory +from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from xmodule.x_module import XModuleDescriptor import courseware.module_render as render from courseware.tests.tests import LoginEnrollmentTestCase @@ -521,7 +522,7 @@ class TestHtmlModifiers(ModuleStoreTestCase): result_fragment.content ) -PER_COURSE_ANONYMIZED_DESCRIPTORS = () +PER_COURSE_ANONYMIZED_DESCRIPTORS = (LTIDescriptor, ) PER_STUDENT_ANONYMIZED_DESCRIPTORS = [ class_ for (name, class_) in XModuleDescriptor.load_classes() diff --git a/rakelib/docs.rake b/rakelib/docs.rake index 1617efa8af..09ecac0efb 100644 --- a/rakelib/docs.rake +++ b/rakelib/docs.rake @@ -32,7 +32,7 @@ task :showdocs, [:options] do |t, args| elsif args.options == 'data' path = "docs/data" else - path = "docs" + path = "docs/developers" end Launchy.open("#{path}/build/html/index.html")