Commit 55925110 by Valera Rozuvan

Merge pull request #1185 from edx/alex/lti_new_window

New feature for lti module. Open in new window.
parents aeae3534 e76e8621
......@@ -7,6 +7,8 @@ the top. Include a label indicating the component affected.
Blades: Add possibility to use multiple LTI tools per page.
Blades: LTI module can now load external content in a new window.
LMS: Disable data download buttons on the instructor dashboard for large courses
LMS: Ported bulk emailing to the beta instructor dashboard.
......
../../../common/static/sass/_mixins-inherited.scss
\ No newline at end of file
......@@ -197,6 +197,10 @@ $lightBluishGrey: rgb(197, 207, 223);
$lightBluishGrey2: rgb(213, 220, 228);
$error-red: rgb(253, 87, 87);
//carryover from LMS for xmodules
$sidebar-color: rgb(246, 246, 246);
// type
$sans-serif: $f-sans-serif;
$body-line-height: golden-ratio(.875em, 1);
......
......@@ -2,8 +2,23 @@ div.lti {
// align center
margin: 0 auto;
h3.error_message {
display: block;
.wrapper-lti-link {
@include font-size(14);
position: relative;
background-color: $sidebar-color;
padding: ($baseline*1.8) ($baseline*1.5) ($baseline*1.1) $baseline;
.lti-link {
position: absolute;
top: ($baseline*1.8);
right: $baseline;
.link_lti_new_window {
@extend .gray-button;
@include font-size(13);
@include line-height(14);
}
}
}
form.ltiLaunchForm {
......@@ -13,18 +28,8 @@ div.lti {
iframe.ltiLaunchFrame {
width: 100%;
height: 800px;
display: none;
display: block;
border: 0px;
overflow-x: hidden;
}
&.rendered {
iframe.ltiLaunchFrame {
display: block;
}
h3.error_message {
display: none;
}
}
}
<div id="lti_id" class="lti">
<div class="lti-wrapper">
<div id="lti_id" class="lti" data-open_in_a_new_page="true">
<form
action="http://www.example.com"
name="ltiLaunchForm"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame"
enctype="application/x-www-form-urlencoded"
>
<input name="launch_presentation_return_url" value="" />
<input name="lti_version" value="LTI-1p0" />
<input name="user_id" value="student" />
<input name="oauth_nonce" value="28347958723982798572" />
<input name="oauth_timestamp" value="2389479832" />
<input name="oauth_consumer_key" value="" />
<input name="lis_result_sourcedid" value="" />
<input name="oauth_signature_method" value="HMAC-SHA1" />
<input name="oauth_version" value="1.0" />
<input name="role" value="student" />
<input name="lis_outcome_service_url" value="" />
<input name="oauth_signature" value="89ru3289r3ry283y3r82ryr38yr" />
<input name="lti_message_type" value="basic-lti-launch-request" />
<input name="oauth_callback" value="about:blank" />
<form
action=""
name="ltiLaunchForm"
class="ltiLaunchForm"
method="post"
target="_blank"
enctype="application/x-www-form-urlencoded"
>
<input name="launch_presentation_return_url" value="" />
<input name="lti_version" value="LTI-1p0" />
<input name="user_id" value="student" />
<input name="oauth_nonce" value="28347958723982798572" />
<input name="oauth_timestamp" value="2389479832" />
<input name="oauth_consumer_key" value="" />
<input name="lis_result_sourcedid" value="" />
<input name="oauth_signature_method" value="HMAC-SHA1" />
<input name="oauth_version" value="1.0" />
<input name="role" value="student" />
<input name="lis_outcome_service_url" value="" />
<input name="oauth_signature" value="89ru3289r3ry283y3r82ryr38yr" />
<input name="lti_message_type" value="basic-lti-launch-request" />
<input name="oauth_callback" value="about:blank" />
<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>
<input type="submit" value="Press to Launch" />
</form>
</div>
</div>
......@@ -5,77 +5,158 @@
*
*
* 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.
* is set for the hidden LTI form, then it is submitted, and the results are
* redirected to an iframe or to a new window (based on the
* "open_in_a_new_page" attribute).
*
* We will test that the form is only submited when the action is set (i.e.
* not empty).
* We will test that the form is only submitted when the action is set (i.e.
* not empty, and not the default one).
*
* 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
* "Hence that general is skillful in attack whose opponent does not know what
* to defend; and he is skillful in defense whose opponent does not know what
* to attack."
*
* ~ Sun Tzu
*/
(function () {
var element, container, form, link,
IN_NEW_WINDOW = 'true',
IN_IFRAME = 'false',
EMPTY_URL = '',
DEFAULT_URL = 'http://www.example.com',
NEW_URL = 'http://www.example.com/some_book';
function initialize(target, action) {
var tempEl;
loadFixtures('lti.html');
element = $('.lti-wrapper');
container = element.find('.lti');
form = container.find('.ltiLaunchForm');
if (target === IN_IFRAME) {
container.data('open_in_a_new_page', 'false');
form.attr('target', 'ltiLaunchFrame');
}
form.attr('action', action);
// If we have a new proper action (non-default), we create either
// a link that will submit the form, or an iframe that will contain
// the answer of auto submitted form.
if (action !== EMPTY_URL && action !== DEFAULT_URL) {
if (target === IN_NEW_WINDOW) {
$('<a />', {
href: '#',
class: 'link_lti_new_window'
}).appendTo(container);
link = container.find('.link_lti_new_window');
} else {
$('<iframe />', {
name: 'ltiLaunchFrame',
class: 'ltiLaunchFrame',
src: ''
}).appendTo(container);
}
}
spyOnEvent(form, 'submit');
LTI(element);
}
describe('LTI', function () {
describe('constructor', function () {
describe('before settings were filled in', function () {
var element, errorMessage, frame;
describe('initialize', function () {
describe(
'open_in_a_new_page is "true", launch URL is empty',
function () {
// This function will be executed before each of the it() specs
// in this suite.
beforeEach(function () {
loadFixtures('lti.html');
initialize(IN_NEW_WINDOW, EMPTY_URL);
});
it('form is not submitted', function () {
expect('submit').not.toHaveBeenTriggeredOn(form);
});
});
element = $('#lti_id');
errorMessage = element.find('.error_message');
form = element.find('.ltiLaunchForm');
frame = element.find('.ltiLaunchFrame');
describe(
'open_in_a_new_page is "true", launch URL is default',
function () {
spyOnEvent(form, 'submit');
beforeEach(function () {
initialize(IN_NEW_WINDOW, DEFAULT_URL);
});
LTI(element);
it('form is not submitted', function () {
expect('submit').not.toHaveBeenTriggeredOn(form);
});
});
it(
'when URL setting is not filled form is not submited',
function () {
describe(
'open_in_a_new_page is "true", launch URL is not empty, and ' +
'not default',
function () {
beforeEach(function () {
initialize(IN_NEW_WINDOW, NEW_URL);
});
it('form is not submitted', function () {
expect('submit').not.toHaveBeenTriggeredOn(form);
});
it('after link is clicked, form is submitted', function () {
link.trigger('click');
expect('submit').toHaveBeenTriggeredOn(form);
});
});
describe('After the settings were filled in', function () {
var element, errorMessage, frame;
describe(
'open_in_a_new_page is "false", launch URL is empty',
function () {
// This function will be executed before each of the it() specs
// in this suite.
beforeEach(function () {
loadFixtures('lti.html');
initialize(IN_IFRAME, EMPTY_URL);
});
element = $('#lti_id');
errorMessage = element.find('.error_message');
form = element.find('.ltiLaunchForm');
frame = element.find('.ltiLaunchFrame');
it('form is not submitted', function () {
expect('submit').not.toHaveBeenTriggeredOn(form);
});
});
spyOnEvent(form, 'submit');
describe(
'open_in_a_new_page is "false", launch URL is default',
function () {
// The user "fills in" the necessary settings, and the
// form will get an action URL.
form.attr('action', 'http://www.example.com/test_submit');
beforeEach(function () {
initialize(IN_IFRAME, DEFAULT_URL);
});
LTI(element);
it('form is not submitted', function () {
expect('submit').not.toHaveBeenTriggeredOn(form);
});
});
describe(
'open_in_a_new_page is "false", launch URL is not empty, ' +
'and not default',
function () {
beforeEach(function () {
initialize(IN_IFRAME, NEW_URL);
});
it('when URL setting is filled form is submited', function () {
it('form is submitted', function () {
expect('submit').toHaveBeenTriggeredOn(form);
});
});
......
/**
* File: lti.js
*
* Purpose: LTI module constructor. Given an LTI element, we process it.
*
*
* Inside the element there is a form. If that form has a valid action
* attribute, then we do one of:
*
* 1.) Submit the form. The results will be shown on the current page in an
* iframe.
* 2.) Attach a handler function to a link which will submit the form. The
* results will be shown in a new window.
*
* The 'open_in_a_new_page' data attribute of the LTI element dictates which of
* the two actions will be performed.
*/
/*
* So the thing to do when working on a motorcycle, as in any other task, is to
* cultivate the peace of mind which does not separate one's self from one's
* surroundings. When that is done successfully, then everything else follows
* naturally. Peace of mind produces right values, right values produce right
* thoughts. Right thoughts produce right actions and right actions produce
* work which will be a material reflection for others to see of the serenity
* at the center of it all.
*
* ~ Robert M. Pirsig
*/
window.LTI = (function () {
// Function initialize(element)
//
// Initialize the LTI iframe.
// Initialize the LTI module.
//
// @param element DOM element, or jQuery element object.
//
// @return undefined
function initialize(element) {
var form;
var form, openInANewPage, formAction;
// In cms (Studio) the element is already a jQuery object. In lms it is
// a DOM object.
......@@ -13,12 +47,36 @@ window.LTI = (function () {
element = $(element);
form = element.find('.ltiLaunchForm');
formAction = form.attr('action');
// If action is empty string, or action is the default URL that should
// not cause a form submit.
if (!formAction || formAction === 'http://www.example.com') {
// Nothing to do - no valid action provided. Error message will be
// displaced in browser (HTML).
return;
}
// We want a Boolean 'true' or 'false'. First we will retrieve the data
// attribute, and then we will parse it via native JSON.parse().
openInANewPage = element.find('.lti').data('open_in_a_new_page');
openInANewPage = JSON.parse(openInANewPage);
// 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.attr('action') !== 'http://www.example.com') {
// submit), then we (depending on instance settings) submit the form
// when user will click on a link, or submit the form immediately.
if (openInANewPage === true) {
element.find('.link_lti_new_window').on('click', function () {
form.submit();
});
} else {
// At this stage the form exists on the page and has a valid
// action. We are safe to submit it, even if `openInANewPage` is
// set to some weird value.
//
// Best case scenario is that `openInANewPage` is set to `true`.
form.submit();
element.find('.lti').addClass('rendered');
}
}
......
......@@ -8,12 +8,14 @@ http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
import logging
import oauthlib.oauth1
import urllib
import json
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
from xblock.fields import Boolean
log = logging.getLogger(__name__)
......@@ -45,6 +47,7 @@ class LTIFields(object):
lti_id = String(help="Id of the tool", default='', scope=Scope.settings)
launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings)
custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings)
class LTIModule(LTIFields, XModule):
......@@ -169,14 +172,15 @@ class LTIModule(LTIFields, XModule):
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,
'element_class': self.category,
'open_in_a_new_page': self.open_in_a_new_page,
'display_name': self.display_name,
}
return self.system.render_template('lti.html', context)
......
......@@ -2,17 +2,27 @@
Feature: LMS.LTI component
As a student, I want to view LTI component in LMS.
Scenario: LTI component in LMS is not rendered
Scenario: LTI component in LMS with no launch_url 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
And the course has an LTI component with no_launch_url fields, new_page is false
Then I view the LTI and error is shown
Scenario: LTI component in LMS is rendered
Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly
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
And the course has an LTI component with incorrect_lti_id fields, new_page is false
Then I view the LTI but incorrect_signature warning 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
And the course has an LTI component with correct fields, new_page is false
Then I view the LTI but incorrect_signature warning is rendered
Scenario: LTI component in LMS is correctly rendered in new page
Given the course has correct LTI credentials
And the course has an LTI component with correct fields, new_page is true
Then I view the LTI and it is rendered in new page
Scenario: LTI component in LMS is correctly rendered in iframe
Given the course has correct LTI credentials
And the course has an LTI component with correct fields, new_page is false
Then I view the LTI and it is rendered in iframe
#pylint: disable=C0111
import os
from django.contrib.auth.models import User
from lettuce import world, step
from lettuce.django import django_url
......@@ -8,33 +9,19 @@ from common import course_id
from student.models import CourseEnrollment
@step('I view the LTI and it is not rendered$')
@step('I view the LTI and error is shown$')
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')
assert world.is_css_present('.error_message')
# iframe is not visible
assert not world.css_visible('iframe')
# iframe is not presented
assert not world.is_css_present('iframe')
location = world.scenario_dict['LTI'].location.html_id()
iframe_name = 'ltiLaunchFrame-' + location
#inside iframe test content is not presented
with world.browser.get_iframe(iframe_name) as iframe:
# iframe does not contain functions from terrain/ui_helpers.py
world.browser.driver.implicitly_wait(1)
try:
assert iframe.is_element_not_present_by_css('.result', wait_time=1)
except:
raise
finally:
world.browser.driver.implicitly_wait(world.IMPLICIT_WAIT)
# link is not presented
assert not world.is_css_present('.link_lti_new_window')
def check_lti_ifarme_content(text):
def check_lti_iframe_content(text):
#inside iframe test content is presented
location = world.scenario_dict['LTI'].location.html_id()
iframe_name = 'ltiLaunchFrame-' + location
......@@ -47,30 +34,33 @@ def check_lti_ifarme_content(text):
))
@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')
@step('I view the LTI and it is rendered in (.*)$')
def lti_is_rendered(_step, rendered_in):
if rendered_in.strip() == 'iframe':
assert world.is_css_present('iframe')
assert not world.is_css_present('.link_lti_new_window')
assert not world.is_css_present('.error_message')
# error is hidden
assert not world.css_visible('.error_message')
# iframe is visible
assert world.css_visible('iframe')
check_lti_iframe_content("This is LTI tool. Success.")
# iframe is visible
assert world.css_visible('iframe')
check_lti_ifarme_content("This is LTI tool. Success.")
elif rendered_in.strip() == 'new page':
assert not world.is_css_present('iframe')
assert world.is_css_present('.link_lti_new_window')
assert not world.is_css_present('.error_message')
check_lti_popup()
else: # incorrent rendered_in parameter
assert False
@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')
check_lti_ifarme_content("Wrong LTI signature")
assert world.is_css_present('iframe')
assert not world.is_css_present('.link_lti_new_window')
assert not world.is_css_present('.error_message')
#inside iframe test content is presented
check_lti_iframe_content("Wrong LTI signature")
@step('the course has correct LTI credentials$')
......@@ -97,44 +87,33 @@ def set_incorrect_lti_passport(_step):
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):
@step('the course has an LTI component with (.*) fields, new_page is(.*)$')
def add_correct_lti_to_course(_step, fields, new_page):
category = 'lti'
world.scenario_dict['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)
lti_id = 'correct_lti_id'
launch_url = world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint']
if fields.strip() == 'incorrect_lti_id': # incorrect fields
lti_id = 'incorrect_lti_id'
elif fields.strip() == 'correct': # correct fields
pass
elif fields.strip() == 'no_launch_url':
launch_url = u''
else: # incorrect parameter
assert False
if new_page.strip().lower() == 'false':
new_page = False
else: # default is True
new_page = True
@step('the course has an LTI component with incorrect fields$')
def add_incorrect_lti_to_course(_step):
category = 'lti'
world.scenario_dict['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']
'lti_id': lti_id,
'launch_url': launch_url,
'open_in_a_new_page': new_page
}
)
course = world.scenario_dict["COURSE"]
......@@ -192,3 +171,28 @@ def i_am_registered_for_the_course(course, metadata):
CourseEnrollment.enroll(usr, course_id(course))
world.log_in(username='robot', password='test')
def check_lti_popup():
parent_window = world.browser.current_window # Save the parent window
world.css_find('.link_lti_new_window').first.click()
assert len(world.browser.windows) != 1
for window in world.browser.windows:
world.browser.switch_to_window(window) # Switch to a different window (the pop-up)
# Check if this is the one we want by comparing the url
url = world.browser.url
basename = os.path.basename(url)
pathname = os.path.splitext(basename)[0]
if pathname == u'correct_lti_endpoint':
break
result = world.css_find('.result').first.text
assert result == u'This is LTI tool. Success.'
world.browser.driver.close() # Close the pop-up window
world.browser.switch_to_window(parent_window) # Switch to the main window again
......@@ -72,9 +72,11 @@ class TestLTI(BaseTestXmodule):
generated_context = self.item_module.render('student_view').content
expected_context = {
'input_fields': self.correct_headers,
'display_name': self.item_module.display_name,
'element_class': self.item_module.location.category,
'element_id': self.item_module.location.html_id(),
'launch_url': 'http://www.example.com', # default value
'open_in_a_new_page': True,
}
self.assertEqual(
generated_context,
......
<div id="${element_id}" class="${element_class}">
<%! import json %>
<%! from django.utils.translation import ugettext as _ %>
## This form will be hidden. Once available on the client, the LTI
## module JavaScript will trigget a "submit" on the form, and the
<div
id="${element_id}"
class="${element_class}"
data-open_in_a_new_page="${json.dumps(open_in_a_new_page)}"
>
## This form will be hidden.
## If open_in_a_new_page is false then, once available on the client, the
## LTI module JavaScript will trigger a "submit" on the form, and the
## result will be rendered to the below iFrame.
## If open_in_a_new_page is true, then link will be shown, and by clicking
## on it, LTI will pop up in new window.
<form
action="${launch_url}"
name="ltiLaunchForm-${element_id}"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame-${element_id}"
target=${"_blank" if open_in_a_new_page else "ltiLaunchFrame-{0}".format(element_id)}
encType="application/x-www-form-urlencoded"
>
......@@ -19,16 +29,29 @@
<input type="submit" value="Press to Launch" />
</form>
% if launch_url and launch_url != 'http://www.example.com':
% if open_in_a_new_page:
<div class="wrapper-lti-link">
<h3 class="title">
${display_name} (${_('External resource')})
</h3>
<p class="lti-link external"><a href="#" class='link_lti_new_window'>
${_('View resource in a new window')}
<i class="icon-external-link"></i>
</a></p>
</div>
% else:
## The result of the form submit will be rendered here.
<iframe
name="ltiLaunchFrame-${element_id}"
class="ltiLaunchFrame"
src=""
></iframe>
% endif
% else:
<h3 class="error_message">
Please provide launch_url. Click "Edit", and fill in the
required fields.
${_('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-${element_id}"
class="ltiLaunchFrame"
src=""
></iframe>
%endif
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment