From 417ddcaaa172fd9b8c007a36b07914767ee690d5 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 19 Aug 2013 17:25:13 +0300 Subject: [PATCH 01/50] LTI module with tests --- .../contentstore/views/component.py | 3 +- common/lib/xmodule/setup.py | 1 + common/lib/xmodule/xmodule/css/lti/lti.scss | 7 ++ .../lib/xmodule/xmodule/js/fixtures/lti.html | 44 +++++++ .../xmodule/js/spec/lti/constructor.js | 111 ++++++++++++++++++ common/lib/xmodule/xmodule/js/src/lti/lti.js | 49 ++++++++ common/lib/xmodule/xmodule/lti_module.py | 93 +++++++++++++++ lms/djangoapps/courseware/tests/test_lti.py | 54 +++++++++ lms/templates/lti.html | 48 ++++++++ 9 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/css/lti/lti.scss create mode 100644 common/lib/xmodule/xmodule/js/fixtures/lti.html create mode 100644 common/lib/xmodule/xmodule/js/spec/lti/constructor.js create mode 100644 common/lib/xmodule/xmodule/js/src/lti/lti.js create mode 100644 common/lib/xmodule/xmodule/lti_module.py create mode 100644 lms/djangoapps/courseware/tests/test_lti.py create mode 100644 lms/templates/lti.html diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 724dc439d9..9e45e14102 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -52,7 +52,8 @@ NOTE_COMPONENT_TYPES = ['notes'] ADVANCED_COMPONENT_TYPES = [ 'annotatable', 'word_cloud', - 'graphical_slider_tool' + 'graphical_slider_tool', + 'lti' ] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 704de15ea7..6a24bf8f27 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -56,6 +56,7 @@ setup( "hidden = xmodule.hidden_module:HiddenDescriptor", "raw = xmodule.raw_module:RawDescriptor", "crowdsource_hinter = xmodule.crowdsource_hinter:CrowdsourceHinterDescriptor", + "lti = xmodule.lti_module:LTIModuleDescriptor" ], 'console_scripts': [ 'xmodule_assets = xmodule.static_content:main', diff --git a/common/lib/xmodule/xmodule/css/lti/lti.scss b/common/lib/xmodule/xmodule/css/lti/lti.scss new file mode 100644 index 0000000000..dcbde33e59 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/lti/lti.scss @@ -0,0 +1,7 @@ +div.lti { + h2.error_message { + &.hidden { + display: none; + } + } +} diff --git a/common/lib/xmodule/xmodule/js/fixtures/lti.html b/common/lib/xmodule/xmodule/js/fixtures/lti.html new file mode 100644 index 0000000000..b6db1b5784 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/lti.html @@ -0,0 +1,44 @@ +
+ +
+ + + + + + + + + + + + + + + + + +
+ + + + + +
diff --git a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js new file mode 100644 index 0000000000..ea651b8939 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js @@ -0,0 +1,111 @@ +/* + * "Hence that general is skilful in attack whose opponent does not know what + * to defend; and he is skilful in defense whose opponent does not know what + * to attack." + * + * ~ Sun Tzu + */ + +(function () { + describe('LTI', function () { + var documentReady = false, + element, errorMessage, frame, + editSettings = false; + + // This function will be executed before each of the it() specs + // in this suite. + beforeEach(function () { + $(document).ready(function () { + documentReady = true; + }); + + spyOn(LTI, 'init').andCallThrough(); + spyOn($.fn, 'submit').andCallThrough(); + + loadFixtures('lti.html'); + + element = $('#lti_id'); + errorMessage = element.find('h2.error_message'); + form = element.find('form#ltiLaunchForm'); + frame = element.find('iframe#ltiLaunchFrame'); + + // First part of the tests will be running without the settings + // filled in. Once we reach the describe() spec + // + // "After the settings were filled in" + // + // the variable `editSettings` will be changed to `true`. + if (editSettings) { + form.attr('action', 'http://google.com/'); + } + + LTI(element); + }); + + describe('constructor', function () { + it('init() is called after document is ready', function () { + waitsFor( + function () { + return documentReady; + }, + 'The document is ready', + 1000 + ); + + runs(function () { + expect(LTI.init).toHaveBeenCalled(); + }); + }); + + describe('before settings were filled in', function () { + it('init() is called with element', function () { + expect(LTI.init).toHaveBeenCalledWith(element); + }); + + it( + 'when URL setting is empty error message is shown', + function () { + + expect(errorMessage).not.toHaveClass('hidden'); + }); + + it('when URL setting is empty iframe is hidden', function () { + expect(frame.css('display')).toBe('none'); + }); + }); + + describe('After the settings were filled in', function () { + it('editSettings is disabled', function () { + expect(editSettings).toBe(false); + + // Let us toggle edit settings switch. Next beforeEach() + // will populate element's attributes with settings. + editSettings = true; + }); + + it('when URL setting is filled form is submited', function () { + expect($.fn.submit).toHaveBeenCalled(); + }); + + it( + 'when URL setting is filled error message is hidden', + function () { + + expect(errorMessage).toHaveClass('hidden'); + }); + + it('when URL setting is filled iframe is shown', function () { + expect(frame.css('display')).not.toBe('none'); + }); + + it( + 'when URL setting is filled iframe is resized', + function () { + + expect(frame.width()).toBe(form.parent().width()); + expect(frame.height()).toBe(800); + }); + }); + }); + }); +}()); diff --git a/common/lib/xmodule/xmodule/js/src/lti/lti.js b/common/lib/xmodule/xmodule/js/src/lti/lti.js new file mode 100644 index 0000000000..46c54ec227 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/lti/lti.js @@ -0,0 +1,49 @@ +window.LTI = (function () { + var LTI; + + // Function LTI() + // + // The LTI module constructor. It will be called by XModule for any + // LTI module DIV that is found on the page. + LTI = function (element) { + $(document).ready(function () { + LTI.init(element); + }); + } + + // Function init() + // + // Initialize the LTI iframe. + LTI.init = function (element) { + var form, frame; + + // In cms (Studio) the element is already a jQuery object. In lms it is + // a DOM object. + // + // To make sure that there is no error, we pass it through the $() + // function. This will make it a jQuery object if it isn't already so. + element = $(element); + + form = element.find('#ltiLaunchForm'); + frame = element.find('#ltiLaunchFrame'); + + // If the Form's action attribute is set (i.e. we can perform a normal + // submit), then we submit the form and make it big enough so that the + // received response can fit in it. Hide the error message, if shown. + if (form.attr('action')) { + form.submit(); + + element.find('h2.error_message').addClass('hidden'); + frame.show(); + frame.width('100%').height(800); + } + + // If no action URL was specified, we show an error message. + else { + frame.hide(); + element.find('h2.error_message').removeClass('hidden'); + } + } + + return LTI; +}()); diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py new file mode 100644 index 0000000000..ee1da3d50a --- /dev/null +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -0,0 +1,93 @@ +""" +Module that allows to insert LTI tools to page. +""" + +import logging +import requests +import urllib + +from xmodule.editing_module import MetadataOnlyEditingDescriptor +from xmodule.x_module import XModule +from pkg_resources import resource_string +from xblock.core import String, Scope + +log = logging.getLogger(__name__) + + +class LTIFields(object): + """provider_url and tool_id together is unique location of LTI in the web. + + Scope settings should be scope content: + + Cale: There is no difference in presentation to the user yet because + there is no sharing between courses. However, when we get to the point of being + able to have multiple courses using the same content, + then the distinction between Scope.settings (local to the current course), + and Scope.content (shared across all uses of this content in any course) + becomes much more clear/necessary. + """ + client_key = String(help="Client key", default='', scope=Scope.settings) + client_secret = String(help="Client secret", default='', scope=Scope.settings) + lti_url = String(help="URL of the tool", default='', scope=Scope.settings) + + +class LTIModule(LTIFields, XModule): + '''LTI Module''' + + js = {'js': [resource_string(__name__, 'js/src/lti/lti.js')]} + css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]} + js_module_name = "LTI" + + def get_html(self): + """ Renders parameters to template. """ + params = { + 'lti_url': self.lti_url, + 'element_id': self.location.html_id(), + 'element_class': self.location.category, + } + params.update(self.oauth_params()) + return self.system.render_template('lti.html', params) + + def oauth_params(self): + """Obtains LTI html from provider""" + client = requests.auth.Client( + client_key=unicode(self.client_key), + client_secret=unicode(self.client_secret) + ) + # must have parameters for correct signing from LTI: + body = { + 'user_id': 'default_user_id', + 'oauth_callback': 'about:blank', + 'lis_outcome_service_url': '', + 'lis_result_sourcedid': '', + 'launch_presentation_return_url': '', + 'lti_message_type': 'basic-lti-launch-request', + 'lti_version': 'LTI-1p0', + } + # This is needed for body encoding: + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + _, headers, _ = client.sign( + unicode(self.lti_url), + http_method=u'POST', + body=body, + headers=headers) + params = headers['Authorization'] + # import ipdb; ipdb.set_trace() + # 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'] + del params[u'OAuth oauth_nonce'] + + # 0.14.2 (current) version of requests oauth library encodes signature, + # with 'Content-Type': 'application/x-www-form-urlencoded' + # so '='' becomes '%3D', but server waits for unencoded signature. + # Decode signature back: + params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8') + return params + + +class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor): + """LTI Descriptor. No export/import to html.""" + module_class = LTIModule diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py new file mode 100644 index 0000000000..9c97d16ff7 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -0,0 +1,54 @@ +"""LTI test""" + +import requests +from . import BaseTestXmodule + + +class TestLTI(BaseTestXmodule): + """Integration test for word cloud xmodule.""" + CATEGORY = "lti" + + def setUp(self): + super(TestLTI, self).setUp() + mocked_noonce = u'135685044251684026041377608307' + mocked_timestamp = u'1234567890' + mocked_signed_signature = u'my_signature%3D' + mocked_decoded_signature = u'my_signature=' + + self.correct_headers = { + u'oauth_nonce': mocked_noonce, + u'oauth_timestamp': mocked_timestamp, + u'oauth_consumer_key': u'', + u'oauth_signature_method': u'HMAC-SHA1', + u'oauth_version': u'1.0', + u'oauth_signature': mocked_decoded_signature} + + saved_sign = requests.auth.Client.sign + + def mocked_sign(self, *args, **kwargs): + """Mocked oauth1 sign function""" + # self is here: + _, headers, _ = saved_sign(self, *args, **kwargs) + # we should replace noonce, timestamp and signed_signature in headers: + old = headers[u'Authorization'] + new = old[:19] + mocked_noonce + old[49:69] + mocked_timestamp + \ + old[79:179] + mocked_signed_signature + old[-1] + headers[u'Authorization'] = new + return None, headers, None + + requests.auth.Client.sign = mocked_sign + + def test_lti_constructor(self): + """Make sure that all parameters extracted """ + fragment = self.runtime.render(self.item_module, None, 'student_view') + expected_context = { + 'element_class': self.item_module.location.category, + 'element_id': self.item_module.location.html_id(), + 'lti_url': '', # default value + } + self.correct_headers.update(expected_context) + # import ipdb; ipdb.set_trace() + self.assertEqual( + fragment.content, + self.runtime.render_template('lti.html', self.correct_headers) + ) diff --git a/lms/templates/lti.html b/lms/templates/lti.html new file mode 100644 index 0000000000..60b0672719 --- /dev/null +++ b/lms/templates/lti.html @@ -0,0 +1,48 @@ +
+ + ## This form will be hidden. Once available on the client, the LTI + ## module JavaScript will trigget a "submit" on the form, and the + ## result will be rendered to the below iFrame. +
+ + + + + + + + + + + + + + + + + +
+ + + + ## The result of the form submit will be rendered here. + + +
From e4e7a4e30beb4a1bb9648a473182a913fc220671 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Tue, 27 Aug 2013 17:49:35 +0300 Subject: [PATCH 02/50] Remove ipdb. --- common/lib/xmodule/xmodule/lti_module.py | 1 - lms/djangoapps/courseware/tests/test_lti.py | 1 - 2 files changed, 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index ee1da3d50a..fa9624dde7 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -73,7 +73,6 @@ class LTIModule(LTIFields, XModule): body=body, headers=headers) params = headers['Authorization'] - # import ipdb; ipdb.set_trace() # parse headers to pass to template as part of context: params = dict([param.strip().replace('"', '').split('=') for param in params.split('",')]) diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index 9c97d16ff7..86419300ad 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -47,7 +47,6 @@ class TestLTI(BaseTestXmodule): 'lti_url': '', # default value } self.correct_headers.update(expected_context) - # import ipdb; ipdb.set_trace() self.assertEqual( fragment.content, self.runtime.render_template('lti.html', self.correct_headers) From c8ea4da2cc18419776d3e5b235f6b2e5588e7a88 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Tue, 27 Aug 2013 18:01:16 +0300 Subject: [PATCH 03/50] Change h2 to h3 --- common/lib/xmodule/xmodule/js/fixtures/lti.html | 4 ++-- common/lib/xmodule/xmodule/js/spec/lti/constructor.js | 2 +- common/lib/xmodule/xmodule/js/src/lti/lti.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/fixtures/lti.html b/common/lib/xmodule/xmodule/js/fixtures/lti.html index b6db1b5784..1e3439f412 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/lti.html +++ b/common/lib/xmodule/xmodule/js/fixtures/lti.html @@ -27,10 +27,10 @@ - + diff --git a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js index 203d79f9bd..d6e56216b3 100644 --- a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js +++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js @@ -8,26 +8,20 @@ (function () { describe('LTI', function () { - var documentReady = false, - element, errorMessage, frame, + var element, errorMessage, frame, editSettings = false; // This function will be executed before each of the it() specs // in this suite. beforeEach(function () { - $(document).ready(function () { - documentReady = true; - }); - - spyOn(LTI, 'init').andCallThrough(); spyOn($.fn, 'submit').andCallThrough(); loadFixtures('lti.html'); element = $('#lti_id'); - errorMessage = element.find('error_message'); - form = element.find('form#ltiLaunchForm'); - frame = element.find('iframe#ltiLaunchFrame'); + errorMessage = element.find('.error_message'); + form = element.find('.ltiLaunchForm'); + frame = element.find('.ltiLaunchFrame'); // First part of the tests will be running without the settings // filled in. Once we reach the describe() spec @@ -36,32 +30,14 @@ // // the variable `editSettings` will be changed to `true`. if (editSettings) { - form.attr('action', 'http://google.com/'); + form.attr('action', 'http://www.example.com/'); } LTI(element); }); describe('constructor', function () { - it('init() is called after document is ready', function () { - waitsFor( - function () { - return documentReady; - }, - 'The document is ready', - 1000 - ); - - runs(function () { - expect(LTI.init).toHaveBeenCalled(); - }); - }); - describe('before settings were filled in', function () { - it('init() is called with element', function () { - expect(LTI.init).toHaveBeenCalledWith(element); - }); - it( 'when URL setting is empty error message is shown', function () { @@ -70,7 +46,7 @@ }); it('when URL setting is empty iframe is hidden', function () { - expect(frame.css('display')).toBe('none'); + expect(frame).toHaveClass('hidden'); }); }); @@ -95,15 +71,7 @@ }); it('when URL setting is filled iframe is shown', function () { - expect(frame.css('display')).not.toBe('none'); - }); - - it( - 'when URL setting is filled iframe is resized', - function () { - - expect(frame.width()).toBe(form.parent().width()); - expect(frame.height()).toBe(800); + expect(frame).not.toHaveClass('hidden'); }); }); }); diff --git a/common/lib/xmodule/xmodule/js/src/lti/lti.js b/common/lib/xmodule/xmodule/js/src/lti/lti.js index 64a757eb28..e5b6885e1b 100644 --- a/common/lib/xmodule/xmodule/js/src/lti/lti.js +++ b/common/lib/xmodule/xmodule/js/src/lti/lti.js @@ -1,21 +1,9 @@ window.LTI = (function () { - var LTI; - - // Function LTI() - // - // The LTI module constructor. It will be called by XModule for any - // LTI module DIV that is found on the page. - LTI = function (element) { - $(document).ready(function () { - LTI.init(element); - }); - } - - // Function init() + // Function initialize(element) // // Initialize the LTI iframe. - LTI.init = function (element) { - var form, frame; + function initialize(element) { + var form; // In cms (Studio) the element is already a jQuery object. In lms it is // a DOM object. @@ -24,26 +12,15 @@ window.LTI = (function () { // function. This will make it a jQuery object if it isn't already so. element = $(element); - form = element.find('#ltiLaunchForm'); - frame = element.find('#ltiLaunchFrame'); + form = element.find('.ltiLaunchForm'); // If the Form's action attribute is set (i.e. we can perform a normal - // submit), then we submit the form and make it big enough so that the - // received response can fit in it. Hide the error message, if shown. + // submit), then we submit the form and make the frame shown. if (form.attr('action')) { form.submit(); - - element.find('.error_message').addClass('hidden'); - frame.show(); - frame.width('100%').height(800); - } - - // If no action URL was specified, we show an error message. - else { - frame.hide(); - element.find('.error_message').removeClass('hidden'); + element.find('.lti').addClass('rendered') } } - return LTI; + return initialize; }()); diff --git a/lms/templates/lti.html b/lms/templates/lti.html index 60b0672719..d1f0c1b236 100644 --- a/lms/templates/lti.html +++ b/lms/templates/lti.html @@ -1,4 +1,4 @@ -
+
## This form will be hidden. Once available on the client, the LTI ## module JavaScript will trigget a "submit" on the form, and the @@ -6,31 +6,29 @@
+ + + + + + + + + + + + + - - - - - - - - - - - - - - - - +
-

Please provide LTI url. Click "Edit", and fill in the required fields.

@@ -38,11 +36,8 @@ ## The result of the form submit will be rendered here.
From e4bad0a6a113a01d43684c14ab6eaff7d5aa7505 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Thu, 29 Aug 2013 11:29:46 +0300 Subject: [PATCH 05/50] Fixing Jasmine tests. --- .../xmodule/js/spec/lti/constructor.js | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js index d6e56216b3..bea11bec9c 100644 --- a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js +++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js @@ -39,14 +39,10 @@ describe('constructor', function () { describe('before settings were filled in', function () { it( - 'when URL setting is empty error message is shown', + 'when URL setting is filled form is not submited', function () { - expect(errorMessage).not.toHaveClass('hidden'); - }); - - it('when URL setting is empty iframe is hidden', function () { - expect(frame).toHaveClass('hidden'); + expect($.fn.submit).not.toHaveBeenCalled(); }); }); @@ -62,17 +58,6 @@ it('when URL setting is filled form is submited', function () { expect($.fn.submit).toHaveBeenCalled(); }); - - it( - 'when URL setting is filled error message is hidden', - function () { - - expect(errorMessage).toHaveClass('hidden'); - }); - - it('when URL setting is filled iframe is shown', function () { - expect(frame).not.toHaveClass('hidden'); - }); }); }); }); From b5dc03ec36945ea90270db97ed086ed877bb72cc Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 29 Aug 2013 19:13:27 +0300 Subject: [PATCH 06/50] Acceptance test for LTI module (not finished), but working --- common/lib/xmodule/xmodule/lti_module.py | 1 + .../courseware/features/lti.feature | 10 ++ lms/djangoapps/courseware/features/lti.py | 98 +++++++++++ .../courseware/features/lti_setup.py | 40 +++++ .../courseware/mock_lti_server/__init__.py | 0 .../mock_lti_server/mock_lti_server.py | 158 ++++++++++++++++++ .../mock_lti_server/test_mock_lti_server.py | 72 ++++++++ 7 files changed, 379 insertions(+) create mode 100644 lms/djangoapps/courseware/features/lti.feature create mode 100644 lms/djangoapps/courseware/features/lti.py create mode 100644 lms/djangoapps/courseware/features/lti_setup.py create mode 100644 lms/djangoapps/courseware/mock_lti_server/__init__.py create mode 100644 lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py create mode 100644 lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index fa9624dde7..cb25fc1504 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -83,6 +83,7 @@ class LTIModule(LTIFields, XModule): # with 'Content-Type': 'application/x-www-form-urlencoded' # so '='' becomes '%3D', but server waits for unencoded signature. # Decode signature back: + # may be it may be encoded by browser again... check params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8') return params diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature new file mode 100644 index 0000000000..e17144f545 --- /dev/null +++ b/lms/djangoapps/courseware/features/lti.feature @@ -0,0 +1,10 @@ +Feature: LTI component + As a student, I want to view LTI component in LMS. + + Scenario: LTI component in LMS is not rendered + Given the course has a LTI component with empty fields + Then I view the LTI and it is not rendered + + Scenario: LTI component in LMS is rendered + Given the course has a LTI component filled with correct data + Then I view the LTI and it is rendered diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py new file mode 100644 index 0000000000..da6d14e759 --- /dev/null +++ b/lms/djangoapps/courseware/features/lti.py @@ -0,0 +1,98 @@ +#pylint: disable=C0111 + +from lettuce import world, step +from lettuce.django import django_url +from common import i_am_registered_for_the_course, section_location + + +@step('I view the LTI and it is not rendered') +def lti_is_not_rendered(_step): + # lti div has no class rendered + assert world.is_css_not_present('div.lti.rendered') + + # error is shown + assert world.css_visible('.error_message') + + # iframe is not visible + assert (not world.css_visible('iframe')) + + #inside iframe test content is not presented + with world.browser.get_iframe('ltiLaunchFrame') as iframe: + # iframe does not contain functions from terrain/ui_helpers.py + assert iframe.is_element_not_present_by_css('.result', wait_time=5) + + +@step('I view the LTI and it is rendered') +def lti_is_rendered(_step): + # lti div has class rendered + assert world.is_css_present('div.lti.rendered') + + # error is hidden + assert (not world.css_visible('.error_message')) + + # iframe is visible + assert world.css_visible('iframe') + + #inside iframe test content is presented + with world.browser.get_iframe('ltiLaunchFrame') as iframe: + # iframe does not contain functions from terrain/ui_helpers.py + assert iframe.is_element_present_by_css('.result', wait_time=5) + + +@step('the course has a LTI component filled with correct data') +def view_lti_with_data(_step): + coursenum = 'test_course' + i_am_registered_for_the_course(_step, coursenum) + + add_correct_lti_to_course(coursenum) + chapter_name = world.scenario_dict['SECTION'].display_name.replace( + " ", "_") + section_name = chapter_name + url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( + world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'), + chapter_name, section_name,) + ) + world.browser.visit(url) + + +@step('the course has a LTI component with empty fields') +def view_default_lti(_step): + coursenum = 'test_course' + i_am_registered_for_the_course(_step, coursenum) + + add_default_lti_to_course(coursenum) + chapter_name = world.scenario_dict['SECTION'].display_name.replace( + " ", "_") + section_name = chapter_name + url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( + world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'), + chapter_name, section_name,) + ) + world.browser.visit(url) + + +def add_correct_lti_to_course(course): + category = 'lti' + world.ItemFactory.create( + parent_location=section_location(course), + category=category, + display_name='LTI', + metadata={ + 'client_key': 'client_key', + 'clent_secret': 'client_secret', + 'lti_url': 'http://127.0.0.1:{}/correct_lti_endpoint'.format(world.lti_server_port) + } + ) + + +def add_default_lti_to_course(course): + category = 'lti' + world.ItemFactory.create( + parent_location=section_location(course), + category=category, + display_name='LTI' + ) diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py new file mode 100644 index 0000000000..e3cf7b2c7c --- /dev/null +++ b/lms/djangoapps/courseware/features/lti_setup.py @@ -0,0 +1,40 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from courseware.mock_lti_server.mock_lti_server import MockLTIServer +from lettuce import before, after, world +from django.conf import settings +import threading + +from logging import getLogger +logger = getLogger(__name__) + + +@before.all +def setup_mock_lti_server(): + + # Add +1 to XQUEUE random port number + server_port = settings.XQUEUE_PORT + 1 + + # Create the mock server instance + server = MockLTIServer(server_port) + logger.debug("LTI server started at {} port".format(str(server_port))) + # Start the server running in a separate daemon thread + # Because the thread is a daemon, it will terminate + # when the main thread terminates. + server_thread = threading.Thread(target=server.serve_forever) + server_thread.daemon = True + server_thread.start() + + # Store the server instance in lettuce's world + # so that other steps can access it + # (and we can shut it down later) + world.lti_server = server + world.lti_server_port = server_port + + +@after.all +def teardown_mock_lti_server(total): + + # Stop the LTI server and free up the port + world.lti_server.shutdown() diff --git a/lms/djangoapps/courseware/mock_lti_server/__init__.py b/lms/djangoapps/courseware/mock_lti_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py new file mode 100644 index 0000000000..8fb1cf4393 --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -0,0 +1,158 @@ +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +import json +import urllib +import urlparse +import threading + +from logging import getLogger +logger = getLogger(__name__) + + +# todo - implement oauth + +class MockLTIRequestHandler(BaseHTTPRequestHandler): + ''' + A handler for LTI POST requests. + ''' + + protocol = "HTTP/1.0" + + def do_HEAD(self): + self._send_head() + + 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 + + # Log the request + logger.debug("LTI provider received POST request {} to path {}".format( + str(post_dict), + self.path) + ) + # Respond only to requests with correct lti endpoint: + if self._is_correct_lti_request(): + correct_dict = { + 'user_id': 'default_user_id', + 'oauth_nonce': '22990037033121997701377766132', + 'oauth_timestamp': '1377766132', + 'oauth_consumer_key': 'client_key', + 'lti_version': 'LTI-1p0', + 'oauth_signature_method': 'HMAC-SHA1', + 'oauth_version': '1.0', + 'oauth_signature': 'HGYMAU/G5EMxd0CDOvWubsqxLIY=', + 'lti_message_type': 'basic-lti-launch-request', + 'oauth_callback': 'about:blank' + } + + if sorted(correct_dict.keys()) != sorted(post_dict.keys()): + error_message = "Incorrect LTI header" + else: + error_message = "This is LTI tool." + else: + error_message = "Invalid request URL" + + self._send_response(error_message) + + def _send_head(self): + ''' + Send the response code and MIME headers + ''' + 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() + + def _post_dict(self): + ''' + Retrieve the POST parameters from the client as a dictionary + ''' + try: + length = int(self.headers.getheader('content-length')) + post_dict = urlparse.parse_qs(self.rfile.read(length)) + # The POST dict will contain a list of values for each key. + # None of our parameters are lists, however, so we map [val] --> val. + #I f the list contains multiple entries, we pick the first one + post_dict = dict( + map( + lambda (key, list_val): (key, list_val[0]), + post_dict.items() + ) + ) + except: + # We return an empty dict here, on the assumption + # that when we later check that the request has + # the correct fields, it won't find them, + # and will therefore send an error response + return {} + return post_dict + + def _send_response(self, message): + ''' + Send message back to the client + ''' + response_str = """TEST TITLE + +

