From 417ddcaaa172fd9b8c007a36b07914767ee690d5 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Mon, 19 Aug 2013 17:25:13 +0300 Subject: [PATCH] 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. + + +