diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 89f084b3f4..84962963bd 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -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.
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 724dc439d9..deef87a403 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/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/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/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index aca804d5e2..658a095d14 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):
+ 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/css/lti/lti.scss b/common/lib/xmodule/xmodule/css/lti/lti.scss
new file mode 100644
index 0000000000..97a8f62d54
--- /dev/null
+++ b/common/lib/xmodule/xmodule/css/lti/lti.scss
@@ -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;
+ }
+ }
+}
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..e5e7ab3f3f
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/fixtures/lti.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Please provide launch_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..0a73496bed
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/spec/lti/constructor.js
@@ -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);
+ });
+ });
+ });
+ });
+}());
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..e5b6885e1b
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/lti/lti.js
@@ -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;
+}());
diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py
new file mode 100644
index 0000000000..bc07cea97e
--- /dev/null
+++ b/common/lib/xmodule/xmodule/lti_module.py
@@ -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::
+
+
+
+ 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
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/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py
index fefa668a56..b7e5ea8435 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/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
====
diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature
new file mode 100644
index 0000000000..abdcfdb704
--- /dev/null
+++ b/lms/djangoapps/courseware/features/lti.feature
@@ -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
\ No newline at end of file
diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py
new file mode 100644
index 0000000000..0e91d5ed02
--- /dev/null
+++ b/lms/djangoapps/courseware/features/lti.py
@@ -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')
diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py
new file mode 100644
index 0000000000..0a6c4590dd
--- /dev/null
+++ b/lms/djangoapps/courseware/features/lti_setup.py
@@ -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()
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..ba9cea84d6
--- /dev/null
+++ b/lms/djangoapps/courseware/mock_lti_server/mock_lti_server.py
@@ -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 = """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 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)
+
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..99650d5faa
--- /dev/null
+++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py
@@ -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)
diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py
index 88129cc8d1..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()
+ 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()))
diff --git a/lms/djangoapps/courseware/tests/test_lti.py b/lms/djangoapps/courseware/tests/test_lti.py
new file mode 100644
index 0000000000..d2b4ea6867
--- /dev/null
+++ b/lms/djangoapps/courseware/tests/test_lti.py
@@ -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 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)
diff --git a/lms/templates/lti.html b/lms/templates/lti.html
new file mode 100644
index 0000000000..3d97c8d808
--- /dev/null
+++ b/lms/templates/lti.html
@@ -0,0 +1,34 @@
+
+
+ ## 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 launch_url. Click "Edit", and fill in the
+ required fields.
+
+
+ ## The result of the form submit will be rendered here.
+
+
+