IFrame loaded

\ +

Server response is:

\ +

{}

+ """.format(message) + + # Log the response + logger.debug("LTI: sent response {}".format(response_str)) + + self.wfile.write(response_str) + + def _is_correct_lti_request(self): + '''If url to get LTI is correct.''' + return 'correct_lti_endpoint' in self.path + + +class MockLTIServer(HTTPServer): + ''' + A mock LTI provider server that responds + to POST requests to localhost. + ''' + + def __init__(self, port_num, oauth={}): + ''' + Initialize the mock XQueue server instance. + + *port_num* is the localhost port to listen to + + *grade_response_dict* is a dictionary that will be JSON-serialized + and sent in response to XQueue grading requests. + ''' + + self.clent_key = oauth.get('client_key', '') + self.clent_secret = oauth.get('client_secret', '') + self.check_oauth() + + handler = MockLTIRequestHandler + address = ('', port_num) + HTTPServer.__init__(self, address, handler) + + def shutdown(self): + ''' + Stop the server and free up the port + ''' + # First call superclass shutdown() + HTTPServer.shutdown(self) + + # We also need to manually close the socket + self.socket.close() + + def get_oauth_signature(self): + '''test''' + return self._signature + + def check_oauth(self): + ''' generate oauth signature ''' + self._signature = '12345' + 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 new file mode 100644 index 0000000000..8d556286c3 --- /dev/null +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -0,0 +1,72 @@ +import mock +import unittest +import threading +import json +import urllib +import time +from mock_lti_server import MockLTIServer, MockLTIRequestHandler + +from nose.plugins.skip import SkipTest + + +class MockLTIServerTest(unittest.TestCase): + ''' + A mock version of the LTI provider server that listens on a local + port and responds with pre-defined grade messages. + + Used for lettuce BDD tests in lms/courseware/features/lti.feature + ''' + + 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 + self.server_url = 'http://127.0.0.1:%d' % server_port + self.server = MockLTIServer(server_port, {'client_key': '', 'client_secret': ''}) + + # Start the server in a separate daemon thread + server_thread = threading.Thread(target=self.server.serve_forever) + server_thread.daemon = True + server_thread.start() + + def tearDown(self): + + # Stop the server, freeing up the port + self.server.shutdown() + + def test_oauth_request(self): + + # Send a grade request + header = { + 'Content-Type': 'application/x-www-form-urlencoded', + u'Authorization': u'OAuth oauth_nonce="151177408427657509491377691584", \ +oauth_timestamp="1377691584", oauth_version="1.0", \ +oauth_signature_method="HMAC-SHA1", oauth_consumer_key="", \ +oauth_signature="wc1unKXxsX5e4HXJu%2FuiQ1KbrVo%3D"', + 'launch_presentation_return_url': '', + 'user_id': 'default_user_id', + 'lis_result_sourcedid': '', + 'lti_version': 'LTI-1p0', + 'lis_outcome_service_url': '', + 'lti_message_type': 'basic-lti-launch-request', + 'oauth_callback': 'about:blank' + } + body = {} + request = { + 'header': json.dumps(header), + 'body': json.dumps(body)} + response_handle = urllib.urlopen( + self.server_url + '/correct_lti_endpoint', + urllib.urlencode(request) + ) + + response_dict = json.loads(response_handle.read()) + # Expect that the response is success + self.assertEqual(response_dict['return_code'], 0) + # self.assertEqual(response_dict['return_code'], 0) + From 04294b04df3eba7433d2323e65a653ac5700a27a Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 30 Aug 2013 15:46:25 +0300 Subject: [PATCH 07/50] Adds oauth checking to acceptance tests --- .../mock_lti_server/mock_lti_server.py | 105 ++++++++++-------- 1 file changed, 59 insertions(+), 46 deletions(-) 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 8fb1cf4393..2eb2d2e139 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -1,15 +1,11 @@ from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler -import json -import urllib import urlparse -import threading - +from requests.packages.oauthlib.oauth1.rfc5849 import signature +import mock from logging import getLogger logger = getLogger(__name__) -# todo - implement oauth - class MockLTIRequestHandler(BaseHTTPRequestHandler): ''' A handler for LTI POST requests. @@ -28,34 +24,41 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): post_dict = self._post_dict() # Retrieve the POST data - # Log the request logger.debug("LTI provider received POST request {} to path {}".format( str(post_dict), self.path) - ) + ) # Log the request + # Respond only to requests with correct lti endpoint: if self._is_correct_lti_request(): - correct_dict = { - 'user_id': 'default_user_id', - 'oauth_nonce': '22990037033121997701377766132', - 'oauth_timestamp': '1377766132', - 'oauth_consumer_key': 'client_key', - 'lti_version': 'LTI-1p0', - 'oauth_signature_method': 'HMAC-SHA1', - 'oauth_version': '1.0', - 'oauth_signature': 'HGYMAU/G5EMxd0CDOvWubsqxLIY=', - 'lti_message_type': 'basic-lti-launch-request', - 'oauth_callback': 'about:blank' - } + correct_keys = [ + 'user_id', + 'oauth_nonce', + 'oauth_timestamp', + 'oauth_consumer_key', + 'lti_version', + 'oauth_signature_method', + 'oauth_version', + 'oauth_signature', + 'lti_message_type', + 'oauth_callback', + 'lis_outcome_service_url', + 'lis_result_sourcedid', + 'launch_presentation_return_url' + ] - if sorted(correct_dict.keys()) != sorted(post_dict.keys()): - error_message = "Incorrect LTI header" + if sorted(correct_keys) != sorted(post_dict.keys()): + status_message = "Incorrect LTI header" else: - error_message = "This is LTI tool." + 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']): + status_message = "This is LTI tool." + else: + status_message = "Wrong LTI signature" else: - error_message = "Invalid request URL" + status_message = "Invalid request URL" - self._send_response(error_message) + self._send_response(status_message) def _send_head(self): ''' @@ -75,7 +78,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): ''' try: length = int(self.headers.getheader('content-length')) - post_dict = urlparse.parse_qs(self.rfile.read(length)) + post_dict = urlparse.parse_qs(self.rfile.read(length), keep_blank_values=True) # The POST dict will contain a list of values for each key. # None of our parameters are lists, however, so we map [val] --> val. #I f the list contains multiple entries, we pick the first one @@ -110,8 +113,8 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): self.wfile.write(response_str) def _is_correct_lti_request(self): - '''If url to get LTI is correct.''' - return 'correct_lti_endpoint' in self.path + '''If url to LTI tool is correct.''' + return self.server.oauth_settings['lti_endpoint'] in self.path class MockLTIServer(HTTPServer): @@ -120,22 +123,13 @@ class MockLTIServer(HTTPServer): to POST requests to localhost. ''' - def __init__(self, port_num, oauth={}): + def __init__(self, address): ''' Initialize the mock XQueue server instance. - *port_num* is the localhost port to listen to - - *grade_response_dict* is a dictionary that will be JSON-serialized - and sent in response to XQueue grading requests. + *address* is the (host, host's port to listen to) tuple. ''' - - self.clent_key = oauth.get('client_key', '') - self.clent_secret = oauth.get('client_secret', '') - self.check_oauth() - handler = MockLTIRequestHandler - address = ('', port_num) HTTPServer.__init__(self, address, handler) def shutdown(self): @@ -144,15 +138,34 @@ class MockLTIServer(HTTPServer): ''' # First call superclass shutdown() HTTPServer.shutdown(self) - # We also need to manually close the socket self.socket.close() - def get_oauth_signature(self): - '''test''' - return self._signature + def check_oauth_signature(self, params, client_signature): + ''' + Checks oauth signature from client. - def check_oauth(self): - ''' generate oauth signature ''' - self._signature = '12345' + `params` are params from post request except signature, + `client_signature` is signature from request. + + Builds mocked request and verifies hmac-sha1 signing:: + 1. builds string to sign from `params`, `url` and `http_method`. + 2. signs it with `client_secret` which comes from server settings. + 3. obtains signature after sign and then compares it with request.signature + (request signature comes form client in request) + + Returns `True` if signatures are correct, otherwise `False`. + + ''' + client_secret = unicode(self.oauth_settings['client_secret']) + url = self.oauth_settings['lti_base'] + self.oauth_settings['lti_endpoint'] + + request = mock.Mock() + + request.params = [(unicode(k), unicode(v)) for k, v in params.items()] + request.uri = unicode(url) + request.http_method = u'POST' + request.signature = unicode(client_signature) + + return signature.verify_hmac_sha1(request, client_secret) From 02de4609de6121977c1ad1317668f061e69c930a Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 30 Aug 2013 15:47:12 +0300 Subject: [PATCH 08/50] updates --- common/lib/xmodule/xmodule/lti_module.py | 7 +++---- lms/djangoapps/courseware/features/lti_setup.py | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index cb25fc1504..865bf4c89b 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -17,14 +17,13 @@ log = logging.getLogger(__name__) class LTIFields(object): """provider_url and tool_id together is unique location of LTI in the web. - Scope settings should be scope content: - - Cale: There is no difference in presentation to the user yet because + Scope settings should be scope content. Expanation by Cale: + "There is no difference in presentation to the user yet because there is no sharing between courses. However, when we get to the point of being able to have multiple courses using the same content, then the distinction between Scope.settings (local to the current course), and Scope.content (shared across all uses of this content in any course) - becomes much more clear/necessary. + becomes much more clear/necessary." """ client_key = String(help="Client key", default='', scope=Scope.settings) client_secret = String(help="Client secret", default='', scope=Scope.settings) diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py index e3cf7b2c7c..0a6c4590dd 100644 --- a/lms/djangoapps/courseware/features/lti_setup.py +++ b/lms/djangoapps/courseware/features/lti_setup.py @@ -13,11 +13,15 @@ logger = getLogger(__name__) @before.all def setup_mock_lti_server(): + server_host = '127.0.0.1' + # Add +1 to XQUEUE random port number server_port = settings.XQUEUE_PORT + 1 + address = (server_host, server_port) + # Create the mock server instance - server = MockLTIServer(server_port) + server = MockLTIServer(address) logger.debug("LTI server started at {} port".format(str(server_port))) # Start the server running in a separate daemon thread # Because the thread is a daemon, it will terminate @@ -26,11 +30,17 @@ def setup_mock_lti_server(): server_thread.daemon = True server_thread.start() + 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' + } + # Store the server instance in lettuce's world # so that other steps can access it # (and we can shut it down later) world.lti_server = server - world.lti_server_port = server_port @after.all From 0e697f972cf7212dc6cbbee10d8b7f2f0a71323f Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 30 Aug 2013 16:46:52 +0300 Subject: [PATCH 09/50] acceptance tests updated --- .../courseware/features/lti.feature | 4 ++ lms/djangoapps/courseware/features/lti.py | 64 ++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index e17144f545..b424113992 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -8,3 +8,7 @@ Feature: LTI component Scenario: LTI component in LMS is rendered Given the course has a LTI component filled with correct data Then I view the LTI and it is rendered + + Scenario: LTI component in LMS is rendered incorreclty + Given the course has a LTI component filled with correct url and client_key, but incorrect client_secret + Then I view the LTI but incorrect_signature warning is rendered \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index da6d14e759..db48bbd8b8 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -37,6 +37,31 @@ def lti_is_rendered(_step): with world.browser.get_iframe('ltiLaunchFrame') as iframe: # iframe does not contain functions from terrain/ui_helpers.py assert iframe.is_element_present_by_css('.result', wait_time=5) + assert ("This is LTI tool. Success." == world.retry_on_exception( + lambda: iframe.find_by_css('.result')[0].text, + max_attempts=5 + )) + + +@step('I view the LTI but incorrect_signature warning is rendered') +def incorrect_lti_is_rendered(_step): + # lti div has class rendered + assert world.is_css_present('div.lti.rendered') + + # error is hidden + assert (not world.css_visible('.error_message')) + + # iframe is visible + assert world.css_visible('iframe') + + #inside iframe test content is presented + with world.browser.get_iframe('ltiLaunchFrame') as iframe: + # iframe does not contain functions from terrain/ui_helpers.py + assert iframe.is_element_present_by_css('.result', wait_time=5) + assert ("Wrong LTI signature" == world.retry_on_exception( + lambda: iframe.find_by_css('.result')[0].text, + max_attempts=5 + )) @step('the course has a LTI component filled with correct data') @@ -75,6 +100,25 @@ def view_default_lti(_step): world.browser.visit(url) +@step('the course has a LTI component filled with correct url \ +and client_key, but incorrect client_secret') +def view_wrong_data_lti(_step): + coursenum = 'test_course' + i_am_registered_for_the_course(_step, coursenum) + + wrong_data_lti_to_course(coursenum) + chapter_name = world.scenario_dict['SECTION'].display_name.replace( + " ", "_") + section_name = chapter_name + url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( + world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'), + chapter_name, section_name,) + ) + world.browser.visit(url) + + def add_correct_lti_to_course(course): category = 'lti' world.ItemFactory.create( @@ -82,9 +126,9 @@ def add_correct_lti_to_course(course): category=category, display_name='LTI', metadata={ - 'client_key': 'client_key', - 'clent_secret': 'client_secret', - 'lti_url': 'http://127.0.0.1:{}/correct_lti_endpoint'.format(world.lti_server_port) + 'client_key': world.lti_server.oauth_settings['client_key'], + 'client_secret': world.lti_server.oauth_settings['client_secret'], + 'lti_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] } ) @@ -96,3 +140,17 @@ def add_default_lti_to_course(course): category=category, display_name='LTI' ) + + +def wrong_data_lti_to_course(course): + category = 'lti' + world.ItemFactory.create( + parent_location=section_location(course), + category=category, + display_name='LTI', + metadata={ + 'client_key': world.lti_server.oauth_settings['client_key'], + 'client_secret': "wrong_secret", + 'lti_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] + } + ) From ec9b3d67d2b2c9f13cd8e1cfd34f9e7276dd2c83 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 30 Aug 2013 17:08:53 +0300 Subject: [PATCH 10/50] updates for acceptance tests --- .../mock_lti_server/mock_lti_server.py | 2 +- .../mock_lti_server/test_mock_lti_server.py | 70 ++++++++++--------- 2 files changed, 37 insertions(+), 35 deletions(-) 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 2eb2d2e139..3f1b6e1249 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -52,7 +52,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): 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']): - status_message = "This is LTI tool." + status_message = "This is LTI tool. Success." else: status_message = "Wrong LTI signature" else: 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 8d556286c3..0751bae1df 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,10 +1,10 @@ -import mock +""" +Test for Mock_LTI_Server +""" import unittest import threading -import json import urllib -import time -from mock_lti_server import MockLTIServer, MockLTIRequestHandler +from mock_lti_server import MockLTIServer from nose.plugins.skip import SkipTest @@ -26,9 +26,15 @@ class MockLTIServerTest(unittest.TestCase): # Create the server server_port = 8034 - self.server_url = 'http://127.0.0.1:%d' % server_port - self.server = MockLTIServer(server_port, {'client_key': '', 'client_secret': ''}) - + server_host = '127.0.0.1' + address = (server_host, server_port) + self.server = MockLTIServer(address) + self.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' + } # Start the server in a separate daemon thread server_thread = threading.Thread(target=self.server.serve_forever) server_thread.daemon = True @@ -39,34 +45,30 @@ class MockLTIServerTest(unittest.TestCase): # Stop the server, freeing up the port self.server.shutdown() - def test_oauth_request(self): - - # Send a grade request - header = { - 'Content-Type': 'application/x-www-form-urlencoded', - u'Authorization': u'OAuth oauth_nonce="151177408427657509491377691584", \ -oauth_timestamp="1377691584", oauth_version="1.0", \ -oauth_signature_method="HMAC-SHA1", oauth_consumer_key="", \ -oauth_signature="wc1unKXxsX5e4HXJu%2FuiQ1KbrVo%3D"', - 'launch_presentation_return_url': '', - 'user_id': 'default_user_id', - 'lis_result_sourcedid': '', - 'lti_version': 'LTI-1p0', - 'lis_outcome_service_url': '', - 'lti_message_type': 'basic-lti-launch-request', - 'oauth_callback': 'about:blank' - } - body = {} + def test_request(self): + """ + Tests that LTI server processes request with right program + path, and responses with incorrect signature. + """ request = { - 'header': json.dumps(header), - 'body': json.dumps(body)} + 'user_id': 'default_user_id', + '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': '' + } + response_handle = urllib.urlopen( - self.server_url + '/correct_lti_endpoint', + self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'], urllib.urlencode(request) ) - - response_dict = json.loads(response_handle.read()) - # Expect that the response is success - self.assertEqual(response_dict['return_code'], 0) - # self.assertEqual(response_dict['return_code'], 0) - + response = response_handle.read() + self.assertTrue('Wrong LTI signature' in response) From 42bcd9c28c8afbb84fa715c584d612159dafbb7c Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 2 Sep 2013 11:45:22 +0300 Subject: [PATCH 11/50] updated comment --- common/lib/xmodule/xmodule/lti_module.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 865bf4c89b..9b3f5fbf72 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -1,5 +1,7 @@ """ Module that allows to insert LTI tools to page. +Module uses current 0.14.2 version of requests (oauth part). +Please update code when upgrading requests. """ import logging @@ -80,9 +82,9 @@ class LTIModule(LTIFields, XModule): # 0.14.2 (current) version of requests oauth library encodes signature, # with 'Content-Type': 'application/x-www-form-urlencoded' - # so '='' becomes '%3D', but server waits for unencoded signature. - # Decode signature back: - # may be it may be encoded by browser again... check + # so '='' becomes '%3D'. + # We send form via browser, so browser will encode it again, + # So we need to decode signature back: params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8') return params From 3e3d89e3707a710393e02b27f575f9dbcac191f3 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 2 Sep 2013 12:57:00 +0300 Subject: [PATCH 12/50] added roles and student id --- common/lib/xmodule/xmodule/lti_module.py | 13 ++++++++++++- .../courseware/mock_lti_server/mock_lti_server.py | 1 + .../mock_lti_server/test_mock_lti_server.py | 1 + lms/templates/lti.html | 3 ++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 9b3f5fbf72..9639968e18 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -41,6 +41,8 @@ class LTIModule(LTIFields, XModule): def get_html(self): """ Renders parameters to template. """ + + # these params do not participate in oauth signing params = { 'lti_url': self.lti_url, 'element_id': self.location.html_id(), @@ -55,15 +57,21 @@ class LTIModule(LTIFields, XModule): client_key=unicode(self.client_key), client_secret=unicode(self.client_secret) ) + + # @ned - why self.runtime.anonymous_student_id is None in dev env? + user_id = self.runtime.anonymous_student_id + user_id = user_id if user_id else 'default_user_id' + # must have parameters for correct signing from LTI: body = { - 'user_id': 'default_user_id', + 'user_id': user_id, 'oauth_callback': 'about:blank', 'lis_outcome_service_url': '', 'lis_result_sourcedid': '', 'launch_presentation_return_url': '', 'lti_message_type': 'basic-lti-launch-request', 'lti_version': 'LTI-1p0', + 'role': 'student' } # This is needed for body encoding: headers = {'Content-Type': 'application/x-www-form-urlencoded'} @@ -80,12 +88,15 @@ class LTIModule(LTIFields, XModule): params[u'oauth_nonce'] = params[u'OAuth oauth_nonce'] del params[u'OAuth oauth_nonce'] + params['user_id'] = body['user_id'] + # 0.14.2 (current) version of requests oauth library encodes signature, # with 'Content-Type': 'application/x-www-form-urlencoded' # so '='' becomes '%3D'. # We send form via browser, so browser will encode it again, # So we need to decode signature back: params[u'oauth_signature'] = urllib.unquote(params[u'oauth_signature']).decode('utf8') + return params 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 3f1b6e1249..afbbcf40a6 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -33,6 +33,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): if self._is_correct_lti_request(): correct_keys = [ 'user_id', + 'role', 'oauth_nonce', 'oauth_timestamp', 'oauth_consumer_key', 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 0751bae1df..99650d5faa 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 @@ -52,6 +52,7 @@ class MockLTIServerTest(unittest.TestCase): """ request = { 'user_id': 'default_user_id', + 'role': 'student', 'oauth_nonce': '', 'oauth_timestamp': '', 'oauth_consumer_key': 'client_key', diff --git a/lms/templates/lti.html b/lms/templates/lti.html index d1f0c1b236..5bbf91279a 100644 --- a/lms/templates/lti.html +++ b/lms/templates/lti.html @@ -22,7 +22,8 @@ - + + From 0d2d10d77a8bf4798367d3f11e19284004d59777 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 2 Sep 2013 15:41:21 +0300 Subject: [PATCH 13/50] custom parameters --- common/lib/xmodule/xmodule/lti_module.py | 34 +++++++++++++++++++----- lms/templates/lti.html | 6 ++++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 9639968e18..274f8e9519 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -11,7 +11,7 @@ import urllib from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.x_module import XModule from pkg_resources import resource_string -from xblock.core import String, Scope +from xblock.core import String, Scope, List log = logging.getLogger(__name__) @@ -29,7 +29,8 @@ class LTIFields(object): """ client_key = String(help="Client key", default='', scope=Scope.settings) client_secret = String(help="Client secret", default='', scope=Scope.settings) - lti_url = String(help="URL of the tool", default='', scope=Scope.settings) + launch_url = String(help="URL of the tool", default='', scope=Scope.settings) + custom_parameters = List(help="Custom parameters", scope=Scope.settings) class LTIModule(LTIFields, XModule): @@ -44,14 +45,29 @@ class LTIModule(LTIFields, XModule): # these params do not participate in oauth signing params = { - 'lti_url': self.lti_url, + 'launch_url': self.launch_url, 'element_id': self.location.html_id(), 'element_class': self.location.category, } - params.update(self.oauth_params()) + + parsed_custom_parameters = {} + for custom_parameter in self.custom_parameters: + try: + param_name, param_value = custom_parameter.split('=') + except ValueError: + raise Exception('Could not parse custom parameter: {0}. \ + Should be "x=y" string.'.format(custom_parameter)) + + # LTI specs: 'custom_' should be prepended before each custom parameter + parsed_custom_parameters.update( + {u'custom_' + unicode(param_name): unicode(param_value)} + ) + + params.update({'custom_parameters': parsed_custom_parameters}) + params.update(self.oauth_params(parsed_custom_parameters)) return self.system.render_template('lti.html', params) - def oauth_params(self): + def oauth_params(self, custom_parameters): """Obtains LTI html from provider""" client = requests.auth.Client( client_key=unicode(self.client_key), @@ -73,11 +89,15 @@ class LTIModule(LTIFields, XModule): 'lti_version': 'LTI-1p0', 'role': 'student' } + + # appending custom parameter for signing + body.update(custom_parameters) + # This is needed for body encoding: headers = {'Content-Type': 'application/x-www-form-urlencoded'} _, headers, _ = client.sign( - unicode(self.lti_url), + unicode(self.launch_url), http_method=u'POST', body=body, headers=headers) @@ -101,5 +121,5 @@ class LTIModule(LTIFields, XModule): class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor): - """LTI Descriptor. No export/import to html.""" + """LTI Descriptor. No export/import to xml.""" module_class = LTIModule diff --git a/lms/templates/lti.html b/lms/templates/lti.html index 5bbf91279a..a0ad6f1fe6 100644 --- a/lms/templates/lti.html +++ b/lms/templates/lti.html @@ -4,7 +4,7 @@ ## module JavaScript will trigget a "submit" on the form, and the ## result will be rendered to the below iFrame.
+ % for param_name, param_value in custom_parameters.items(): + + %endfor +
From b1d40b0f27a027072efd6f58db15b96de1af41dd Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 2 Sep 2013 16:12:48 +0300 Subject: [PATCH 14/50] updated docs --- common/lib/xmodule/xmodule/lti_module.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 274f8e9519..101efe2553 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -1,7 +1,11 @@ """ Module that allows to insert LTI tools to page. + Module uses current 0.14.2 version of requests (oauth part). Please update code when upgrading requests. + +Protocol is oauth1, LTI version is 1.1.1: +http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html """ import logging @@ -42,7 +46,7 @@ class LTIModule(LTIFields, XModule): def get_html(self): """ Renders parameters to template. """ - + import ipdb; ipdb.set_trace() # these params do not participate in oauth signing params = { 'launch_url': self.launch_url, From f1dc6ecf676f5e13eb9e49bfd1c1fe8ea8a1c831 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 2 Sep 2013 17:20:47 +0300 Subject: [PATCH 15/50] Add course setings for client key, secret --- common/lib/xmodule/xmodule/course_module.py | 1 + common/lib/xmodule/xmodule/lti_module.py | 35 ++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index aca804d5e2..d4fdcb7b60 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -153,6 +153,7 @@ class TextbookList(List): class CourseFields(object): + LTIs = List(help="LTI tools passports as id:client_key:client_secret", scope=Scope.settings) textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 101efe2553..c7656422e7 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -31,8 +31,9 @@ class LTIFields(object): and Scope.content (shared across all uses of this content in any course) becomes much more clear/necessary." """ - client_key = String(help="Client key", default='', scope=Scope.settings) - client_secret = String(help="Client secret", default='', scope=Scope.settings) + # client_key = String(help="Client key", default='', scope=Scope.settings) + # client_secret = String(help="Client secret", default='', scope=Scope.settings) + lti_id = String(help="Id of the tool", default='', scope=Scope.settings) launch_url = String(help="URL of the tool", default='', scope=Scope.settings) custom_parameters = List(help="Custom parameters", scope=Scope.settings) @@ -46,7 +47,23 @@ class LTIModule(LTIFields, XModule): def get_html(self): """ Renders parameters to template. """ - import ipdb; ipdb.set_trace() + + # get client_key and client_secret parameters from current course: + # course location example: u'i4x://blades/1/course/2013_Spring' + course = self.descriptor.system.load_item( + self.location.tag + '://' + self.location.org + '/' + + self.location.course + '/course' + '/2013_Spring') + client_key, client_secret = '', '' + for lti_passport in course.LTIs: + try: + lti_id, key, secret = lti_passport.split(':') + except ValueError: + raise Exception('Could not parse LTI passport: {0}. \ + Should be "id:key:secret" string.'.format(lti_passport)) + if lti_id == self.lti_id: + client_key, client_secret = key, secret + break + # these params do not participate in oauth signing params = { 'launch_url': self.launch_url, @@ -68,14 +85,18 @@ class LTIModule(LTIFields, XModule): ) params.update({'custom_parameters': parsed_custom_parameters}) - params.update(self.oauth_params(parsed_custom_parameters)) + params.update(self.oauth_params( + parsed_custom_parameters, + client_key, + client_secret + )) return self.system.render_template('lti.html', params) - def oauth_params(self, custom_parameters): + def oauth_params(self, custom_parameters, client_key, client_secret): """Obtains LTI html from provider""" client = requests.auth.Client( - client_key=unicode(self.client_key), - client_secret=unicode(self.client_secret) + client_key=unicode(client_key), + client_secret=unicode(client_secret) ) # @ned - why self.runtime.anonymous_student_id is None in dev env? From e5ec4600df7e97ebf249f824309b3004062804f6 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Wed, 4 Sep 2013 11:34:21 +0300 Subject: [PATCH 16/50] Add documentation to LTI module and to dev docs --- common/lib/xmodule/xmodule/lti_module.py | 128 +++++++++++++++++++---- docs/developers/source/xmodule.rst | 8 ++ 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index c7656422e7..c0c4feb55e 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -1,7 +1,7 @@ """ Module that allows to insert LTI tools to page. -Module uses current 0.14.2 version of requests (oauth part). +Module uses current edx-platform 0.14.2 version of requests (oauth part). Please update code when upgrading requests. Protocol is oauth1, LTI version is 1.1.1: @@ -21,25 +21,105 @@ log = logging.getLogger(__name__) class LTIFields(object): - """provider_url and tool_id together is unique location of LTI in the web. - - Scope settings should be scope content. Expanation by Cale: - "There is no difference in presentation to the user yet because - there is no sharing between courses. However, when we get to the point of being - able to have multiple courses using the same content, - then the distinction between Scope.settings (local to the current course), - and Scope.content (shared across all uses of this content in any course) - becomes much more clear/necessary." """ - # client_key = String(help="Client key", default='', scope=Scope.settings) - # client_secret = String(help="Client secret", default='', scope=Scope.settings) + 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. + `custom_parameters` are additional parameters to navigate to proper book and book page. + + For example, for Vitalsource provider, `launch_url` should be + *https://bc-staging.vitalsource.com/books/book*, + and to get to proper book and book page, you should set custom parameters as:: + + vbid=put_book_id_here + book_location=page/put_page_number_here + + """ lti_id = String(help="Id of the tool", default='', scope=Scope.settings) launch_url = String(help="URL of the tool", default='', scope=Scope.settings) - custom_parameters = List(help="Custom parameters", scope=Scope.settings) + custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings) class LTIModule(LTIFields, XModule): - '''LTI Module''' + '''Module provides LTI integration to course. + + Except usual xmodule structure it proceeds with oauth signing. + How it works:: + + 1. Get credentials from course settings. + + 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: + + user_id + oauth_callback + lis_outcome_service_url + lis_result_sourcedid + launch_presentation_return_url + lti_message_type + lti_version + role + *+ all custom parameters* + + These parameters should be encoded and signed by *oauth1* together with + `launch_url` and *POST* request type. 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. + + Additional oauth data which is generated after signing is usual:: + + oauth_callback + oauth_nonce + oauth_consumer_key + oauth_signature_method + oauth_timestamp + oauth_version + + + All that data is passed to form and sent to LTI provider server by browser via + autosubmit via javascript. + + Form example:: + +
+ + + + + + + + + + + + + + + + % for param_name, param_value in custom_parameters.items(): + + %endfor + + +
+ + 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')]} @@ -48,11 +128,14 @@ class LTIModule(LTIFields, XModule): def get_html(self): """ Renders parameters to template. """ - # get client_key and client_secret parameters from current course: + # Obtains client_key and client_secret credentials from current course: # course location example: u'i4x://blades/1/course/2013_Spring' course = self.descriptor.system.load_item( - self.location.tag + '://' + self.location.org + '/' + - self.location.course + '/course' + '/2013_Spring') + self.location.tag + '://' + + self.location.org + '/' + + self.location.course + + '/course' + + '/2013_Spring') client_key, client_secret = '', '' for lti_passport in course.LTIs: try: @@ -71,6 +154,7 @@ class LTIModule(LTIFields, XModule): 'element_class': self.location.category, } + # parsing custom parameters to dict parsed_custom_parameters = {} for custom_parameter in self.custom_parameters: try: @@ -93,7 +177,15 @@ class LTIModule(LTIFields, XModule): return self.system.render_template('lti.html', params) def oauth_params(self, custom_parameters, client_key, client_secret): - """Obtains LTI html from provider""" + """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. + """ + client = requests.auth.Client( client_key=unicode(client_key), client_secret=unicode(client_secret) diff --git a/docs/developers/source/xmodule.rst b/docs/developers/source/xmodule.rst index 008a1303d2..77ee2ea684 100644 --- a/docs/developers/source/xmodule.rst +++ b/docs/developers/source/xmodule.rst @@ -95,6 +95,14 @@ Html :members: :show-inheritance: + +LTI +=== + +.. automodule:: xmodule.lti_module + :members: + :show-inheritance: + Mako ==== From fd64ed9a490685056e8bdaa7b0c7b5c69c9508bb Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Wed, 4 Sep 2013 16:26:57 +0300 Subject: [PATCH 17/50] Updated error message in LTI template. --- lms/templates/lti.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/lti.html b/lms/templates/lti.html index a0ad6f1fe6..60bf00d176 100644 --- a/lms/templates/lti.html +++ b/lms/templates/lti.html @@ -34,7 +34,7 @@

- Please provide LTI url. Click "Edit", and fill in the + Please provide launch_url. Click "Edit", and fill in the required fields.

From f6d9c077f0568ea522a08576b0f8ec384dfbaeeb Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 5 Sep 2013 16:43:14 +0300 Subject: [PATCH 18/50] Improve LTI module Add simplifyed template. Update lti integration test. Add getting oauth credentials from course settings. Add user id transferring to provider. Improve documentation of lti module. --- common/lib/xmodule/xmodule/lti_module.py | 70 ++++++++++----------- lms/djangoapps/courseware/tests/test_lti.py | 46 +++++++++----- lms/templates/lti.html | 16 +---- 3 files changed, 66 insertions(+), 66 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index c0c4feb55e..2a529e0ef3 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -14,6 +14,7 @@ import urllib from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.x_module import XModule +from xmodule.course_module import CourseDescriptor from pkg_resources import resource_string from xblock.core import String, Scope, List @@ -106,9 +107,9 @@ class LTIModule(LTIFields, XModule): - % for param_name, param_value in custom_parameters.items(): - - %endfor + + + @@ -129,13 +130,9 @@ class LTIModule(LTIFields, XModule): """ Renders parameters to template. """ # Obtains client_key and client_secret credentials from current course: - # course location example: u'i4x://blades/1/course/2013_Spring' - course = self.descriptor.system.load_item( - self.location.tag + '://' + - self.location.org + '/' + - self.location.course + - '/course' + - '/2013_Spring') + course_id = self.runtime.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.LTIs: try: @@ -147,15 +144,8 @@ class LTIModule(LTIFields, XModule): client_key, client_secret = key, secret break - # these params do not participate in oauth signing - params = { - 'launch_url': self.launch_url, - 'element_id': self.location.html_id(), - 'element_class': self.location.category, - } - # parsing custom parameters to dict - parsed_custom_parameters = {} + custom_parameters = {} for custom_parameter in self.custom_parameters: try: param_name, param_value = custom_parameter.split('=') @@ -164,17 +154,26 @@ class LTIModule(LTIFields, XModule): Should be "x=y" string.'.format(custom_parameter)) # LTI specs: 'custom_' should be prepended before each custom parameter - parsed_custom_parameters.update( + custom_parameters.update( {u'custom_' + unicode(param_name): unicode(param_value)} ) - params.update({'custom_parameters': parsed_custom_parameters}) - params.update(self.oauth_params( - parsed_custom_parameters, + input_fields = (self.oauth_params( + custom_parameters, client_key, client_secret )) - return self.system.render_template('lti.html', params) + + context = { + 'input_fields': input_fields, + + # these params do not participate in oauth signing + 'launch_url': self.launch_url, + 'element_id': self.location.html_id(), + 'element_class': self.location.category, + } + + return self.system.render_template('lti.html', context) def oauth_params(self, custom_parameters, client_key, client_secret): """Signs request and returns signature and oauth parameters. @@ -191,20 +190,19 @@ class LTIModule(LTIFields, XModule): client_secret=unicode(client_secret) ) - # @ned - why self.runtime.anonymous_student_id is None in dev env? user_id = self.runtime.anonymous_student_id - user_id = user_id if user_id else 'default_user_id' + assert user_id is not None # must have parameters for correct signing from LTI: body = { - 'user_id': user_id, - 'oauth_callback': 'about:blank', - 'lis_outcome_service_url': '', - 'lis_result_sourcedid': '', - 'launch_presentation_return_url': '', - 'lti_message_type': 'basic-lti-launch-request', - 'lti_version': 'LTI-1p0', - 'role': 'student' + 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' } # appending custom parameter for signing @@ -220,13 +218,11 @@ class LTIModule(LTIFields, XModule): headers=headers) params = headers['Authorization'] # parse headers to pass to template as part of context: - params = dict([param.strip().replace('"', '').split('=') for param in params.split('",')]) + params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')]) params[u'oauth_nonce'] = params[u'OAuth oauth_nonce'] del params[u'OAuth oauth_nonce'] - params['user_id'] = body['user_id'] - # 0.14.2 (current) version of requests oauth library encodes signature, # with 'Content-Type': 'application/x-www-form-urlencoded' # so '='' becomes '%3D'. @@ -234,6 +230,8 @@ class LTIModule(LTIFields, XModule): # 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 + params.update(body) return params diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index 86419300ad..cf74ea2660 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -1,53 +1,69 @@ -"""LTI test""" +"""LTI integration tests""" import requests from . import BaseTestXmodule +from collections import OrderedDict class TestLTI(BaseTestXmodule): - """Integration test for word cloud xmodule.""" + """ + Integration test for lti xmodule. + """ CATEGORY = "lti" def setUp(self): + """ + Mock oauth1 signing of requests library for testing. + """ super(TestLTI, self).setUp() mocked_noonce = u'135685044251684026041377608307' mocked_timestamp = u'1234567890' - mocked_signed_signature = u'my_signature%3D' + mocked_signature_after_sign = u'my_signature%3D' mocked_decoded_signature = u'my_signature=' self.correct_headers = { + 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'oauth_nonce': mocked_noonce, u'oauth_timestamp': mocked_timestamp, u'oauth_consumer_key': u'', u'oauth_signature_method': u'HMAC-SHA1', u'oauth_version': u'1.0', - u'oauth_signature': mocked_decoded_signature} + u'user_id': self.runtime.anonymous_student_id, + u'role': u'student', + u'oauth_signature': mocked_decoded_signature + } saved_sign = requests.auth.Client.sign def mocked_sign(self, *args, **kwargs): """Mocked oauth1 sign function""" - # self is here: + # self is here: _, headers, _ = saved_sign(self, *args, **kwargs) # we should replace noonce, timestamp and signed_signature in headers: old = headers[u'Authorization'] - new = old[:19] + mocked_noonce + old[49:69] + mocked_timestamp + \ - old[79:179] + mocked_signed_signature + old[-1] - headers[u'Authorization'] = new + old_parsed = OrderedDict([param.strip().replace('"', '').split('=') for param in old.split(',')]) + old_parsed[u'OAuth oauth_nonce'] = mocked_noonce + old_parsed[u'oauth_timestamp'] = mocked_timestamp + old_parsed[u'oauth_signature'] = mocked_signature_after_sign + headers[u'Authorization'] = ', '.join([k+'="'+v+'"' for k, v in old_parsed.items()]) return None, headers, None requests.auth.Client.sign = mocked_sign def test_lti_constructor(self): """Make sure that all parameters extracted """ - fragment = self.runtime.render(self.item_module, None, 'student_view') + self.runtime.render_template = lambda template, context: context + generated_context = self.item_module.get_html() expected_context = { + 'input_fields': self.correct_headers, 'element_class': self.item_module.location.category, 'element_id': self.item_module.location.html_id(), - 'lti_url': '', # default value + 'launch_url': '', # default value } - self.correct_headers.update(expected_context) - self.assertEqual( - fragment.content, - self.runtime.render_template('lti.html', self.correct_headers) - ) + self.assertDictEqual(generated_context, expected_context) diff --git a/lms/templates/lti.html b/lms/templates/lti.html index 60bf00d176..3d97c8d808 100644 --- a/lms/templates/lti.html +++ b/lms/templates/lti.html @@ -11,22 +11,8 @@ target="ltiLaunchFrame" encType="application/x-www-form-urlencoded" > - - - - - - - - - - - - - - - % for param_name, param_value in custom_parameters.items(): + % for param_name, param_value in input_fields.items(): %endfor From e10577d6add81a150e74080a15657cf6ae27fe7d Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 5 Sep 2013 16:44:09 +0300 Subject: [PATCH 19/50] Pass user_id and course_id to runtime in cms (dev) and tests. --- cms/djangoapps/contentstore/views/preview.py | 4 ++-- common/lib/xmodule/xmodule/tests/__init__.py | 5 +++-- lms/djangoapps/courseware/tests/__init__.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index ccbb7fb5bb..45ad7a7424 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -81,7 +81,6 @@ def preview_component(request, location): component, 'xmodule_edit.html' ) - return render_to_response('component.html', { 'preview': get_preview_html(request, component, 0), 'editor': component.runtime.render(component, None, 'studio_view').content, @@ -104,7 +103,6 @@ def preview_module_system(request, preview_id, descriptor): return lms_field_data(descriptor._field_data, student_data) course_id = get_course_for_item(descriptor.location).location.course_id - return ModuleSystem( ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'), # TODO (cpennington): Do we want to track how instructors are using the preview problems? @@ -118,6 +116,8 @@ def preview_module_system(request, preview_id, descriptor): xblock_field_data=preview_field_data, can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)), mixins=settings.XBLOCK_MIXINS, + course_id=course_id, + anonymous_student_id='student' ) diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index fefa668a56..c518d1afa0 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -40,7 +40,7 @@ open_ended_grading_interface = { } -def get_test_system(): +def get_test_system(course_id=''): """ Construct a test ModuleSystem instance. @@ -66,7 +66,8 @@ def get_test_system(): node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), xblock_field_data=lambda descriptor: descriptor._field_data, anonymous_student_id='student', - open_ended_grading_interface=open_ended_grading_interface + open_ended_grading_interface= open_ended_grading_interface, + course_id=course_id ) diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 88129cc8d1..2552da9fb9 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -86,7 +86,7 @@ class BaseTestXmodule(ModuleStoreTestCase): data=self.DATA ) - self.runtime = get_test_system() + self.runtime = get_test_system(course_id='MITx/999/Robot_Super_Course') # Allow us to assert that the template was called in the same way from # different code paths while maintaining the type returned by render_template self.runtime.render_template = lambda template, context: u'{!r}, {!r}'.format(template, sorted(context.items())) From 3531f2cbb7a491cf27100dd8a670a1bc85eca6ad Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 5 Sep 2013 16:51:05 +0300 Subject: [PATCH 20/50] Update docs. --- common/lib/xmodule/xmodule/lti_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 2a529e0ef3..4728518894 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -69,7 +69,7 @@ class LTIModule(LTIFields, XModule): and set into course settings by course author. After that signature and other oauth data are generated. - Additional oauth data which is generated after signing is usual:: + Oauth data which is generated after signing is usual:: oauth_callback oauth_nonce From 462a42e99e5cf6a13f28c7d58b27cf28e92c5399 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 5 Sep 2013 17:23:48 +0300 Subject: [PATCH 21/50] Update acceptance test --- .../xmodule/modulestore/tests/factories.py | 2 +- lms/djangoapps/courseware/features/lti.py | 82 ++++++++++++++++--- 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 4ad801aef8..c8228c5e3e 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -27,7 +27,7 @@ class XModuleCourseFactory(Factory): store = editable_modulestore('direct') # Write the data to the mongo datastore - new_course = store.create_xmodule(location) + new_course = store.create_xmodule(location, metadata=kwargs.get('metadata', None)) # This metadata code was copied from cms/djangoapps/contentstore/views.py if display_name is not None: diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index db48bbd8b8..3508bd0b61 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -1,9 +1,17 @@ #pylint: disable=C0111 +from django.contrib.auth.models import User from lettuce import world, step from lettuce.django import django_url -from common import i_am_registered_for_the_course, section_location +from common import section_location, course_id +from django.contrib.auth.models import User +from student.models import CourseEnrollment +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor +from courseware.courses import get_course_by_id +from xmodule import seq_module, vertical_module @step('I view the LTI and it is not rendered') def lti_is_not_rendered(_step): @@ -67,8 +75,13 @@ def incorrect_lti_is_rendered(_step): @step('the course has a LTI component filled with correct data') def view_lti_with_data(_step): coursenum = 'test_course' - i_am_registered_for_the_course(_step, coursenum) - + metadata = { + 'LTIs': ["test_lti_id:{}:{}".format( + world.lti_server.oauth_settings['client_key'], + world.lti_server.oauth_settings['client_secret'] + )] + } + i_am_registered_for_the_course(_step, coursenum, metadata) add_correct_lti_to_course(coursenum) chapter_name = world.scenario_dict['SECTION'].display_name.replace( " ", "_") @@ -85,8 +98,8 @@ def view_lti_with_data(_step): @step('the course has a LTI component with empty fields') def view_default_lti(_step): coursenum = 'test_course' - i_am_registered_for_the_course(_step, coursenum) - + metadata = {} + i_am_registered_for_the_course(_step, coursenum, {}) add_default_lti_to_course(coursenum) chapter_name = world.scenario_dict['SECTION'].display_name.replace( " ", "_") @@ -104,8 +117,13 @@ def view_default_lti(_step): and client_key, but incorrect client_secret') def view_wrong_data_lti(_step): coursenum = 'test_course' - i_am_registered_for_the_course(_step, coursenum) - + metadata = { + 'LTIs': ["test_lti_id:{}:{}".format( + world.lti_server.oauth_settings['client_key'], + world.lti_server.oauth_settings['client_secret'] + )] + } + i_am_registered_for_the_course(_step, coursenum, metadata) wrong_data_lti_to_course(coursenum) chapter_name = world.scenario_dict['SECTION'].display_name.replace( " ", "_") @@ -126,9 +144,8 @@ def add_correct_lti_to_course(course): category=category, display_name='LTI', metadata={ - 'client_key': world.lti_server.oauth_settings['client_key'], - 'client_secret': world.lti_server.oauth_settings['client_secret'], - 'lti_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] + 'lti_id': 'test_lti_id', + 'launch_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] } ) @@ -149,8 +166,49 @@ def wrong_data_lti_to_course(course): category=category, display_name='LTI', metadata={ - 'client_key': world.lti_server.oauth_settings['client_key'], - 'client_secret': "wrong_secret", + 'lti_id': 'test_lti_id', 'lti_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] } ) + + +@step(u'The course "([^"]*)" exists$') +def create_course(_step, course, metadata): + + # First clear the modulestore so we don't try to recreate + # the same course twice + # This also ensures that the necessary templates are loaded + world.clear_courses() + + # Create the course + # We always use the same org and display name, + # but vary the course identifier (e.g. 600x or 191x) + world.scenario_dict['COURSE'] = world.CourseFactory.create(org='edx', + number=course, + display_name='Test Course', + metadata=metadata) + + # 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') + + world.ItemFactory.create( + parent_location=world.scenario_dict['SECTION'].location, + category='sequential', + display_name='Test Section') + + +@step(u'I am registered for the course "([^"]*)"$') +def i_am_registered_for_the_course(step, course, metadata): + # Create the course + create_course(step, course, metadata) + + # Create the user + world.create_user('robot', 'test') + u = User.objects.get(username='robot') + + # If the user is not already enrolled, enroll the user. + # TODO: change to factory + CourseEnrollment.enroll(u, course_id(course)) + + world.log_in(username='robot', password='test') From d5639293372e4877ed67f0c5864144a0a4dedbc4 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 5 Sep 2013 19:21:17 +0300 Subject: [PATCH 22/50] Add correct acceptance tests for LTI --- .../courseware/features/lti.feature | 9 +- lms/djangoapps/courseware/features/lti.py | 148 +++++++----------- 2 files changed, 64 insertions(+), 93 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index b424113992..80ffeaa56a 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -2,13 +2,16 @@ Feature: LTI component As a student, I want to view LTI component in LMS. Scenario: LTI component in LMS is not rendered - Given the course has a LTI component with empty fields + Given the course has correct LTI credentials + And the course has a LTI component with incorrect fields Then I view the LTI and it is not rendered Scenario: LTI component in LMS is rendered - Given the course has a LTI component filled with correct data + Given the course has correct LTI credentials + And the course has a LTI component filled with correct fields Then I view the LTI and it is rendered Scenario: LTI component in LMS is rendered incorreclty - Given the course has a LTI component filled with correct url and client_key, but incorrect client_secret + Given the course has a incorrect LTI credentials + And the course has a LTI component filled with correct fields Then I view the LTI but incorrect_signature warning is rendered \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 3508bd0b61..0ff6bbb809 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -5,15 +5,10 @@ from lettuce import world, step from lettuce.django import django_url from common import section_location, course_id -from django.contrib.auth.models import User from student.models import CourseEnrollment -from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore -from xmodule.course_module import CourseDescriptor -from courseware.courses import get_course_by_id -from xmodule import seq_module, vertical_module -@step('I view the LTI and it is not rendered') + +@step('I view the LTI and it is not rendered$') def lti_is_not_rendered(_step): # lti div has no class rendered assert world.is_css_not_present('div.lti.rendered') @@ -30,7 +25,7 @@ def lti_is_not_rendered(_step): assert iframe.is_element_not_present_by_css('.result', wait_time=5) -@step('I view the LTI and it is rendered') +@step('I view the LTI and it is rendered$') def lti_is_rendered(_step): # lti div has class rendered assert world.is_css_present('div.lti.rendered') @@ -51,7 +46,7 @@ def lti_is_rendered(_step): )) -@step('I view the LTI but incorrect_signature warning is rendered') +@step('I view the LTI but incorrect_signature warning is rendered$') def incorrect_lti_is_rendered(_step): # lti div has class rendered assert world.is_css_present('div.lti.rendered') @@ -72,108 +67,80 @@ def incorrect_lti_is_rendered(_step): )) -@step('the course has a LTI component filled with correct data') -def view_lti_with_data(_step): +@step('the course has correct LTI credentials$') +def set_correct_lti_passport(_step): coursenum = 'test_course' - metadata = { - 'LTIs': ["test_lti_id:{}:{}".format( + metadata = { + 'LTIs': ["correct_lti_id:{}:{}".format( world.lti_server.oauth_settings['client_key'], world.lti_server.oauth_settings['client_secret'] )] } - i_am_registered_for_the_course(_step, coursenum, metadata) - add_correct_lti_to_course(coursenum) - chapter_name = world.scenario_dict['SECTION'].display_name.replace( - " ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( - world.scenario_dict['COURSE'].org, - world.scenario_dict['COURSE'].number, - world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,) - ) - world.browser.visit(url) + i_am_registered_for_the_course(coursenum, metadata) -@step('the course has a LTI component with empty fields') -def view_default_lti(_step): +@step('the course has a incorrect LTI credentials$') +def set_incorrect_lti_passport(_step): coursenum = 'test_course' - metadata = {} - i_am_registered_for_the_course(_step, coursenum, {}) - add_default_lti_to_course(coursenum) - chapter_name = world.scenario_dict['SECTION'].display_name.replace( - " ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( - world.scenario_dict['COURSE'].org, - world.scenario_dict['COURSE'].number, - world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,) - ) - world.browser.visit(url) - - -@step('the course has a LTI component filled with correct url \ -and client_key, but incorrect client_secret') -def view_wrong_data_lti(_step): - coursenum = 'test_course' - metadata = { + metadata = { 'LTIs': ["test_lti_id:{}:{}".format( world.lti_server.oauth_settings['client_key'], - world.lti_server.oauth_settings['client_secret'] + "incorrect_lti_secret_key" )] } - i_am_registered_for_the_course(_step, coursenum, metadata) - wrong_data_lti_to_course(coursenum) - chapter_name = world.scenario_dict['SECTION'].display_name.replace( - " ", "_") - section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( - world.scenario_dict['COURSE'].org, - world.scenario_dict['COURSE'].number, - world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,) - ) - world.browser.visit(url) + i_am_registered_for_the_course(coursenum, metadata) -def add_correct_lti_to_course(course): +@step('the course has a LTI component filled with correct fields$') +def add_correct_lti_to_course(_step): category = 'lti' world.ItemFactory.create( - parent_location=section_location(course), + # parent_location=section_location(course), + parent_location=world.scenario_dict['SEQUENTIAL'].location, category=category, display_name='LTI', metadata={ - 'lti_id': 'test_lti_id', + 'lti_id': 'correct_lti_id', 'launch_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] } ) - - -def add_default_lti_to_course(course): - category = 'lti' - world.ItemFactory.create( - parent_location=section_location(course), - category=category, - display_name='LTI' + chapter_name = world.scenario_dict['SECTION'].display_name.replace( + " ", "_") + section_name = chapter_name + url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( + world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'), + chapter_name, section_name,) ) + world.browser.visit(url) -def wrong_data_lti_to_course(course): +@step('the course has a LTI component with incorrect fields$') +def add_incorrect_lti_to_course(_step): category = 'lti' world.ItemFactory.create( - parent_location=section_location(course), + parent_location=world.scenario_dict['SEQUENTIAL'].location, category=category, display_name='LTI', metadata={ - 'lti_id': 'test_lti_id', + 'lti_id': 'incorrect_lti_id', 'lti_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] } ) + chapter_name = world.scenario_dict['SECTION'].display_name.replace( + " ", "_") + section_name = chapter_name + url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( + world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'), + chapter_name, section_name,) + ) + world.browser.visit(url) -@step(u'The course "([^"]*)" exists$') -def create_course(_step, course, metadata): +def create_course(course, metadata): # First clear the modulestore so we don't try to recreate # the same course twice @@ -183,32 +150,33 @@ def create_course(_step, course, metadata): # Create the course # We always use the same org and display name, # but vary the course identifier (e.g. 600x or 191x) - world.scenario_dict['COURSE'] = world.CourseFactory.create(org='edx', - number=course, - display_name='Test Course', - metadata=metadata) + world.scenario_dict['COURSE'] = world.CourseFactory.create( + org='edx', + number=course, + display_name='Test Course', + metadata=metadata + ) # 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') - - world.ItemFactory.create( + world.scenario_dict['SECTION'] = world.ItemFactory.create( + parent_location=world.scenario_dict['COURSE'].location, + display_name='Test Section' + ) + world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create( parent_location=world.scenario_dict['SECTION'].location, category='sequential', display_name='Test Section') -@step(u'I am registered for the course "([^"]*)"$') -def i_am_registered_for_the_course(step, course, metadata): +def i_am_registered_for_the_course(course, metadata): # Create the course - create_course(step, course, metadata) + create_course(course, metadata) # Create the user world.create_user('robot', 'test') - u = User.objects.get(username='robot') + usr = User.objects.get(username='robot') # If the user is not already enrolled, enroll the user. - # TODO: change to factory - CourseEnrollment.enroll(u, course_id(course)) + CourseEnrollment.enroll(usr, course_id(course)) world.log_in(username='robot', password='test') From faa03f0e251796c089b46fc40ed7d82d341f23ac Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 5 Sep 2013 19:39:01 +0300 Subject: [PATCH 23/50] Update docstrings. --- common/lib/xmodule/xmodule/lti_module.py | 14 ++++++++++---- lms/djangoapps/courseware/tests/test_lti.py | 11 +++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 4728518894..2ef1c84813 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -44,7 +44,8 @@ class LTIFields(object): class LTIModule(LTIFields, XModule): - '''Module provides LTI integration to course. + ''' + Module provides LTI integration to course. Except usual xmodule structure it proceeds with oauth signing. How it works:: @@ -127,7 +128,9 @@ class LTIModule(LTIFields, XModule): js_module_name = "LTI" def get_html(self): - """ Renders parameters to template. """ + """ + Renders parameters to template. + """ # Obtains client_key and client_secret credentials from current course: course_id = self.runtime.course_id @@ -176,7 +179,8 @@ class LTIModule(LTIFields, XModule): return self.system.render_template('lti.html', context) 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 @@ -236,5 +240,7 @@ class LTIModule(LTIFields, XModule): class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor): - """LTI Descriptor. No export/import to xml.""" + """ + LTIModuleDescriptor provides no export/import to xml. + """ module_class = LTIModule diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index cf74ea2660..e863d47636 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -8,6 +8,9 @@ from collections import OrderedDict class TestLTI(BaseTestXmodule): """ Integration test for lti xmodule. + + It checks overall code, by assuring that context that goes to template is correct. + As part of that, checks oauth signature generation by mocking signing function of `requests` library. """ CATEGORY = "lti" @@ -42,7 +45,9 @@ class TestLTI(BaseTestXmodule): saved_sign = requests.auth.Client.sign def mocked_sign(self, *args, **kwargs): - """Mocked oauth1 sign function""" + """ + Mocked oauth1 sign function. + """ # self is here: _, headers, _ = saved_sign(self, *args, **kwargs) # we should replace noonce, timestamp and signed_signature in headers: @@ -57,7 +62,9 @@ class TestLTI(BaseTestXmodule): requests.auth.Client.sign = mocked_sign def test_lti_constructor(self): - """Make sure that all parameters extracted """ + """ + Makes sure that all parameters extracted. + """ self.runtime.render_template = lambda template, context: context generated_context = self.item_module.get_html() expected_context = { From ab3cde8a4d5ef89c6377ad4fd014a7011cfbaa07 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Thu, 5 Sep 2013 19:55:30 +0300 Subject: [PATCH 24/50] Fix --- lms/djangoapps/courseware/tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 2552da9fb9..0a4d3508b8 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -86,7 +86,7 @@ class BaseTestXmodule(ModuleStoreTestCase): data=self.DATA ) - self.runtime = get_test_system(course_id='MITx/999/Robot_Super_Course') + self.runtime = get_test_system(course_id=self.course.id) # Allow us to assert that the template was called in the same way from # different code paths while maintaining the type returned by render_template self.runtime.render_template = lambda template, context: u'{!r}, {!r}'.format(template, sorted(context.items())) From a1876578108088208879badff06429c54ac623eb Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Fri, 6 Sep 2013 10:34:21 +0300 Subject: [PATCH 25/50] Updated fixture for LTI Jasmine tests. --- .../lib/xmodule/xmodule/js/fixtures/lti.html | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/fixtures/lti.html b/common/lib/xmodule/xmodule/js/fixtures/lti.html index 50e712b72d..e5e7ab3f3f 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/lti.html +++ b/common/lib/xmodule/xmodule/js/fixtures/lti.html @@ -9,32 +9,31 @@ encType="application/x-www-form-urlencoded" > - - - - - - - - - - - - - - - + + + + + + + + + + + + + + -

+ Please provide launch_url. Click "Edit", and fill in the required fields.

From 7b637d49d6c611bf14bb68aa0f9edfba3178da17 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 17:22:46 +0300 Subject: [PATCH 26/50] Add comma in last item of list. --- cms/djangoapps/contentstore/views/component.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 9e45e14102..deef87a403 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -53,7 +53,7 @@ ADVANCED_COMPONENT_TYPES = [ 'annotatable', 'word_cloud', 'graphical_slider_tool', - 'lti' + 'lti', ] + OPEN_ENDED_COMPONENT_TYPES + NOTE_COMPONENT_TYPES ADVANCED_COMPONENT_CATEGORY = 'advanced' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' From 787545c83e994e5436417e170cb0d598bdb6e410 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 17:25:13 +0300 Subject: [PATCH 27/50] Renames LTIs to lti_passports. --- common/lib/xmodule/xmodule/course_module.py | 2 +- common/lib/xmodule/xmodule/lti_module.py | 2 +- lms/djangoapps/courseware/features/lti.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index d4fdcb7b60..658a095d14 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -153,7 +153,7 @@ class TextbookList(List): class CourseFields(object): - LTIs = List(help="LTI tools passports as id:client_key:client_secret", scope=Scope.settings) + lti_passports = List(help="LTI tools passports as id:client_key:client_secret", scope=Scope.settings) textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 2ef1c84813..97d4050fd0 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -137,7 +137,7 @@ class LTIModule(LTIFields, XModule): 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.LTIs: + for lti_passport in course.lti_passports: try: lti_id, key, secret = lti_passport.split(':') except ValueError: diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 0ff6bbb809..512e7ea542 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -71,7 +71,7 @@ def incorrect_lti_is_rendered(_step): def set_correct_lti_passport(_step): coursenum = 'test_course' metadata = { - 'LTIs': ["correct_lti_id:{}:{}".format( + 'lti_passports': ["correct_lti_id:{}:{}".format( world.lti_server.oauth_settings['client_key'], world.lti_server.oauth_settings['client_secret'] )] @@ -83,7 +83,7 @@ def set_correct_lti_passport(_step): def set_incorrect_lti_passport(_step): coursenum = 'test_course' metadata = { - 'LTIs': ["test_lti_id:{}:{}".format( + 'lti_passports': ["test_lti_id:{}:{}".format( world.lti_server.oauth_settings['client_key'], "incorrect_lti_secret_key" )] From 663ff81bc1e0db37fbccdb9250b5f8886d541dd4 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 17:34:15 +0300 Subject: [PATCH 28/50] Updates documentation. --- common/lib/xmodule/xmodule/lti_module.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 97d4050fd0..ab4fd5b102 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -65,9 +65,10 @@ class LTIModule(LTIFields, XModule): *+ all custom parameters* These parameters should be encoded and signed by *oauth1* together with - `launch_url` and *POST* request type. 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. + `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. Oauth data which is generated after signing is usual:: @@ -80,7 +81,7 @@ class LTIModule(LTIFields, XModule): oauth_version - All that data is passed to form and sent to LTI provider server by browser via + 4. All that data is passed to form and sent to LTI provider server by browser via autosubmit via javascript. Form example:: @@ -115,7 +116,7 @@ class LTIModule(LTIFields, XModule): - 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. From a2224e682026e2a0e56887ece2d8119fca55cc9c Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 17:39:32 +0300 Subject: [PATCH 29/50] Improve code style. --- common/lib/xmodule/xmodule/lti_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index ab4fd5b102..70928b4f3f 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -137,7 +137,7 @@ class LTIModule(LTIFields, XModule): course_id = self.runtime.course_id course_location = CourseDescriptor.id_to_location(course_id) course = self.descriptor.runtime.modulestore.get_item(course_location) - client_key, client_secret = '', '' + client_key = client_secret = '' for lti_passport in course.lti_passports: try: lti_id, key, secret = lti_passport.split(':') From 32138d3539859b7f4be042c9c3b57b43958eca71 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 17:56:19 +0300 Subject: [PATCH 30/50] Improve code. --- common/lib/xmodule/xmodule/lti_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 70928b4f3f..45064d32cd 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -152,7 +152,7 @@ class LTIModule(LTIFields, XModule): custom_parameters = {} for custom_parameter in self.custom_parameters: try: - param_name, param_value = custom_parameter.split('=') + param_name, param_value = custom_parameter.split('=', 1) except ValueError: raise Exception('Could not parse custom parameter: {0}. \ Should be "x=y" string.'.format(custom_parameter)) From 56f01ce0565f599f270f3ee43f2b926b1fb64bce Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:00:43 +0300 Subject: [PATCH 31/50] Improve code. --- common/lib/xmodule/xmodule/lti_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 45064d32cd..ba7b6b621d 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -142,7 +142,7 @@ class LTIModule(LTIFields, XModule): try: lti_id, key, secret = lti_passport.split(':') except ValueError: - raise Exception('Could not parse LTI passport: {0}. \ + raise Exception('Could not parse LTI passport: {0!r}. \ Should be "id:key:secret" string.'.format(lti_passport)) if lti_id == self.lti_id: client_key, client_secret = key, secret @@ -154,7 +154,7 @@ class LTIModule(LTIFields, XModule): try: param_name, param_value = custom_parameter.split('=', 1) except ValueError: - raise Exception('Could not parse custom parameter: {0}. \ + raise Exception('Could not parse custom parameter: {0!r}. \ Should be "x=y" string.'.format(custom_parameter)) # LTI specs: 'custom_' should be prepended before each custom parameter From 6fc280d61169d75b1f959c960f295756b5975b27 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Fri, 6 Sep 2013 18:02:42 +0300 Subject: [PATCH 32/50] Updated Jasmine tests. Now the introductory text is of a more serious nature. --- .../xmodule/xmodule/js/spec/lti/constructor.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js index bea11bec9c..0c58da7d88 100644 --- a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js +++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js @@ -1,3 +1,21 @@ +/** + * File: constructor.js + * + * Purpose: Jasmine tests for LTI module (front-end part). + * + * + * The front-end part of the LTI module is really simple. If an action + * is set for the hidden LTI form, then it is submited, and the results are + * redirected to an iframe. + * + * We will test that the form is only submited when the action is set (i.e. + * not empty). + * + * Other aspects of LTI module will be covered by Python unit tests and + * acceptance tests. + * + */ + /* * "Hence that general is skilful in attack whose opponent does not know what * to defend; and he is skilful in defense whose opponent does not know what From dd4fe82b0026f68391d11935fff79c9a31b38889 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:03:02 +0300 Subject: [PATCH 33/50] Improve code. --- common/lib/xmodule/xmodule/lti_module.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index ba7b6b621d..3fd2e2f6a6 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -158,9 +158,7 @@ class LTIModule(LTIFields, XModule): Should be "x=y" string.'.format(custom_parameter)) # LTI specs: 'custom_' should be prepended before each custom parameter - custom_parameters.update( - {u'custom_' + unicode(param_name): unicode(param_value)} - ) + custom_parameters[u'custom_' + unicode(param_name)] = unicode(param_value) input_fields = (self.oauth_params( custom_parameters, From 6220da32c9dd4fdf1b2e673d8cbba73393ad6932 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:03:44 +0300 Subject: [PATCH 34/50] Improve code. --- common/lib/xmodule/xmodule/lti_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 3fd2e2f6a6..5ff80239bf 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -160,11 +160,11 @@ class LTIModule(LTIFields, XModule): # LTI specs: 'custom_' should be prepended before each custom parameter custom_parameters[u'custom_' + unicode(param_name)] = unicode(param_value) - input_fields = (self.oauth_params( + input_fields = self.oauth_params( custom_parameters, client_key, client_secret - )) + ) context = { 'input_fields': input_fields, From 5e3c6736aba8460e5631ceb577bcd19a23d4792a Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:04:59 +0300 Subject: [PATCH 35/50] Fixes. --- common/lib/xmodule/xmodule/lti_module.py | 2 +- lms/djangoapps/courseware/tests/test_lti.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 5ff80239bf..f99e792ded 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -214,7 +214,7 @@ class LTIModule(LTIFields, XModule): # This is needed for body encoding: headers = {'Content-Type': 'application/x-www-form-urlencoded'} - _, headers, _ = client.sign( + __, headers, __ = client.sign( unicode(self.launch_url), http_method=u'POST', body=body, diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index e863d47636..c1772dbf9a 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -49,7 +49,7 @@ class TestLTI(BaseTestXmodule): Mocked oauth1 sign function. """ # self is here: - _, headers, _ = saved_sign(self, *args, **kwargs) + __, headers, __ = saved_sign(self, *args, **kwargs) # we should replace noonce, timestamp and signed_signature in headers: old = headers[u'Authorization'] old_parsed = OrderedDict([param.strip().replace('"', '').split('=') for param in old.split(',')]) From 45da4115d150c8e2188efc336da7f319290e51b9 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:19:17 +0300 Subject: [PATCH 36/50] Fix --- common/lib/xmodule/xmodule/tests/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index c518d1afa0..b7e5ea8435 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -66,8 +66,8 @@ def get_test_system(course_id=''): node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), xblock_field_data=lambda descriptor: descriptor._field_data, anonymous_student_id='student', - open_ended_grading_interface= open_ended_grading_interface, - course_id=course_id + open_ended_grading_interface=open_ended_grading_interface, + course_id=course_id, ) From 494fd52de03480f13588b292ff823d565422e397 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:23:11 +0300 Subject: [PATCH 37/50] Fix --- lms/djangoapps/courseware/features/lti.feature | 2 +- lms/djangoapps/courseware/features/lti.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index 80ffeaa56a..9e18a150ad 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -11,7 +11,7 @@ Feature: LTI component And the course has a LTI component filled with correct fields Then I view the LTI and it is rendered - Scenario: LTI component in LMS is rendered incorreclty + Scenario: LTI component in LMS is rendered incorrectly Given the course has a incorrect LTI credentials And the course has a LTI component filled with correct fields Then I view the LTI but incorrect_signature warning is rendered \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 512e7ea542..6378dcded3 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from lettuce import world, step from lettuce.django import django_url -from common import section_location, course_id +from common import course_id from student.models import CourseEnrollment From c4259ba9bfc1ea9675302c69878b7712e9216fae Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:24:07 +0300 Subject: [PATCH 38/50] Fix --- lms/djangoapps/courseware/features/lti.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 6378dcded3..5c2b6e9f38 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -17,7 +17,7 @@ def lti_is_not_rendered(_step): assert world.css_visible('.error_message') # iframe is not visible - assert (not world.css_visible('iframe')) + assert not world.css_visible('iframe') #inside iframe test content is not presented with world.browser.get_iframe('ltiLaunchFrame') as iframe: @@ -31,7 +31,7 @@ def lti_is_rendered(_step): assert world.is_css_present('div.lti.rendered') # error is hidden - assert (not world.css_visible('.error_message')) + assert not world.css_visible('.error_message') # iframe is visible assert world.css_visible('iframe') @@ -52,7 +52,7 @@ def incorrect_lti_is_rendered(_step): assert world.is_css_present('div.lti.rendered') # error is hidden - assert (not world.css_visible('.error_message')) + assert not world.css_visible('.error_message') # iframe is visible assert world.css_visible('iframe') From cf70a986d615d1f4b40cd14cda29f2bf9030735e Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:28:33 +0300 Subject: [PATCH 39/50] Fix --- lms/djangoapps/courseware/features/lti.feature | 2 +- lms/djangoapps/courseware/features/lti.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index 9e18a150ad..7b8186186f 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -12,6 +12,6 @@ Feature: LTI component Then I view the LTI and it is rendered Scenario: LTI component in LMS is rendered incorrectly - Given the course has a incorrect LTI credentials + Given the course has incorrect LTI credentials And the course has a LTI component filled with correct fields Then I view the LTI but incorrect_signature warning is rendered \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 5c2b6e9f38..39ee7caf42 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -79,7 +79,7 @@ def set_correct_lti_passport(_step): i_am_registered_for_the_course(coursenum, metadata) -@step('the course has a incorrect LTI credentials$') +@step('the course has incorrect LTI credentials$') def set_incorrect_lti_passport(_step): coursenum = 'test_course' metadata = { From f45eddc1cb4e5f7d22dc592c7f8f87d7205b3dda Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:31:00 +0300 Subject: [PATCH 40/50] Fix --- lms/djangoapps/courseware/features/lti.feature | 6 +++--- lms/djangoapps/courseware/features/lti.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index 7b8186186f..abdcfdb704 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -3,15 +3,15 @@ Feature: LTI component Scenario: LTI component in LMS is not rendered Given the course has correct LTI credentials - And the course has a LTI component with incorrect fields + And the course has an LTI component with incorrect fields Then I view the LTI and it is not rendered Scenario: LTI component in LMS is rendered Given the course has correct LTI credentials - And the course has a LTI component filled with correct fields + And the course has an LTI component filled with correct fields Then I view the LTI and it is rendered Scenario: LTI component in LMS is rendered incorrectly Given the course has incorrect LTI credentials - And the course has a LTI component filled with correct fields + And the course has an LTI component filled with correct fields Then I view the LTI but incorrect_signature warning is rendered \ No newline at end of file diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index 39ee7caf42..bc72f8eae0 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -91,7 +91,7 @@ def set_incorrect_lti_passport(_step): i_am_registered_for_the_course(coursenum, metadata) -@step('the course has a LTI component filled with correct fields$') +@step('the course has an LTI component filled with correct fields$') def add_correct_lti_to_course(_step): category = 'lti' world.ItemFactory.create( @@ -116,7 +116,7 @@ def add_correct_lti_to_course(_step): world.browser.visit(url) -@step('the course has a LTI component with incorrect fields$') +@step('the course has an LTI component with incorrect fields$') def add_incorrect_lti_to_course(_step): category = 'lti' world.ItemFactory.create( From a5016473b2a462fa5834a914d12848f2eeb502c6 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:34:00 +0300 Subject: [PATCH 41/50] Fix --- .../courseware/mock_lti_server/mock_lti_server.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 afbbcf40a6..ba9cea84d6 100644 --- a/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py @@ -82,13 +82,8 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler): post_dict = urlparse.parse_qs(self.rfile.read(length), keep_blank_values=True) # The POST dict will contain a list of values for each key. # None of our parameters are lists, however, so we map [val] --> val. - #I f the list contains multiple entries, we pick the first one - post_dict = dict( - map( - lambda (key, list_val): (key, list_val[0]), - post_dict.items() - ) - ) + # If the list contains multiple entries, we pick the first one + post_dict = {key: val[0] for key, val in post_dict.items()} except: # We return an empty dict here, on the assumption # that when we later check that the request has From 5e8aebf00ba62bfd7050dc1ccf29f6db5eec422e Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:37:02 +0300 Subject: [PATCH 42/50] Fix --- lms/djangoapps/courseware/tests/test_lti.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index c1772dbf9a..ef30c9c75e 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -19,7 +19,7 @@ class TestLTI(BaseTestXmodule): Mock oauth1 signing of requests library for testing. """ super(TestLTI, self).setUp() - mocked_noonce = u'135685044251684026041377608307' + mocked_nonce = u'135685044251684026041377608307' mocked_timestamp = u'1234567890' mocked_signature_after_sign = u'my_signature%3D' mocked_decoded_signature = u'my_signature=' @@ -32,7 +32,7 @@ class TestLTI(BaseTestXmodule): u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', - u'oauth_nonce': mocked_noonce, + u'oauth_nonce': mocked_nonce, u'oauth_timestamp': mocked_timestamp, u'oauth_consumer_key': u'', u'oauth_signature_method': u'HMAC-SHA1', @@ -53,7 +53,7 @@ class TestLTI(BaseTestXmodule): # we should replace noonce, timestamp and signed_signature in headers: old = headers[u'Authorization'] old_parsed = OrderedDict([param.strip().replace('"', '').split('=') for param in old.split(',')]) - old_parsed[u'OAuth oauth_nonce'] = mocked_noonce + old_parsed[u'OAuth oauth_nonce'] = mocked_nonce old_parsed[u'oauth_timestamp'] = mocked_timestamp old_parsed[u'oauth_signature'] = mocked_signature_after_sign headers[u'Authorization'] = ', '.join([k+'="'+v+'"' for k, v in old_parsed.items()]) From c63170aea583acc701be0330bfc2da1dab1c0960 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 6 Sep 2013 18:37:17 +0300 Subject: [PATCH 43/50] Fix --- lms/djangoapps/courseware/tests/test_lti.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index ef30c9c75e..7034607969 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -50,7 +50,7 @@ class TestLTI(BaseTestXmodule): """ # self is here: __, headers, __ = saved_sign(self, *args, **kwargs) - # we should replace noonce, timestamp and signed_signature in headers: + # we should replace nonce, timestamp and signed_signature in headers: old = headers[u'Authorization'] old_parsed = OrderedDict([param.strip().replace('"', '').split('=') for param in old.split(',')]) old_parsed[u'OAuth oauth_nonce'] = mocked_nonce From 86dbb1fe3f7ce2728ea859d0d6ed6eeecbf91072 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 9 Sep 2013 17:28:46 +0300 Subject: [PATCH 44/50] Addressing comments on PR 804. Rewriting expect() calls to use Jasmine jQuery event spies. Making individual it() tests independent. --- .../xmodule/js/spec/lti/constructor.js | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js index 0c58da7d88..0a73496bed 100644 --- a/common/lib/xmodule/xmodule/js/spec/lti/constructor.js +++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js @@ -26,55 +26,57 @@ (function () { describe('LTI', function () { - var element, errorMessage, frame, - editSettings = false; - - // This function will be executed before each of the it() specs - // in this suite. - beforeEach(function () { - spyOn($.fn, 'submit').andCallThrough(); - - loadFixtures('lti.html'); - - element = $('#lti_id'); - errorMessage = element.find('.error_message'); - form = element.find('.ltiLaunchForm'); - frame = element.find('.ltiLaunchFrame'); - - // First part of the tests will be running without the settings - // filled in. Once we reach the describe() spec - // - // "After the settings were filled in" - // - // the variable `editSettings` will be changed to `true`. - if (editSettings) { - form.attr('action', 'http://www.example.com/'); - } - - LTI(element); - }); - describe('constructor', function () { describe('before settings were filled in', function () { + var element, errorMessage, frame; + + // This function will be executed before each of the it() specs + // in this suite. + beforeEach(function () { + loadFixtures('lti.html'); + + element = $('#lti_id'); + errorMessage = element.find('.error_message'); + form = element.find('.ltiLaunchForm'); + frame = element.find('.ltiLaunchFrame'); + + spyOnEvent(form, 'submit'); + + LTI(element); + }); + it( 'when URL setting is filled form is not submited', function () { - expect($.fn.submit).not.toHaveBeenCalled(); + expect('submit').not.toHaveBeenTriggeredOn(form); }); }); describe('After the settings were filled in', function () { - it('editSettings is disabled', function () { - expect(editSettings).toBe(false); + var element, errorMessage, frame; - // Let us toggle edit settings switch. Next beforeEach() - // will populate element's attributes with settings. - editSettings = true; + // This function will be executed before each of the it() specs + // in this suite. + beforeEach(function () { + loadFixtures('lti.html'); + + element = $('#lti_id'); + errorMessage = element.find('.error_message'); + form = element.find('.ltiLaunchForm'); + frame = element.find('.ltiLaunchFrame'); + + spyOnEvent(form, 'submit'); + + // The user "fills in" the necessary settings, and the + // form will get an action URL. + form.attr('action', 'http://www.example.com/'); + + LTI(element); }); it('when URL setting is filled form is submited', function () { - expect($.fn.submit).toHaveBeenCalled(); + expect('submit').toHaveBeenTriggeredOn(form); }); }); }); From ef4b7b9b1d5402cba3f40c6a27b3accf3decd75e Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 9 Sep 2013 18:41:48 +0300 Subject: [PATCH 45/50] Add custom LTI exception. --- common/lib/xmodule/xmodule/exceptions.py | 8 ++++++++ common/lib/xmodule/xmodule/lti_module.py | 1 + 2 files changed, 9 insertions(+) diff --git a/common/lib/xmodule/xmodule/exceptions.py b/common/lib/xmodule/xmodule/exceptions.py index 48c083cbf1..58d0b0abad 100644 --- a/common/lib/xmodule/xmodule/exceptions.py +++ b/common/lib/xmodule/xmodule/exceptions.py @@ -31,3 +31,11 @@ class SerializationError(Exception): def __init__(self, location, msg): super(SerializationError, self).__init__(msg) self.location = location + + +class LTIError(Exception): + """ + An error occured when parsing LTI paramaters from + course settings or LTI unit + """ + pass diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index f99e792ded..f6bb4b7997 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -14,6 +14,7 @@ import urllib from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.x_module import XModule +from xmodule.exceptions import LTIError from xmodule.course_module import CourseDescriptor from pkg_resources import resource_string from xblock.core import String, Scope, List From bd6fed83ebc0ccab7f4662f853e943ba82720a19 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 9 Sep 2013 18:45:25 +0300 Subject: [PATCH 46/50] Fix. --- lms/djangoapps/courseware/features/lti.py | 30 ++++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index bc72f8eae0..ed7832b2c9 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -104,15 +104,18 @@ def add_correct_lti_to_course(_step): 'launch_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] } ) + course = world.scenario_dict["COURSE"] chapter_name = world.scenario_dict['SECTION'].display_name.replace( " ", "_") section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( - world.scenario_dict['COURSE'].org, - world.scenario_dict['COURSE'].number, - world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,) - ) + path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format( + org=course.org, + num=course.num, + name=course.display_name.replace(' ', '_'), + chapter=chapter_name, + section=section_name) + url = django_url(path) + world.browser.visit(url) @@ -128,15 +131,18 @@ def add_incorrect_lti_to_course(_step): 'lti_url': world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint'] } ) + course = world.scenario_dict["COURSE"] chapter_name = world.scenario_dict['SECTION'].display_name.replace( " ", "_") section_name = chapter_name - url = django_url('/courses/%s/%s/%s/courseware/%s/%s' % ( - world.scenario_dict['COURSE'].org, - world.scenario_dict['COURSE'].number, - world.scenario_dict['COURSE'].display_name.replace(' ', '_'), - chapter_name, section_name,) - ) + path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format( + org=course.org, + num=course.num, + name=course.display_name.replace(' ', '_'), + chapter=chapter_name, + section=section_name) + url = django_url(path) + world.browser.visit(url) From 03de84ecfe04878f0be58219916dedc5607af576 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 9 Sep 2013 18:55:54 +0300 Subject: [PATCH 47/50] Fix --- common/lib/xmodule/xmodule/lti_module.py | 4 ++-- lms/djangoapps/courseware/features/lti.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index f6bb4b7997..58537b8857 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -143,7 +143,7 @@ class LTIModule(LTIFields, XModule): try: lti_id, key, secret = lti_passport.split(':') except ValueError: - raise Exception('Could not parse LTI passport: {0!r}. \ + raise LTIError('Could not parse LTI passport: {0!r}. \ Should be "id:key:secret" string.'.format(lti_passport)) if lti_id == self.lti_id: client_key, client_secret = key, secret @@ -155,7 +155,7 @@ class LTIModule(LTIFields, XModule): try: param_name, param_value = custom_parameter.split('=', 1) except ValueError: - raise Exception('Could not parse custom parameter: {0!r}. \ + raise LTIError('Could not parse custom parameter: {0!r}. \ Should be "x=y" string.'.format(custom_parameter)) # LTI specs: 'custom_' should be prepended before each custom parameter diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py index ed7832b2c9..0e91d5ed02 100644 --- a/lms/djangoapps/courseware/features/lti.py +++ b/lms/djangoapps/courseware/features/lti.py @@ -110,7 +110,7 @@ def add_correct_lti_to_course(_step): section_name = chapter_name path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format( org=course.org, - num=course.num, + num=course.number, name=course.display_name.replace(' ', '_'), chapter=chapter_name, section=section_name) @@ -137,7 +137,7 @@ def add_incorrect_lti_to_course(_step): section_name = chapter_name path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format( org=course.org, - num=course.num, + num=course.number, name=course.display_name.replace(' ', '_'), chapter=chapter_name, section=section_name) From cd72f0577820d4e27adf90521841d5d48e103caf Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 9 Sep 2013 19:01:51 +0300 Subject: [PATCH 48/50] Fix test --- lms/djangoapps/courseware/tests/test_lti.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py index 7034607969..d2b4ea6867 100644 --- a/lms/djangoapps/courseware/tests/test_lti.py +++ b/lms/djangoapps/courseware/tests/test_lti.py @@ -3,6 +3,7 @@ import requests from . import BaseTestXmodule from collections import OrderedDict +import mock class TestLTI(BaseTestXmodule): @@ -59,7 +60,9 @@ class TestLTI(BaseTestXmodule): headers[u'Authorization'] = ', '.join([k+'="'+v+'"' for k, v in old_parsed.items()]) return None, headers, None - requests.auth.Client.sign = mocked_sign + patcher = mock.patch.object(requests.auth.Client, "sign", mocked_sign) + patcher.start() + self.addCleanup(patcher.stop) def test_lti_constructor(self): """ From 8377f490f058ba7783e888ba644973040d5f566c Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 9 Sep 2013 19:30:44 +0300 Subject: [PATCH 49/50] Revert "Add custom LTI exception." This reverts commit ef4b7b9b1d5402cba3f40c6a27b3accf3decd75e. --- common/lib/xmodule/xmodule/exceptions.py | 8 -------- common/lib/xmodule/xmodule/lti_module.py | 1 - 2 files changed, 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/exceptions.py b/common/lib/xmodule/xmodule/exceptions.py index 58d0b0abad..48c083cbf1 100644 --- a/common/lib/xmodule/xmodule/exceptions.py +++ b/common/lib/xmodule/xmodule/exceptions.py @@ -31,11 +31,3 @@ class SerializationError(Exception): def __init__(self, location, msg): super(SerializationError, self).__init__(msg) self.location = location - - -class LTIError(Exception): - """ - An error occured when parsing LTI paramaters from - course settings or LTI unit - """ - pass diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 58537b8857..962d7782ff 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -14,7 +14,6 @@ import urllib from xmodule.editing_module import MetadataOnlyEditingDescriptor from xmodule.x_module import XModule -from xmodule.exceptions import LTIError from xmodule.course_module import CourseDescriptor from pkg_resources import resource_string from xblock.core import String, Scope, List From ecc37227f2f1a29938efbdef0556f369362b4146 Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Mon, 9 Sep 2013 19:32:44 +0300 Subject: [PATCH 50/50] Fix --- common/lib/xmodule/xmodule/lti_module.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 962d7782ff..bc07cea97e 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -21,6 +21,10 @@ from xblock.core import String, Scope, List log = logging.getLogger(__name__) +class LTIError(Exception): + pass + + class LTIFields(object): """ Fields to define and obtain LTI tool from provider are set here,