Add Learning Tools Interoperability (LTI) blade.
LTI blade allows to include LTI components to courses. Python integration, Jasmine and acceptance tests are included.
This commit is contained in:
committed by
Alexander Kryklia
parent
0734d835a1
commit
d02ef8bc12
@@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
|
||||
Blades: Added Learning Tools Interoperability (LTI) blade. Now LTI components
|
||||
can be included to courses.
|
||||
|
||||
LMS: Added alphabetical sorting of forum categories and subcategories.
|
||||
It is hidden behind a false defaulted course level flag.
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -153,6 +153,7 @@ class TextbookList(List):
|
||||
|
||||
|
||||
class CourseFields(object):
|
||||
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)
|
||||
|
||||
30
common/lib/xmodule/xmodule/css/lti/lti.scss
Normal file
30
common/lib/xmodule/xmodule/css/lti/lti.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
div.lti {
|
||||
// align center
|
||||
margin: 0 auto;
|
||||
|
||||
h3.error_message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
form.ltiLaunchForm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
iframe.ltiLaunchFrame {
|
||||
width: 100%;
|
||||
height: 800px;
|
||||
display: none;
|
||||
border: 0px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&.rendered {
|
||||
iframe.ltiLaunchFrame {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h3.error_message {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
40
common/lib/xmodule/xmodule/js/fixtures/lti.html
Normal file
40
common/lib/xmodule/xmodule/js/fixtures/lti.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<div id="lti_id" class="lti">
|
||||
|
||||
<form
|
||||
action=""
|
||||
name="ltiLaunchForm"
|
||||
class="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" />
|
||||
</form>
|
||||
|
||||
<h3 class="error_message">
|
||||
Please provide launch_url. Click "Edit", and fill in the
|
||||
required fields.
|
||||
</h3>
|
||||
|
||||
<iframe
|
||||
name="ltiLaunchFrame"
|
||||
class="ltiLaunchFrame"
|
||||
src=""
|
||||
></iframe>
|
||||
|
||||
</div>
|
||||
84
common/lib/xmodule/xmodule/js/spec/lti/constructor.js
Normal file
84
common/lib/xmodule/xmodule/js/spec/lti/constructor.js
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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
|
||||
* to attack."
|
||||
*
|
||||
* ~ Sun Tzu
|
||||
*/
|
||||
|
||||
(function () {
|
||||
describe('LTI', function () {
|
||||
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('submit').not.toHaveBeenTriggeredOn(form);
|
||||
});
|
||||
});
|
||||
|
||||
describe('After the 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');
|
||||
|
||||
// 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('submit').toHaveBeenTriggeredOn(form);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}());
|
||||
26
common/lib/xmodule/xmodule/js/src/lti/lti.js
Normal file
26
common/lib/xmodule/xmodule/js/src/lti/lti.js
Normal file
@@ -0,0 +1,26 @@
|
||||
window.LTI = (function () {
|
||||
// Function initialize(element)
|
||||
//
|
||||
// Initialize the LTI iframe.
|
||||
function initialize(element) {
|
||||
var form;
|
||||
|
||||
// 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');
|
||||
|
||||
// If the Form's action attribute is set (i.e. we can perform a normal
|
||||
// submit), then we submit the form and make the frame shown.
|
||||
if (form.attr('action')) {
|
||||
form.submit();
|
||||
element.find('.lti').addClass('rendered')
|
||||
}
|
||||
}
|
||||
|
||||
return initialize;
|
||||
}());
|
||||
249
common/lib/xmodule/xmodule/lti_module.py
Normal file
249
common/lib/xmodule/xmodule/lti_module.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Module that allows to insert LTI tools to page.
|
||||
|
||||
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:
|
||||
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
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
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LTIError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class LTIFields(object):
|
||||
"""
|
||||
Fields to define and obtain LTI tool from provider are set here,
|
||||
except credentials, which should be set in course settings::
|
||||
|
||||
`lti_id` is id to connect tool with credentials in course settings.
|
||||
`launch_url` is launch url of tool.
|
||||
`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 (vbid, book_location, etc..)", scope=Scope.settings)
|
||||
|
||||
|
||||
class LTIModule(LTIFields, XModule):
|
||||
'''
|
||||
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.
|
||||
|
||||
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::
|
||||
|
||||
oauth_callback
|
||||
oauth_nonce
|
||||
oauth_consumer_key
|
||||
oauth_signature_method
|
||||
oauth_timestamp
|
||||
oauth_version
|
||||
|
||||
|
||||
4. All that data is passed to form and sent to LTI provider server by browser via
|
||||
autosubmit via javascript.
|
||||
|
||||
Form example::
|
||||
|
||||
<form
|
||||
action="${launch_url}"
|
||||
name="ltiLaunchForm"
|
||||
class="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
<input name="launch_presentation_return_url" value="" />
|
||||
<input name="lis_outcome_service_url" value="" />
|
||||
<input name="lis_result_sourcedid" value="" />
|
||||
<input name="lti_message_type" value="basic-lti-launch-request" />
|
||||
<input name="lti_version" value="LTI-1p0" />
|
||||
<input name="oauth_callback" value="about:blank" />
|
||||
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
|
||||
<input name="oauth_nonce" value="${oauth_nonce}" />
|
||||
<input name="oauth_signature_method" value="HMAC-SHA1" />
|
||||
<input name="oauth_timestamp" value="${oauth_timestamp}" />
|
||||
<input name="oauth_version" value="1.0" />
|
||||
<input name="user_id" value="${user_id}" />
|
||||
<input name="role" value="student" />
|
||||
<input name="oauth_signature" value="${oauth_signature}" />
|
||||
|
||||
<input name="custom_1" value="${custom_param_1_value}" />
|
||||
<input name="custom_2" value="${custom_param_2_value}" />
|
||||
<input name="custom_..." value="${custom_param_..._value}" />
|
||||
|
||||
<input type="submit" value="Press to Launch" />
|
||||
</form>
|
||||
|
||||
5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures.
|
||||
|
||||
If signatures are correct, LTI provider redirects iframe source to LTI tool web page,
|
||||
and LTI tool is rendered to iframe inside course.
|
||||
|
||||
Otherwise error message from LTI provider is generated.
|
||||
'''
|
||||
|
||||
js = {'js': [resource_string(__name__, 'js/src/lti/lti.js')]}
|
||||
css = {'scss': [resource_string(__name__, 'css/lti/lti.scss')]}
|
||||
js_module_name = "LTI"
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Renders parameters to template.
|
||||
"""
|
||||
|
||||
# Obtains client_key and client_secret credentials from current course:
|
||||
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.lti_passports:
|
||||
try:
|
||||
lti_id, key, secret = lti_passport.split(':')
|
||||
except ValueError:
|
||||
raise LTIError('Could not parse LTI passport: {0!r}. \
|
||||
Should be "id:key:secret" string.'.format(lti_passport))
|
||||
if lti_id == self.lti_id:
|
||||
client_key, client_secret = key, secret
|
||||
break
|
||||
|
||||
# parsing custom parameters to dict
|
||||
custom_parameters = {}
|
||||
for custom_parameter in self.custom_parameters:
|
||||
try:
|
||||
param_name, param_value = custom_parameter.split('=', 1)
|
||||
except ValueError:
|
||||
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
|
||||
custom_parameters[u'custom_' + unicode(param_name)] = unicode(param_value)
|
||||
|
||||
input_fields = self.oauth_params(
|
||||
custom_parameters,
|
||||
client_key,
|
||||
client_secret
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
`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)
|
||||
)
|
||||
|
||||
user_id = self.runtime.anonymous_student_id
|
||||
assert user_id is not None
|
||||
|
||||
# must have parameters for correct signing from LTI:
|
||||
body = {
|
||||
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
|
||||
body.update(custom_parameters)
|
||||
|
||||
# This is needed for body encoding:
|
||||
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
|
||||
__, headers, __ = client.sign(
|
||||
unicode(self.launch_url),
|
||||
http_method=u'POST',
|
||||
body=body,
|
||||
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[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'.
|
||||
# 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')
|
||||
|
||||
# add lti parameters to oauth parameters for sending in form
|
||||
params.update(body)
|
||||
return params
|
||||
|
||||
|
||||
class LTIModuleDescriptor(LTIFields, MetadataOnlyEditingDescriptor):
|
||||
"""
|
||||
LTIModuleDescriptor provides no export/import to xml.
|
||||
"""
|
||||
module_class = LTIModule
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -95,6 +95,14 @@ Html
|
||||
:members:
|
||||
:show-inheritance:
|
||||
|
||||
|
||||
LTI
|
||||
===
|
||||
|
||||
.. automodule:: xmodule.lti_module
|
||||
:members:
|
||||
:show-inheritance:
|
||||
|
||||
Mako
|
||||
====
|
||||
|
||||
|
||||
17
lms/djangoapps/courseware/features/lti.feature
Normal file
17
lms/djangoapps/courseware/features/lti.feature
Normal file
@@ -0,0 +1,17 @@
|
||||
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 correct LTI credentials
|
||||
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 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 an LTI component filled with correct fields
|
||||
Then I view the LTI but incorrect_signature warning is rendered
|
||||
188
lms/djangoapps/courseware/features/lti.py
Normal file
188
lms/djangoapps/courseware/features/lti.py
Normal file
@@ -0,0 +1,188 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
from common import course_id
|
||||
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
@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)
|
||||
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 correct LTI credentials$')
|
||||
def set_correct_lti_passport(_step):
|
||||
coursenum = 'test_course'
|
||||
metadata = {
|
||||
'lti_passports': ["correct_lti_id:{}:{}".format(
|
||||
world.lti_server.oauth_settings['client_key'],
|
||||
world.lti_server.oauth_settings['client_secret']
|
||||
)]
|
||||
}
|
||||
i_am_registered_for_the_course(coursenum, metadata)
|
||||
|
||||
|
||||
@step('the course has incorrect LTI credentials$')
|
||||
def set_incorrect_lti_passport(_step):
|
||||
coursenum = 'test_course'
|
||||
metadata = {
|
||||
'lti_passports': ["test_lti_id:{}:{}".format(
|
||||
world.lti_server.oauth_settings['client_key'],
|
||||
"incorrect_lti_secret_key"
|
||||
)]
|
||||
}
|
||||
i_am_registered_for_the_course(coursenum, metadata)
|
||||
|
||||
|
||||
@step('the course has an 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=world.scenario_dict['SEQUENTIAL'].location,
|
||||
category=category,
|
||||
display_name='LTI',
|
||||
metadata={
|
||||
'lti_id': 'correct_lti_id',
|
||||
'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
|
||||
path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format(
|
||||
org=course.org,
|
||||
num=course.number,
|
||||
name=course.display_name.replace(' ', '_'),
|
||||
chapter=chapter_name,
|
||||
section=section_name)
|
||||
url = django_url(path)
|
||||
|
||||
world.browser.visit(url)
|
||||
|
||||
|
||||
@step('the course has an LTI component with incorrect fields$')
|
||||
def add_incorrect_lti_to_course(_step):
|
||||
category = 'lti'
|
||||
world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['SEQUENTIAL'].location,
|
||||
category=category,
|
||||
display_name='LTI',
|
||||
metadata={
|
||||
'lti_id': 'incorrect_lti_id',
|
||||
'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
|
||||
path = "/courses/{org}/{num}/{name}/courseware/{chapter}/{section}".format(
|
||||
org=course.org,
|
||||
num=course.number,
|
||||
name=course.display_name.replace(' ', '_'),
|
||||
chapter=chapter_name,
|
||||
section=section_name)
|
||||
url = django_url(path)
|
||||
|
||||
world.browser.visit(url)
|
||||
|
||||
|
||||
def create_course(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.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['SECTION'].location,
|
||||
category='sequential',
|
||||
display_name='Test Section')
|
||||
|
||||
|
||||
def i_am_registered_for_the_course(course, metadata):
|
||||
# Create the course
|
||||
create_course(course, metadata)
|
||||
|
||||
# Create the user
|
||||
world.create_user('robot', 'test')
|
||||
usr = User.objects.get(username='robot')
|
||||
|
||||
# If the user is not already enrolled, enroll the user.
|
||||
CourseEnrollment.enroll(usr, course_id(course))
|
||||
|
||||
world.log_in(username='robot', password='test')
|
||||
50
lms/djangoapps/courseware/features/lti_setup.py
Normal file
50
lms/djangoapps/courseware/features/lti_setup.py
Normal file
@@ -0,0 +1,50 @@
|
||||
#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():
|
||||
|
||||
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(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
|
||||
# when the main thread terminates.
|
||||
server_thread = threading.Thread(target=server.serve_forever)
|
||||
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
|
||||
|
||||
|
||||
@after.all
|
||||
def teardown_mock_lti_server(total):
|
||||
|
||||
# Stop the LTI server and free up the port
|
||||
world.lti_server.shutdown()
|
||||
167
lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
Normal file
167
lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
|
||||
import urlparse
|
||||
from requests.packages.oauthlib.oauth1.rfc5849 import signature
|
||||
import mock
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
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_keys = [
|
||||
'user_id',
|
||||
'role',
|
||||
'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_keys) != sorted(post_dict.keys()):
|
||||
status_message = "Incorrect LTI header"
|
||||
else:
|
||||
params = {k: v for k, v in post_dict.items() if k != 'oauth_signature'}
|
||||
if self.server.check_oauth_signature(params, post_dict['oauth_signature']):
|
||||
status_message = "This is LTI tool. Success."
|
||||
else:
|
||||
status_message = "Wrong LTI signature"
|
||||
else:
|
||||
status_message = "Invalid request URL"
|
||||
|
||||
self._send_response(status_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), 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.
|
||||
# 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
|
||||
# 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 = """<html><head><title>TEST TITLE</title></head>
|
||||
<body>
|
||||
<div><h2>IFrame loaded</h2> \
|
||||
<h3>Server response is:</h3>\
|
||||
<h3 class="result">{}</h3></div>
|
||||
</body></html>""".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 LTI tool is correct.'''
|
||||
return self.server.oauth_settings['lti_endpoint'] in self.path
|
||||
|
||||
|
||||
class MockLTIServer(HTTPServer):
|
||||
'''
|
||||
A mock LTI provider server that responds
|
||||
to POST requests to localhost.
|
||||
'''
|
||||
|
||||
def __init__(self, address):
|
||||
'''
|
||||
Initialize the mock XQueue server instance.
|
||||
|
||||
*address* is the (host, host's port to listen to) tuple.
|
||||
'''
|
||||
handler = MockLTIRequestHandler
|
||||
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 check_oauth_signature(self, params, client_signature):
|
||||
'''
|
||||
Checks oauth signature from client.
|
||||
|
||||
`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)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Test for Mock_LTI_Server
|
||||
"""
|
||||
import unittest
|
||||
import threading
|
||||
import urllib
|
||||
from mock_lti_server import MockLTIServer
|
||||
|
||||
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
|
||||
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
|
||||
server_thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
|
||||
# Stop the server, freeing up the port
|
||||
self.server.shutdown()
|
||||
|
||||
def test_request(self):
|
||||
"""
|
||||
Tests that LTI server processes request with right program
|
||||
path, and responses with incorrect signature.
|
||||
"""
|
||||
request = {
|
||||
'user_id': 'default_user_id',
|
||||
'role': 'student',
|
||||
'oauth_nonce': '',
|
||||
'oauth_timestamp': '',
|
||||
'oauth_consumer_key': 'client_key',
|
||||
'lti_version': 'LTI-1p0',
|
||||
'oauth_signature_method': 'HMAC-SHA1',
|
||||
'oauth_version': '1.0',
|
||||
'oauth_signature': '',
|
||||
'lti_message_type': 'basic-lti-launch-request',
|
||||
'oauth_callback': 'about:blank',
|
||||
'launch_presentation_return_url': '',
|
||||
'lis_outcome_service_url': '',
|
||||
'lis_result_sourcedid': ''
|
||||
}
|
||||
|
||||
response_handle = urllib.urlopen(
|
||||
self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint'],
|
||||
urllib.urlencode(request)
|
||||
)
|
||||
response = response_handle.read()
|
||||
self.assertTrue('Wrong LTI signature' in response)
|
||||
@@ -86,7 +86,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
|
||||
data=self.DATA
|
||||
)
|
||||
|
||||
self.runtime = get_test_system()
|
||||
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()))
|
||||
|
||||
79
lms/djangoapps/courseware/tests/test_lti.py
Normal file
79
lms/djangoapps/courseware/tests/test_lti.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""LTI integration tests"""
|
||||
|
||||
import requests
|
||||
from . import BaseTestXmodule
|
||||
from collections import OrderedDict
|
||||
import mock
|
||||
|
||||
|
||||
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"
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Mock oauth1 signing of requests library for testing.
|
||||
"""
|
||||
super(TestLTI, self).setUp()
|
||||
mocked_nonce = u'135685044251684026041377608307'
|
||||
mocked_timestamp = u'1234567890'
|
||||
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_nonce,
|
||||
u'oauth_timestamp': mocked_timestamp,
|
||||
u'oauth_consumer_key': u'',
|
||||
u'oauth_signature_method': u'HMAC-SHA1',
|
||||
u'oauth_version': u'1.0',
|
||||
u'user_id': self.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 <oauthlib.oauth1.rfc5849.Client object> here:
|
||||
__, headers, __ = saved_sign(self, *args, **kwargs)
|
||||
# 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
|
||||
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
|
||||
|
||||
patcher = mock.patch.object(requests.auth.Client, "sign", mocked_sign)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_lti_constructor(self):
|
||||
"""
|
||||
Makes sure that all parameters extracted.
|
||||
"""
|
||||
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(),
|
||||
'launch_url': '', # default value
|
||||
}
|
||||
self.assertDictEqual(generated_context, expected_context)
|
||||
34
lms/templates/lti.html
Normal file
34
lms/templates/lti.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div 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="${launch_url}"
|
||||
name="ltiLaunchForm"
|
||||
class="ltiLaunchForm"
|
||||
method="post"
|
||||
target="ltiLaunchFrame"
|
||||
encType="application/x-www-form-urlencoded"
|
||||
>
|
||||
|
||||
% for param_name, param_value in input_fields.items():
|
||||
<input name="${param_name}" value="${param_value}" />
|
||||
%endfor
|
||||
|
||||
<input type="submit" value="Press to Launch" />
|
||||
</form>
|
||||
|
||||
<h3 class="error_message">
|
||||
Please provide launch_url. Click "Edit", and fill in the
|
||||
required fields.
|
||||
</h3>
|
||||
|
||||
## The result of the form submit will be rendered here.
|
||||
<iframe
|
||||
name="ltiLaunchFrame"
|
||||
class="ltiLaunchFrame"
|
||||
src=""
|
||||
></iframe>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user