Commit e633f55b by Douglas Hall

Refactor LTI XModule code from the edx-platform repo to this XBlock repo

parent 3ee67dcc
### Python artifacts
*.pyc
*.egg-info
### Editor and IDE artifacts
*~
*.swp
*.orig
/nbproject
.idea/
.redcar/
codekit-config.json
.pycharm_helpers/
### Testing artifacts
.coverage
var/
\ No newline at end of file
[pep8]
ignore=E501
max_line_length=120
exclude=settings
language: python
python: "2.7"
install:
- "make install"
sudo: false
script:
- make quality
- make test
branches:
only:
- master
after_success: coveralls
This diff is collapsed. Click to expand it.
all: install compile-sass quality test
install-test:
pip install -q -r test_requirements.txt
install: install-test
compile-sass:
./scripts/sass.sh
quality:
./scripts/quality.sh
test:
./scripts/test.sh
Copyright (C) 2015 edX
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
# LTI Consumer XBlock
This is a Python package containing an implementation of an LTI consumer using the XBlock API.
LTI Consumer XBlock |Build Status| |Coveralls|
----------------------------------------------
This XBlock implements the consumer side of the LTI specification enabling
integration of third-party LTI provider tools.
Installation
------------
Install the requirements into the python virtual environment of your
``edx-platform`` installation by running the following command from the
root folder:
.. code:: bash
$ pip install -r requirements.txt
Enabling in Studio
------------------
You can enable the LTI Consumer XBlock in Studio through the
advanced settings.
1. From the main page of a specific course, navigate to
``Settings -> Advanced Settings`` from the top menu.
2. Check for the ``advanced_modules`` policy key, and add
``"lti_consumer"`` to the policy value list.
3. Click the "Save changes" button.
Workbench installation and settings
-----------------------------------
Install to the workbench's virtualenv by running the following command
from the xblock-lti-consumer repo root with the workbench's virtualenv activated:
.. code:: bash
$ make install
Running tests
-------------
From the xblock-lti-consumer repo root, run the tests with the following command:
.. code:: bash
$ make test
Running code quality check
--------------------------
From the xblock-lti-consumer repo root, run the quality checks with the following command:
.. code:: bash
$ make quality
Compiling Sass
--------------
This XBlock uses Sass for writing style rules. The Sass is compiled
and committed to the git repo using:
.. code:: bash
$ make compile-sass
Changes to style rules should be made to the Sass files, compiled to CSS,
and committed to the git repository.
License
-------
The LTI Consumer XBlock is available under the Apache Version 2.0 License.
.. |Build Status| image:: https://travis-ci.org/edx/xblock-lti-consumer.svg
:target: https://travis-ci.org/edx/xblock-lti-consumer
.. |Coveralls| image:: https://coveralls.io/repos/edx/xblock-lti-consumer/badge.svg?branch=master&service=github
:target: https://coveralls.io/github/edx/xblock-lti-consumer?branch=master
"""
Runtime will load the XBlock class from here.
"""
from .lti_consumer import LtiConsumerXBlock
"""
Exceptions for the LTI Consumer XBlock.
"""
class LtiError(Exception):
"""
General error class for LTI XBlock.
"""
pass
"""
Utility functions for working with OAuth signatures.
"""
import logging
import hashlib
import base64
import urllib
from oauthlib import oauth1
from .exceptions import LtiError
log = logging.getLogger(__name__)
class SignedRequest(object):
"""
Encapsulates request attributes needed when working
with the `oauthlib.oauth1` API
"""
def __init__(self, **kwargs):
self.uri = kwargs.get('uri')
self.http_method = kwargs.get('http_method')
self.params = kwargs.get('params')
self.oauth_params = kwargs.get('oauth_params')
self.headers = kwargs.get('headers')
self.body = kwargs.get('body')
self.decoded_body = kwargs.get('decoded_body')
self.signature = kwargs.get('signature')
def get_oauth_request_signature(key, secret, url, headers, body):
"""
Returns Authorization header for a signed oauth request.
Arguments:
key (str): LTI provider key
secret (str): LTI provider secret
url (str): URL for the signed request
header (str): HTTP headers for the signed request
body (str): Body of the signed request
Returns:
str: Authorization header for the OAuth signed request
"""
client = oauth1.Client(client_key=unicode(key), client_secret=unicode(secret))
try:
# Add Authorization header which looks like:
# Authorization: OAuth oauth_nonce="80966668944732164491378916897",
# oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1",
# oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"
__, headers, __ = client.sign(
unicode(url.strip()),
http_method=u'POST',
body=body,
headers=headers
)
except ValueError: # Scheme not in url.
raise LtiError("Failed to sign oauth request")
return headers['Authorization']
def verify_oauth_body_signature(request, lti_provider_secret, service_url):
"""
Verify grade request from LTI provider using OAuth body signing.
Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html::
This specification extends the OAuth signature to include integrity checks on HTTP request bodies
with content types other than application/x-www-form-urlencoded.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request
lti_provider_secret (str): Secret key for the LTI provider
service_url (str): URL that the request was made to
content_type (str): HTTP content type of the request
Raises:
LtiError if request is incorrect.
"""
headers = {
'Authorization': unicode(request.headers.get('Authorization')),
'Content-Type': request.content_type,
}
sha1 = hashlib.sha1()
sha1.update(request.body)
oauth_body_hash = base64.b64encode(sha1.digest()) # pylint: disable=E1121
oauth_params = oauth1.rfc5849.signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
oauth_headers = dict(oauth_params)
oauth_signature = oauth_headers.pop('oauth_signature')
mock_request_lti_1 = SignedRequest(
uri=unicode(urllib.unquote(service_url)),
http_method=unicode(request.method),
params=oauth_headers.items(),
signature=oauth_signature
)
mock_request_lti_2 = SignedRequest(
uri=unicode(urllib.unquote(request.url)),
http_method=unicode(request.method),
params=oauth_headers.items(),
signature=oauth_signature
)
if oauth_body_hash != oauth_headers.get('oauth_body_hash'):
log.error(
"OAuth body hash verification failed, provided: %s, "
"calculated: %s, for url: %s, body is: %s",
oauth_headers.get('oauth_body_hash'),
oauth_body_hash,
service_url,
request.body
)
raise LtiError("OAuth body hash verification is failed.")
if (not oauth1.rfc5849.signature.verify_hmac_sha1(mock_request_lti_1, lti_provider_secret) and not
oauth1.rfc5849.signature.verify_hmac_sha1(mock_request_lti_2, lti_provider_secret)):
log.error(
"OAuth signature verification failed, for "
"headers:%s url:%s method:%s",
oauth_headers,
service_url,
unicode(request.method)
)
raise LtiError("OAuth signature verification has failed.")
return True
def log_authorization_header(request, client_key, client_secret):
"""
Helper function that logs proper HTTP Authorization header for a given request
Used only in debug situations, this logs the correct Authorization header based on
the request header and body according to OAuth 1 Body signing
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object to log Authorization header for
Returns:
nothing
"""
sha1 = hashlib.sha1()
sha1.update(request.body)
oauth_body_hash = unicode(base64.b64encode(sha1.digest())) # pylint: disable=too-many-function-args
log.debug("[LTI] oauth_body_hash = %s", oauth_body_hash)
client = oauth1.Client(client_key, client_secret)
params = client.get_oauth_params(request)
params.append((u'oauth_body_hash', oauth_body_hash))
mock_request = SignedRequest(
uri=unicode(urllib.unquote(request.url)),
headers=request.headers,
body=u"",
decoded_body=u"",
oauth_params=params,
http_method=unicode(request.method),
)
sig = client.get_oauth_signature(mock_request)
mock_request.oauth_params.append((u'oauth_signature', sig))
__, headers, _ = client._render(mock_request) # pylint: disable=protected-access
log.debug(
"\n\n#### COPY AND PASTE AUTHORIZATION HEADER ####\n%s\n####################################\n\n",
headers['Authorization']
)
"""
This module adds support for the LTI Outcomes Management Service.
For more details see:
https://www.imsglobal.org/specs/ltiomv1p0
"""
import logging
import urllib
from lxml import etree
from xml.sax.saxutils import escape
from xblockutils.resources import ResourceLoader
from .exceptions import LtiError
from .oauth import verify_oauth_body_signature
log = logging.getLogger(__name__)
def parse_grade_xml_body(body):
"""
Parses values from the Outcome Service XML.
XML body should contain nsmap with namespace, that is specified in LTI specs.
Arguments:
body (str): XML Outcome Service request body
Returns:
tuple: imsx_messageIdentifier, sourcedId, score, action
Raises:
LtiError
if submitted score is outside the permitted range
if the XML is missing required entities
if there was a problem parsing the XML body
"""
lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
namespaces = {'def': lti_spec_namespace}
data = body.strip().encode('utf-8')
try:
parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') # pylint: disable=no-member
root = etree.fromstring(data, parser=parser) # pylint: disable=no-member
except etree.XMLSyntaxError as ex:
raise LtiError(ex.message or 'Body is not valid XML')
try:
imsx_message_identifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text or ''
except IndexError:
raise LtiError('Failed to parse imsx_messageIdentifier from XML request body')
try:
body = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0]
except IndexError:
raise LtiError('Failed to parse imsx_POXBody from XML request body')
try:
action = body.getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '')
except IndexError:
raise LtiError('Failed to parse action from XML request body')
try:
sourced_id = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text
except IndexError:
raise LtiError('Failed to parse sourcedId from XML request body')
try:
score = root.xpath("//def:textString", namespaces=namespaces)[0].text
except IndexError:
raise LtiError('Failed to parse score textString from XML request body')
# Raise exception if score is not float or not in range 0.0-1.0 regarding spec.
score = float(score)
if not 0.0 <= score <= 1.0:
raise LtiError('score value outside the permitted range of 0.0-1.0')
return imsx_message_identifier, sourced_id, score, action
class OutcomeService(object):
"""
Service for handling LTI Outcome Management Service requests.
For more details see:
https://www.imsglobal.org/specs/ltiomv1p0
"""
def __init__(self, xblock):
self.xblock = xblock
def handle_request(self, request):
"""
Handler for Outcome Service requests.
Parses and validates XML request body. Currently, only the
replaceResultRequest action is supported.
Example of request body from LTI provider::
<?xml version = "1.0" encoding = "UTF-8"?>
<imsx_POXEnvelopeRequest xmlns = "some_link (may be not required)">
<imsx_POXHeader>
<imsx_POXRequestHeaderInfo>
<imsx_version>V1.0</imsx_version>
<imsx_messageIdentifier>528243ba5241b</imsx_messageIdentifier>
</imsx_POXRequestHeaderInfo>
</imsx_POXHeader>
<imsx_POXBody>
<replaceResultRequest>
<resultRecord>
<sourcedGUID>
<sourcedId>feb-123-456-2929::28883</sourcedId>
</sourcedGUID>
<result>
<resultScore>
<language>en-us</language>
<textString>0.4</textString>
</resultScore>
</result>
</resultRecord>
</replaceResultRequest>
</imsx_POXBody>
</imsx_POXEnvelopeRequest>
See /templates/xml/outcome_service_response.xml for the response body format.
Arguments:
request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request
Returns:
str: Outcome Service XML response
"""
resource_loader = ResourceLoader(__name__)
response_xml_template = resource_loader.load_unicode('/templates/xml/outcome_service_response.xml')
# Returns when `action` is unsupported.
# Supported actions:
# - replaceResultRequest.
unsupported_values = {
'imsx_codeMajor': 'unsupported',
'imsx_description': 'Target does not support the requested operation.',
'imsx_messageIdentifier': 'unknown',
'response': ''
}
# Returns if:
# - past due grades are not accepted and grade is past due
# - score is out of range
# - can't parse response from TP;
# - can't verify OAuth signing or OAuth signing is incorrect.
failure_values = {
'imsx_codeMajor': 'failure',
'imsx_description': 'The request has failed.',
'imsx_messageIdentifier': 'unknown',
'response': ''
}
if not self.xblock.accept_grades_past_due and self.xblock.is_past_due:
failure_values['imsx_description'] = "Grade is past due"
return response_xml_template.format(**failure_values)
try:
imsx_message_identifier, sourced_id, score, action = parse_grade_xml_body(request.body)
except LtiError as ex: # pylint: disable=no-member
body = escape(request.body) if request.body else ''
error_message = "Request body XML parsing error: {} {}".format(ex.message, body)
log.debug("[LTI]: %s" + error_message)
failure_values['imsx_description'] = error_message
return response_xml_template.format(**failure_values)
# Verify OAuth signing.
__, secret = self.xblock.lti_provider_key_secret
try:
verify_oauth_body_signature(request, secret, self.xblock.outcome_service_url)
except (ValueError, LtiError) as ex:
failure_values['imsx_messageIdentifier'] = escape(imsx_message_identifier)
error_message = "OAuth verification error: " + escape(ex.message)
failure_values['imsx_description'] = error_message
log.debug("[LTI]: " + error_message)
return response_xml_template.format(**failure_values)
real_user = self.xblock.runtime.get_real_user(urllib.unquote(sourced_id.split(':')[-1]))
if not real_user: # that means we can't save to database, as we do not have real user id.
failure_values['imsx_messageIdentifier'] = escape(imsx_message_identifier)
failure_values['imsx_description'] = "User not found."
return response_xml_template.format(**failure_values)
if action == 'replaceResultRequest':
self.xblock.set_user_module_score(real_user, score, self.xblock.max_score())
values = {
'imsx_codeMajor': 'success',
'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourced_id, score=score),
'imsx_messageIdentifier': escape(imsx_message_identifier),
'response': '<replaceResultResponse/>'
}
log.debug("[LTI]: Grade is saved.")
return response_xml_template.format(**values)
unsupported_values['imsx_messageIdentifier'] = escape(imsx_message_identifier)
log.debug("[LTI]: Incorrect action.")
return response_xml_template.format(**unsupported_values)
.xblock-student_view.xblock-student_view-lti_consumer h2.problem-header{display:inline-block}.xblock-student_view.xblock-student_view-lti_consumer div.problem-progress{display:inline-block;padding-left:5px;color:#666;font-weight:100;font-size:1em}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer{margin:0 auto}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .wrapper-lti-link{font-size:14px;background-color:#f6f6f6;padding:20px}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .wrapper-lti-link .lti-link{margin-bottom:0;text-align:right}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .wrapper-lti-link .lti-link button{font-size:13px;line-height:20.72px}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .lti-modal{top:80px !important}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer .lti-modal .inner-wrapper{height:100%;padding:0 0 0 0}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer form.ltiLaunchForm{display:none}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer iframe.ltiLaunchFrame{width:100%;height:100%;display:block;border:0px}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer h4.problem-feedback-label{font-weight:100;font-size:1em;font-family:"Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif}.xblock-student_view.xblock-student_view-lti_consumer div.lti_consumer div.problem-feedback{margin-top:5px;margin-bottom:5px}
function LtiConsumerXBlock(runtime, element) {
$(function ($) {
// Adapted from leanModal v1.1 by Ray Stone - http://finelysliced.com.au
// Dual licensed under the MIT and GPL
// Renamed leanModal to iframeModal to avoid clash with platform-provided leanModal
// which removes the href attribute from iframe elements upon modal closing
$.fn.extend({
iframeModal: function (options) {
var $trigger = $(this);
var defaults = {top: 100, overlay: 0.5, closeButton: null};
var overlay = $("<div id='lean_overlay'></div>");
$("body").append(overlay);
options = $.extend(defaults, options);
return this.each(function () {
var o = options;
$(this).click(function (e) {
var modal_id = $(this).data("target");
$("#lean_overlay").click(function () {
close_modal(modal_id)
});
$(o.closeButton).click(function () {
close_modal(modal_id)
});
var modal_height = $(modal_id).outerHeight();
var modal_width = $(modal_id).outerWidth();
$("#lean_overlay").css({"display": "block", opacity: 0});
$("#lean_overlay").fadeTo(200, o.overlay);
$(modal_id).css({
"display": "block",
"position": "fixed",
"opacity": 0,
"z-index": 11000,
"left": 50 + "%",
"margin-left": -(modal_width / 2) + "px",
"top": o.top + "px"
});
$(modal_id).fadeTo(200, 1);
e.preventDefault();
/* Manage focus for modal dialog */
var iframe = $(modal_id).find('iframe')[0].contentWindow;
/* Set focus on close button */
$(o.closeButton).focus();
/* Redirect close button to iframe */
$(o.closeButton).on('keydown', function (e) {
if (e.which === 9) {
e.preventDefault();
$(modal_id).find('iframe')[0].contentWindow.focus();
}
});
/* Redirect non-iframe tab to close button */
var $inputs = $('select, input, textarea, button, a').filter(':visible').not(o.closeButton);
$inputs.on('focus', function(e) {
e.preventDefault();
$(options.closeButton).focus();
});
});
});
function close_modal(modal_id) {
$('select, input, textarea, button, a').off('focus');
$("#lean_overlay").fadeOut(200);
$(modal_id).css({"display": "none"})
$trigger.focus();
}
}
});
var $element = $(element);
var $ltiContainer = $element.find('.lti-consumer-container');
var askToSendUsername = $ltiContainer.data('ask-to-send-username') == 'True';
var askToSendEmail = $ltiContainer.data('ask-to-send-email') == 'True';
// Apply click handler to modal launch button
$element.find('.btn-lti-modal').iframeModal({top: 200, closeButton: '.close-modal'});
// Apply click handler to new window launch button
$element.find('.btn-lti-new-window').click(function(){
var launch = true;
// If this instance is configured to require username and/or email, ask user if it is okay to send them
// Do not launch if it is not okay
if(askToSendUsername && askToSendEmail) {
launch = confirm(gettext("Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information."));
} else if (askToSendUsername) {
launch = confirm(gettext("Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information."));
} else if (askToSendEmail) {
launch = confirm(gettext("Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information."));
}
if (launch) {
window.open($(this).data('target'));
}
});
});
}
.xblock-student_view.xblock-student_view-lti_consumer {
h2.problem-header {
display: inline-block;
}
div.problem-progress {
display: inline-block;
padding-left: 5px;
color: #666;
font-weight: 100;
font-size: 1em;
}
div.lti_consumer {
margin: 0 auto;
.wrapper-lti-link {
font-size: 14px;
background-color: #f6f6f6;
padding: 20px;
.lti-link {
margin-bottom: 0;
text-align: right;
button {
font-size: 13px;
line-height: 20.72px;
}
}
}
.lti-modal {
top: 80px !important;
.inner-wrapper {
height: 100%;
padding: 0 0 0 0;
}
}
form.ltiLaunchForm {
display: none;
}
iframe.ltiLaunchFrame {
width: 100%;
height: 100%;
display: block;
border: 0px;
}
h4.problem-feedback-label {
font-weight: 100;
font-size: 1em;
font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif;
}
div.problem-feedback {
margin-top: 5px;
margin-bottom: 5px;
}
}
}
<iframe
title="External Tool Content"
class="ltiLaunchFrame"
name="ltiFrame-${element_id}"
src="${form_url}"
allowfullscreen="true"
webkitallowfullscreen="true"
mozallowfullscreen="true"
></iframe>
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>LTI</title>
</head>
<body>
## This form will be hidden.
## LTI module JavaScript will trigger a "submit" on the form, and the
## result will be rendered instead.
<form
id="lti-${element_id}"
action="${launch_url}"
method="post"
encType="application/x-www-form-urlencoded"
style="display:none;"
>
% for param_name, param_value in lti_parameters.items():
<input name="${param_name}" value="${param_value}" />
% endfor
<input type="submit" value="Press to Launch" />
</form>
<script type="text/javascript">
(function (d) {
var element = d.getElementById("lti-${element_id}");
if (element) {
element.submit();
}
}(document));
</script>
</body>
</html>
<h2 class="problem-header">
## Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS
${display_name} (External resource)
</h2>
% if has_score and weight:
<div class="problem-progress">
% if module_score is not None:
(${"{points} / {total_points} points".format(points=module_score, total_points=weight)})
% else:
(${"{total_points} points possible".format(total_points=weight)})
% endif
</div>
% endif
<div
id="${element_id}"
class="${element_class} lti-consumer-container"
data-ask-to-send-username="${ask_to_send_username}"
data-ask-to-send-email="${ask_to_send_email}"
>
% if launch_url and not hide_launch:
% if launch_target in ['modal', 'new_window']:
<section class="wrapper-lti-link">
% if description:
<div class="lti-description">${description}</div>
% endif
<p class="lti-link external">
% if launch_target == 'modal':
<button
class="btn btn-pl-primary btn-base btn-lti-modal"
data-target="#${element_id + '-lti-modal'}"
>
${button_text or 'View resource in a modal window'} <i class="icon fa fa-external-link"></i>
</button>
% else:
<button
class="btn btn-pl-primary btn-base btn-lti-new-window"
data-target="${form_url}"
>
${button_text or 'View resource in a new window'} <i class="icon fa fa-external-link"></i>
</button>
% endif
</p>
</section>
% endif
% if launch_target == 'modal':
<section
id="${element_id}-lti-modal"
class="modal lti-modal"
aria-hidden="true"
style="width:${modal_width}px; height:${modal_height}px;"
>
<div class="inner-wrapper" role="dialog" aria-labelledby="lti-modal-title">
<button class="close-modal" tabindex="1">
<i class="icon fa fa-remove"></i>
<span class="sr">Close</span>
</button>
## The result of the LTI launch form submit will be rendered here.
<%include file="templates/html/lti_iframe.html"/>
</div>
</section>
% endif
% if launch_target == 'iframe':
<div style="height:${inline_height}px;">
## The result of the LTI launch form submit will be rendered here.
<%include file="templates/html/lti_iframe.html"/>
</div>
% endif
% elif not hide_launch:
<h3 class="error_message">
Please provide launch_url. Click "Edit", and fill in the required fields.
</h3>
% endif
% if has_score and comment:
<h4 class="problem-feedback-label">Feedback on your work from the grader:</h4>
<div class="problem-feedback">
## sanitized with bleach in view
${comment}
</div>
% endif
</div>
<?xml version="1.0" encoding="UTF-8"?>
<imsx_POXEnvelopeResponse xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
<imsx_POXHeader>
<imsx_POXResponseHeaderInfo>
<imsx_version>V1.0</imsx_version>
<imsx_messageIdentifier>{imsx_messageIdentifier}</imsx_messageIdentifier>
<imsx_statusInfo>
<imsx_codeMajor>{imsx_codeMajor}</imsx_codeMajor>
<imsx_severity>status</imsx_severity>
<imsx_description>{imsx_description}</imsx_description>
<imsx_messageRefIdentifier>
</imsx_messageRefIdentifier>
</imsx_statusInfo>
</imsx_POXResponseHeaderInfo>
</imsx_POXHeader>
<imsx_POXBody>{response}</imsx_POXBody>
</imsx_POXEnvelopeResponse>
"""
Module containing tests for xblock-lti-consumer
"""
"""
Module containing unit tests for xblock-lti-consumer
"""
"""
Unit tests for lti_consumer.oauth module
"""
import unittest
from mock import Mock, patch
from lti_consumer.tests.unit.test_utils import make_request
from lti_consumer.exceptions import LtiError
from lti_consumer.oauth import (
get_oauth_request_signature,
verify_oauth_body_signature,
log_authorization_header,
)
OAUTH_PARAMS = [
(u'oauth_nonce', u'80966668944732164491378916897'),
(u'oauth_timestamp', u'1378916897'),
(u'oauth_version', u'1.0'),
(u'oauth_signature_method', u'HMAC-SHA1'),
(u'oauth_consumer_key', u'test'),
(u'oauth_signature', u'frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D'),
]
OAUTH_PARAMS_WITH_BODY_HASH = OAUTH_PARAMS + [(u'oauth_body_hash', u'2jmj7l5rSw0yVb/vlWAYkK/YBwk=')]
class TestGetOauthRequestSignature(unittest.TestCase):
"""
Unit tests for `lti_consumer.oauth.get_oauth_request_signature`
"""
@patch('oauthlib.oauth1.Client.sign')
def test_auth_header_returned(self, mock_client_sign):
"""
Test that the correct Authorization header is returned
"""
mock_client_sign.return_value = '', {'Authorization': ''}, ''
signature = get_oauth_request_signature('test', 'secret', '', {}, '')
mock_client_sign.assert_called_with('', http_method=u'POST', body='', headers={})
self.assertEqual(signature, '')
@patch('oauthlib.oauth1.Client.sign')
def test_sign_raises_error(self, mock_client_sign):
"""
Test that the correct Authorization header is returned
"""
mock_client_sign.side_effect = ValueError
with self.assertRaises(LtiError):
__ = get_oauth_request_signature('test', 'secret', '', {}, '')
class TestVerifyOauthBodySignature(unittest.TestCase):
"""
Unit tests for `lti_consumer.oauth.verify_oauth_body_signature`
"""
@patch('oauthlib.oauth1.rfc5849.signature.verify_hmac_sha1', Mock(return_value=True))
@patch('oauthlib.oauth1.rfc5849.signature.collect_parameters', Mock(return_value=OAUTH_PARAMS_WITH_BODY_HASH))
def test_valid_signature(self):
"""
Test True is returned when the request signature is valid
"""
self.assertTrue(verify_oauth_body_signature(make_request(''), 'test', 'secret'))
@patch('oauthlib.oauth1.rfc5849.signature.verify_hmac_sha1', Mock(return_value=False))
@patch('oauthlib.oauth1.rfc5849.signature.collect_parameters', Mock(return_value=OAUTH_PARAMS_WITH_BODY_HASH))
def test_invalid_signature(self):
"""
Test exception is raised when the request signature is invalid
"""
with self.assertRaises(LtiError):
verify_oauth_body_signature(make_request(''), 'test', 'secret')
@patch('oauthlib.oauth1.rfc5849.signature.verify_hmac_sha1', Mock(return_value=False))
@patch('oauthlib.oauth1.rfc5849.signature.collect_parameters', Mock(return_value=OAUTH_PARAMS))
def test_missing_oauth_body_hash(self):
"""
Test exception is raised when the request signature is missing oauth_body_hash
"""
with self.assertRaises(LtiError):
verify_oauth_body_signature(make_request(''), 'test', 'secret')
class TestLogCorrectAuthorizationHeader(unittest.TestCase):
"""
Unit tests for `lti_consumer.oauth.log_authorization_header`
"""
@patch('lti_consumer.oauth.log')
def test_log_auth_header(self, mock_log):
"""
Test that log.debug is called
"""
log_authorization_header(make_request(''), 'test', 'secret')
self.assertTrue(mock_log.debug.called)
"""
Utility functions used within unit tests
"""
from webob import Request
from mock import Mock
from xblock.fields import ScopeIds
from xblock.runtime import KvsFieldData, DictKeyValueStore
from workbench.runtime import WorkbenchRuntime
FAKE_USER_ID = 'fake_user_id'
def make_xblock(xblock_name, xblock_cls, attributes):
"""
Helper to construct XBlock objects
"""
runtime = WorkbenchRuntime()
key_store = DictKeyValueStore()
db_model = KvsFieldData(key_store)
ids = generate_scope_ids(runtime, xblock_name)
xblock = xblock_cls(runtime, db_model, scope_ids=ids)
xblock.category = Mock()
xblock.location = Mock(
html_id=Mock(return_value='sample_element_id'),
)
xblock.runtime = Mock(
hostname='localhost',
)
xblock.course_id = 'course-v1:edX+DemoX+Demo_Course'
for key, value in attributes.iteritems():
setattr(xblock, key, value)
return xblock
def generate_scope_ids(runtime, block_type):
"""
Helper to generate scope IDs for an XBlock
"""
def_id = runtime.id_generator.create_definition(block_type)
usage_id = runtime.id_generator.create_usage(def_id)
return ScopeIds('user', block_type, def_id, usage_id)
def make_request(body, method='POST'):
"""
Helper to make a request
"""
request = Request.blank('/')
request.method = 'POST'
request.body = body.encode('utf-8')
request.method = method
return request
# ***************************
# ** DO NOT EDIT THIS FILE **
# ***************************
#
# It is generated by:
# $ edx_lint write pylintrc
#
#
#
#
#
#
#
#
# STAY AWAY!
#
#
#
#
#
# SERIOUSLY.
#
# ------------------------------
[MASTER]
profile = no
ignore =
persistent = yes
load-plugins = edx_lint.pylint,pylint_django
[MESSAGES CONTROL]
disable =
locally-disabled,
locally-enabled,
too-few-public-methods,
bad-builtin,
star-args,
abstract-class-not-used,
abstract-class-little-used,
no-init,
fixme,
too-many-lines,
no-self-use,
too-many-ancestors,
too-many-instance-attributes,
too-few-public-methods,
too-many-public-methods,
too-many-return-statements,
too-many-branches,
too-many-arguments,
too-many-locals,
unused-wildcard-import,
duplicate-code
[REPORTS]
output-format = text
files-output = no
reports = no
evaluation = 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
comment = no
[BASIC]
required-attributes =
bad-functions = map,filter,apply,input
module-rgx = (([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
const-rgx = (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$
class-rgx = [A-Z_][a-zA-Z0-9]+$
function-rgx = ([a-z_][a-z0-9_]{2,30}|test_[a-z0-9_]+)$
method-rgx = ([a-z_][a-z0-9_]{2,40}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$
attr-rgx = [a-z_][a-z0-9_]{2,30}$
argument-rgx = [a-z_][a-z0-9_]{2,30}$
variable-rgx = [a-z_][a-z0-9_]{2,30}$
class-attribute-rgx = ([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
inlinevar-rgx = [A-Za-z_][A-Za-z0-9_]*$
good-names = f,i,j,k,db,ex,Run,_,__
bad-names = foo,bar,baz,toto,tutu,tata
no-docstring-rgx = __.*__$|test_.+|setUp$|setUpClass$|tearDown$|tearDownClass$|Meta$
docstring-min-length = -1
[FORMAT]
max-line-length = 120
ignore-long-lines = ^\s*(# )?<?https?://\S+>?$
single-line-if-stmt = no
no-space-check = trailing-comma,dict-separator
max-module-lines = 1000
indent-string = ' '
[MISCELLANEOUS]
notes = FIXME,XXX,TODO
[SIMILARITIES]
min-similarity-lines = 4
ignore-comments = yes
ignore-docstrings = yes
ignore-imports = no
[TYPECHECK]
ignore-mixin-members = yes
ignored-classes = SQLObject
zope = no
unsafe-load-any-extension = yes
generated-members =
REQUEST,
acl_users,
aq_parent,
objects,
DoesNotExist,
can_read,
can_write,
get_url,
size,
content,
status_code,
create,
build,
fields,
tag,
org,
course,
category,
name,
revision,
_meta,
[VARIABLES]
init-import = no
dummy-variables-rgx = _|dummy|unused|.*_unused
additional-builtins =
[CLASSES]
ignore-iface-methods = isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by
defining-attr-methods = __init__,__new__,setUp
valid-classmethod-first-arg = cls
valid-metaclass-classmethod-first-arg = mcs
[DESIGN]
max-args = 5
ignored-argument-names = _.*
max-locals = 15
max-returns = 6
max-branches = 12
max-statements = 50
max-parents = 7
max-attributes = 7
min-public-methods = 2
max-public-methods = 20
[IMPORTS]
deprecated-modules = regsub,TERMIOS,Bastion,rexec
import-graph =
ext-import-graph =
int-import-graph =
[EXCEPTIONS]
overgeneral-exceptions = Exception
# d8bfe98cfd638a8d10288d842a7d3d4532a279c3
lxml==3.4.4
bleach==1.4.2
oauthlib==1.0.3
mako==1.0.2
git+https://github.com/edx/XBlock.git@xblock-0.4.1#egg=XBlock==0.4.1
git+https://github.com/edx/xblock-utils.git@v1.0.0#egg=xblock-utils==v1.0.0
-e .
#!/usr/bin/env bash
set -e
pep8 --config=.pep8 lti_consumer
pylint --rcfile=pylintrc lti_consumer
#!/usr/bin/env bash
set -e
# Student view sass
sass --no-cache --style compressed ./lti_consumer/static/sass/student.scss ./lti_consumer/static/css/student.css
#!/usr/bin/env bash
set -e
export DJANGO_SETTINGS_MODULE="workbench.settings"
mkdir -p var
rm -rf .coverage
nosetests --with-coverage --cover-package="lti_consumer"
"""Setup for lti_consumer XBlock."""
import os
from setuptools import setup
def package_data(pkg, roots):
"""Generic function to find package_data.
All of the files under each of the `roots` will be declared as package
data for package `pkg`.
"""
data = []
for root in roots:
for dirname, __, files in os.walk(os.path.join(pkg, root)):
for fname in files:
data.append(os.path.relpath(os.path.join(dirname, fname), pkg))
return {pkg: data}
setup(
name='lti_consumer-xblock',
version='v1.0.0',
description='This XBlock implements the consumer side of the LTI specification.',
packages=[
'lti_consumer',
],
install_requires=[
'lxml==3.4.4',
'bleach==1.4.2',
'oauthlib==1.0.3',
'mako==1.0.2',
'XBlock==0.4.1',
'xblock-utils==v1.0.0',
],
dependency_links=[
'https://github.com/edx/xblock-utils/tarball/c39bf653e4f27fb3798662ef64cde99f57603f79#egg=xblock-utils',
],
entry_points={
'xblock.v1': [
'lti_consumer = lti_consumer:LtiConsumerXBlock',
]
},
package_data=package_data("lti_consumer", ["static", "templates", "public"]),
)
-r requirements.txt
astroid==1.3.8 # Pinning to avoid backwards incompatibility issue with pylint/pylint-django
coveralls
pep8
git+https://github.com/edx/django-pyfs.git@1.0.3#egg=django-pyfs==1.0.3
git+https://github.com/edx/edx-lint.git@v0.3.2#egg=edx_lint==0.3.2
git+https://github.com/edx/xblock-sdk.git#egg=xblock-sdk
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