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 @@
+
+
+
+
+
+ Please provide LTI url. Click "Edit", and fill in the
+ required fields.
+
+
+
+
+
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.
+
+
+
+ Please provide LTI url. Click "Edit", and fill in the
+ required fields.
+
+
+ ## The result of the form submit will be rendered here.
+
+
+