LTI module with tests
This commit is contained in:
committed by
Alexander Kryklia
parent
0052b87c37
commit
417ddcaaa1
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
7
common/lib/xmodule/xmodule/css/lti/lti.scss
Normal file
7
common/lib/xmodule/xmodule/css/lti/lti.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
div.lti {
|
||||
h2.error_message {
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
common/lib/xmodule/xmodule/js/fixtures/lti.html
Normal file
44
common/lib/xmodule/xmodule/js/fixtures/lti.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<div align="center" id="lti_id" class="lti">
|
||||
|
||||
<form
|
||||
action=""
|
||||
name="ltiLaunchForm"
|
||||
id="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
|
||||
<input type="hidden" name="launch_presentation_return_url" value="">
|
||||
<input type="hidden" name="lis_outcome_service_url" value="">
|
||||
<input type="hidden" name="lis_result_sourcedid" value="">
|
||||
<input type="hidden" name="lti_message_type" value="basic-lti-launch-request">
|
||||
<input type="hidden" name="lti_version" value="LTI-1p0">
|
||||
<input type="hidden" name="oauth_callback" value="about:blank">
|
||||
<input type="hidden" name="oauth_consumer_key" value=""/>
|
||||
<input type="hidden" name="oauth_nonce" value=""/>
|
||||
<input type="hidden" name="oauth_signature_method" value="HMAC-SHA1"/>
|
||||
<input type="hidden" name="oauth_timestamp" value=""/>
|
||||
<input type="hidden" name="oauth_version" value="1.0"/>
|
||||
<input type="hidden" name="user_id" value="default_user_id">
|
||||
|
||||
<input type="hidden" name="oauth_signature" value=""/>
|
||||
<input type="submit" value="Press to Launch" style="display: none"/>
|
||||
|
||||
</form>
|
||||
|
||||
<h2 class="error_message hidden">
|
||||
Please provide LTI url. Click "Edit", and fill in the
|
||||
required fields.
|
||||
</h2>
|
||||
|
||||
<iframe
|
||||
name="ltiLaunchFrame"
|
||||
id="ltiLaunchFrame"
|
||||
width="0"
|
||||
height="0"
|
||||
src=""
|
||||
style="border: 0px; overflow-x: hidden;"
|
||||
></iframe>
|
||||
|
||||
</div>
|
||||
111
common/lib/xmodule/xmodule/js/spec/lti/constructor.js
Normal file
111
common/lib/xmodule/xmodule/js/spec/lti/constructor.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
49
common/lib/xmodule/xmodule/js/src/lti/lti.js
Normal file
49
common/lib/xmodule/xmodule/js/src/lti/lti.js
Normal file
@@ -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;
|
||||
}());
|
||||
93
common/lib/xmodule/xmodule/lti_module.py
Normal file
93
common/lib/xmodule/xmodule/lti_module.py
Normal file
@@ -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
|
||||
54
lms/djangoapps/courseware/tests/test_lti.py
Normal file
54
lms/djangoapps/courseware/tests/test_lti.py
Normal file
@@ -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 <oauthlib.oauth1.rfc5849.Client object at 0x107456e90> 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)
|
||||
)
|
||||
48
lms/templates/lti.html
Normal file
48
lms/templates/lti.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<div align="center" id="${element_id}" class="${element_class}">
|
||||
|
||||
## 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.
|
||||
<form
|
||||
action="${lti_url}"
|
||||
name="ltiLaunchForm"
|
||||
id="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
|
||||
<input type="hidden" name="launch_presentation_return_url" value="">
|
||||
<input type="hidden" name="lis_outcome_service_url" value="">
|
||||
<input type="hidden" name="lis_result_sourcedid" value="">
|
||||
<input type="hidden" name="lti_message_type" value="basic-lti-launch-request">
|
||||
<input type="hidden" name="lti_version" value="LTI-1p0">
|
||||
<input type="hidden" name="oauth_callback" value="about:blank">
|
||||
<input type="hidden" name="oauth_consumer_key" value="${oauth_consumer_key}"/>
|
||||
<input type="hidden" name="oauth_nonce" value="${oauth_nonce}"/>
|
||||
<input type="hidden" name="oauth_signature_method" value="HMAC-SHA1"/>
|
||||
<input type="hidden" name="oauth_timestamp" value="${oauth_timestamp}"/>
|
||||
<input type="hidden" name="oauth_version" value="1.0"/>
|
||||
<input type="hidden" name="user_id" value="default_user_id">
|
||||
|
||||
<input type="hidden" name="oauth_signature" value="${oauth_signature}"/>
|
||||
<input type="submit" value="Press to Launch" style="display: none"/>
|
||||
|
||||
</form>
|
||||
|
||||
<h3 class="error_message hidden">
|
||||
Please provide LTI url. Click "Edit", and fill in the
|
||||
required fields.
|
||||
</h3>
|
||||
|
||||
## The result of the form submit will be rendered here.
|
||||
<iframe
|
||||
name="ltiLaunchFrame"
|
||||
id="ltiLaunchFrame"
|
||||
width="0"
|
||||
height="0"
|
||||
src=""
|
||||
style="border: 0px; overflow-x: hidden;"
|
||||
></iframe>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user