Add grading functionality to LTI xmodule

Co-author: Alexander Kryklia <kryklia@edx.org>
Co-author: Ned Batchelder <ned@edx.org>
Co-author: Oleg Marchev <oleg@edx.org>
Co-author: Valera Rozuvan <valera@edx.org>
Co-author: polesye
[BLD-384]
This commit is contained in:
Calen Pennington
2013-11-26 15:47:35 -05:00
parent 52ab2b1313
commit 11bbf4c182
17 changed files with 1123 additions and 159 deletions

View File

@@ -1,21 +1,60 @@
"""
Module that allows to insert LTI tools to page.
Learning Tools Interoperability (LTI) module.
Protocol is oauth1, LTI version is 1.1.1:
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
Resources
---------
Theoretical background and detailed specifications of LTI can be found on:
http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html
This module is based on the version 1.1.1 of the LTI specifications by the
IMS Global authority. For authentication, it uses OAuth1.
When responding back to the LTI tool provider, we must issue a correct
response. Types of responses and their message payload is available at:
Table A1.2 Interpretation of the 'CodeMajor/severity' matrix.
http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html
A resource to test the LTI protocol (PHP realization):
http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
What is supported:
------------------
1.) Display of simple LTI in iframe or a new window.
2.) Multiple LTI components on a single page.
3.) The use of multiple LTI providers per course.
4.) Use of advanced LTI component that provides back a grade.
a.) The LTI provider sends back a grade to a specified URL.
b.) Currently only action "update" is supported. "Read", and "delete"
actions initially weren't required.
"""
import logging
import oauthlib.oauth1
from oauthlib.oauth1.rfc5849 import signature
import hashlib
import base64
import urllib
import textwrap
from lxml import etree
from webob import Response
import mock
from xml.sax.saxutils import escape
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xmodule.raw_module import EmptyDataRawDescriptor
from xmodule.x_module import XModule
from xmodule.x_module import XModule, module_attr
from xmodule.course_module import CourseDescriptor
from pkg_resources import resource_string
from xblock.core import String, Scope, List
from xblock.fields import Boolean
from xblock.core import String, Scope, List, XBlock
from xblock.fields import Boolean, Float
log = logging.getLogger(__name__)
@@ -29,8 +68,8 @@ 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.
`lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon)
`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
@@ -40,7 +79,7 @@ class LTIFields(object):
vbid=put_book_id_here
book_location=page/put_page_number_here
Default non-empty url for `launch_url` is needed due to oauthlib demand (url scheme should be presented)::
Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented)::
https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
"""
@@ -48,13 +87,15 @@ class LTIFields(object):
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)
graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings)
weight = Float(help="Weight for student grades.", default=1.0, scope=Scope.settings)
class LTIModule(LTIFields, XModule):
'''
"""
Module provides LTI integration to course.
Except usual xmodule structure it proceeds with oauth signing.
Except usual Xmodule structure it proceeds with OAuth signing.
How it works::
1. Get credentials from course settings.
@@ -71,14 +112,14 @@ class LTIModule(LTIFields, XModule):
role
*+ all custom parameters*
These parameters should be encoded and signed by *oauth1* together with
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.
After that signature and other OAuth data are generated.
Oauth data which is generated after signing is usual::
OAuth data which is generated after signing is usual::
oauth_callback
oauth_nonce
@@ -89,47 +130,47 @@ class LTIModule(LTIFields, XModule):
4. All that data is passed to form and sent to LTI provider server by browser via
autosubmit via javascript.
autosubmit via JavaScript.
Form example::
<form
action="${launch_url}"
name="ltiLaunchForm-${element_id}"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame-${element_id}"
encType="application/x-www-form-urlencoded"
>
<input name="launch_presentation_return_url" value="" />
<input name="lis_outcome_service_url" value="" />
<input name="lis_result_sourcedid" value="" />
<input name="lti_message_type" value="basic-lti-launch-request" />
<input name="lti_version" value="LTI-1p0" />
<input name="oauth_callback" value="about:blank" />
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
<input name="oauth_nonce" value="${oauth_nonce}" />
<input name="oauth_signature_method" value="HMAC-SHA1" />
<input name="oauth_timestamp" value="${oauth_timestamp}" />
<input name="oauth_version" value="1.0" />
<input name="user_id" value="${user_id}" />
<input name="role" value="student" />
<input name="oauth_signature" value="${oauth_signature}" />
action="${launch_url}"
name="ltiLaunchForm-${element_id}"
class="ltiLaunchForm"
method="post"
target="ltiLaunchFrame-${element_id}"
encType="application/x-www-form-urlencoded"
>
<input name="launch_presentation_return_url" value="" />
<input name="lis_outcome_service_url" value="" />
<input name="lis_result_sourcedid" value="" />
<input name="lti_message_type" value="basic-lti-launch-request" />
<input name="lti_version" value="LTI-1p0" />
<input name="oauth_callback" value="about:blank" />
<input name="oauth_consumer_key" value="${oauth_consumer_key}" />
<input name="oauth_nonce" value="${oauth_nonce}" />
<input name="oauth_signature_method" value="HMAC-SHA1" />
<input name="oauth_timestamp" value="${oauth_timestamp}" />
<input name="oauth_version" value="1.0" />
<input name="user_id" value="${user_id}" />
<input name="role" value="student" />
<input name="oauth_signature" value="${oauth_signature}" />
<input name="custom_1" value="${custom_param_1_value}" />
<input name="custom_2" value="${custom_param_2_value}" />
<input name="custom_..." value="${custom_param_..._value}" />
<input name="custom_1" value="${custom_param_1_value}" />
<input name="custom_2" value="${custom_param_2_value}" />
<input name="custom_..." value="${custom_param_..._value}" />
<input type="submit" value="Press to Launch" />
</form>
<input type="submit" value="Press to Launch" />
</form>
5. LTI provider has same secret key and it signs data string via *oauth1* and compares signatures.
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')]}
@@ -180,21 +221,7 @@ class LTIModule(LTIFields, XModule):
"tool_consumer_instance_contact_email",
]
# Obtains client_key and client_secret credentials from current course:
course_id = self.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 = [i.strip() for i in 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.strip():
client_key, client_secret = key, secret
break
client_key, client_secret = self.get_client_key_secret()
# parsing custom parameters to dict
custom_parameters = {}
@@ -214,12 +241,12 @@ class LTIModule(LTIFields, XModule):
input_fields = self.oauth_params(
custom_parameters,
client_key,
client_secret
client_secret,
)
context = {
'input_fields': input_fields,
# these params do not participate in oauth signing
# These parameters do not participate in OAuth signing.
'launch_url': self.launch_url.strip(),
'element_id': self.location.html_id(),
'element_class': self.category,
@@ -229,12 +256,57 @@ class LTIModule(LTIFields, XModule):
return self.system.render_template('lti.html', context)
def get_user_id(self):
user_id = self.runtime.anonymous_student_id
assert user_id is not None
return unicode(urllib.quote(user_id))
def get_outcome_service_url(self):
"""
Return URL for storing grades.
"""
uri = 'http://{host}{path}'.format(
host=self.system.hostname,
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
)
return uri
def get_resource_link_id(self):
"""
This is an opaque unique identifier that the TC guarantees will be unique
within the TC for every placement of the link.
If the tool / activity is placed multiple times in the same context,
each of those placements will be distinct.
This value will also change if the item is exported from one system or
context and imported into another system or context.
This parameter is required.
"""
return unicode(urllib.quote(self.id))
def get_lis_result_sourcedid(self):
"""
This field contains an identifier that indicates the LIS Result Identifier (if any)
associated with this launch. This field identifies a unique row and column within the
TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id.
This value may change for a particular resource_link_id / user_id from one launch to the next.
The TP should only retain the most recent value for this field for a particular resource_link_id / user_id.
This field is generally optional, but is required for grading.
context_id is - is an opaque identifier that uniquely identifies the context that contains
the link being launched.
lti_id should be context_id by meaning.
"""
return u':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id()))
def oauth_params(self, custom_parameters, client_key, client_secret):
"""
Signs request and returns signature and oauth parameters.
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.
@@ -245,22 +317,23 @@ class LTIModule(LTIFields, XModule):
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:
# Must have parameters for correct signing from LTI:
body = {
u'user_id': user_id,
u'user_id': self.get_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'
u'role': u'student',
# Parameters required for grading:
u'resource_link_id': self.get_resource_link_id(),
u'lis_outcome_service_url': self.get_outcome_service_url(),
u'lis_result_sourcedid': self.get_lis_result_sourcedid(),
}
# appending custom parameter for signing
# Appending custom parameter for signing.
body.update(custom_parameters)
headers = {
@@ -274,9 +347,9 @@ class LTIModule(LTIFields, XModule):
http_method=u'POST',
body=body,
headers=headers)
except ValueError: # scheme not in url
#https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
#Stubbing headers for now:
except ValueError: # Scheme not in url.
# https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
# Stubbing headers for now:
headers = {
u'Content-Type': u'application/x-www-form-urlencoded',
u'Authorization': u'OAuth oauth_nonce="80966668944732164491378916897", \
@@ -284,7 +357,7 @@ oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-
oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
params = headers['Authorization']
# parse headers to pass to template as part of context:
# 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']
@@ -297,13 +370,217 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
# 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
# Add LTI parameters to OAuth parameters for sending in form.
params.update(body)
return params
def max_score(self):
return self.weight
@XBlock.handler
def grade_handler(self, request, dispatch):
"""
This is called by courseware.module_render, to handle an AJAX call.
Used only for grading. Returns XML response.
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>
Example of correct/incorrect answer XML body:: see response_xml_template.
"""
response_xml_template = textwrap.dedent("""
<?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>
""")
# 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:
# - 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': ''
}
try:
imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body)
except Exception:
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
# Verify OAuth signing.
try:
self.verify_oauth_body_sign(request)
except (ValueError, LTIError):
failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
real_user = self.system.get_real_user(urllib.unquote(sourcedId.split(':')[-1]))
if action == 'replaceResultRequest':
self.system.publish(
event={
'event_name': 'grade',
'value': score * self.max_score(),
'max_value': self.max_score(),
},
custom_user=real_user
)
values = {
'imsx_codeMajor': 'success',
'imsx_description': 'Score for {sourced_id} is now {score}'.format(sourced_id=sourcedId, score=score),
'imsx_messageIdentifier': escape(imsx_messageIdentifier),
'response': '<replaceResultResponse/>'
}
return Response(response_xml_template.format(**values), content_type="application/xml")
unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier)
return Response(response_xml_template.format(**unsupported_values), content_type='application/xml')
@classmethod
def parse_grade_xml_body(cls, body):
"""
Parses XML from request.body and returns parsed data
XML body should contain nsmap with namespace, that is specified in LTI specs.
Returns tuple: imsx_messageIdentifier, sourcedId, score, action
Raises Exception if can't parse.
"""
lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
namespaces = {'def': lti_spec_namespace}
data = body.strip().encode('utf-8')
parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
root = etree.fromstring(data, parser=parser)
imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text
sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text
score = root.xpath("//def:textString", namespaces=namespaces)[0].text
action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '')
# Raise exception if score is not float or not in range 0.0-1.0 regarding spec.
score = float(score)
if not 0 <= score <= 1:
raise LTIError
return imsx_messageIdentifier, sourcedId, score, action
def verify_oauth_body_sign(self, request):
"""
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: DjangoWebobRequest.
Raises:
LTIError if request is incorrect.
"""
client_key, client_secret = self.get_client_key_secret()
headers = {
'Authorization':unicode(request.headers.get('Authorization')),
'Content-Type': 'application/x-www-form-urlencoded',
}
sha1 = hashlib.sha1()
sha1.update(request.body)
oauth_body_hash = base64.b64encode(sha1.hexdigest())
oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
oauth_headers =dict(oauth_params)
oauth_signature = oauth_headers.pop('oauth_signature')
mock_request = mock.Mock(
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') or
not signature.verify_hmac_sha1(mock_request, client_secret)):
raise LTIError
def get_client_key_secret(self):
"""
Obtains client_key and client_secret credentials from current course.
"""
course_id = self.course_id
course_location = CourseDescriptor.id_to_location(course_id)
course = self.descriptor.runtime.modulestore.get_item(course_location)
for lti_passport in course.lti_passports:
try:
lti_id, key, secret = [i.strip() for i in 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.strip():
return key, secret
return '', ''
class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescriptor):
"""
Descriptor for LTI Xmodule.
"""
has_score = True
module_class = LTIModule
grade_handler = module_attr('grade_handler')

View File

@@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
"""Test for LTI Xmodule functional logic."""
from mock import Mock, patch, PropertyMock
import textwrap
from lxml import etree
from webob.request import Request
from copy import copy
import urllib
from xmodule.lti_module import LTIDescriptor
from . import LogicTest
class LTIModuleTest(LogicTest):
"""Logic tests for LTI module."""
descriptor_class = LTIDescriptor
def setUp(self):
super(LTIModuleTest, self).setUp()
self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'}
self.request_body_xml_template = textwrap.dedent("""
<?xml version = "1.0" encoding = "UTF-8"?>
<imsx_POXEnvelopeRequest xmlns = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0">
<imsx_POXHeader>
<imsx_POXRequestHeaderInfo>
<imsx_version>V1.0</imsx_version>
<imsx_messageIdentifier>{messageIdentifier}</imsx_messageIdentifier>
</imsx_POXRequestHeaderInfo>
</imsx_POXHeader>
<imsx_POXBody>
<{action}>
<resultRecord>
<sourcedGUID>
<sourcedId>{sourcedId}</sourcedId>
</sourcedGUID>
<result>
<resultScore>
<language>en-us</language>
<textString>{grade}</textString>
</resultScore>
</result>
</resultRecord>
</{action}>
</imsx_POXBody>
</imsx_POXEnvelopeRequest>
""")
self.system.get_real_user = Mock()
self.xmodule.get_client_key_secret = Mock(return_value=('key', 'secret'))
self.system.publish = Mock()
self.user_id = self.xmodule.runtime.anonymous_student_id
self.lti_id = self.xmodule.lti_id
self.module_id = '//MITx/999/lti/'
sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id))
self.DEFAULTS = {
'sourcedId': sourcedId,
'action': 'replaceResultRequest',
'grade': '0.5',
'messageIdentifier': '528243ba5241b',
}
def get_request_body(self, params={}):
data = copy(self.DEFAULTS)
data.update(params)
return self.request_body_xml_template.format(**data)
def get_response_values(self, response):
parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
root = etree.fromstring(response.body.strip(), parser=parser)
lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0"
namespaces = {'def': lti_spec_namespace}
code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text
description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text
messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text
imsx_POXBody = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0]
try:
action = imsx_POXBody.getchildren()[0].tag.replace('{'+lti_spec_namespace+'}', '')
except Exception:
action = None
return {
'code_major': code_major,
'description': description,
'messageIdentifier': messageIdentifier,
'action': action
}
def test_authorization_header_not_present(self):
"""
Request has no Authorization header.
This is an unknown service request, i.e., it is not a part of the original service specification.
"""
request = Request(self.environ)
request.body = self.get_request_body()
response = self.xmodule.grade_handler(request, '')
real_response = self.get_response_values(response)
expected_response = {
'action': None,
'code_major': 'failure',
'description': 'The request has failed.',
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
}
self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)
def test_authorization_header_empty(self):
"""
Request Authorization header has no value.
This is an unknown service request, i.e., it is not a part of the original service specification.
"""
request = Request(self.environ)
request.authorization = "bad authorization header"
request.body = self.get_request_body()
response = self.xmodule.grade_handler(request, '')
real_response = self.get_response_values(response)
expected_response = {
'action': None,
'code_major': 'failure',
'description': 'The request has failed.',
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
}
self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)
def test_grade_not_in_range(self):
"""
Grade returned from Tool Provider is outside the range 0.0-1.0.
"""
self.xmodule.verify_oauth_body_sign = Mock()
request = Request(self.environ)
request.body = self.get_request_body(params={'grade': '10'})
response = self.xmodule.grade_handler(request, '')
real_response = self.get_response_values(response)
expected_response = {
'action': None,
'code_major': 'failure',
'description': 'The request has failed.',
'messageIdentifier': 'unknown',
}
self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)
def test_bad_grade_decimal(self):
"""
Grade returned from Tool Provider doesn't use a period as the decimal point.
"""
self.xmodule.verify_oauth_body_sign = Mock()
request = Request(self.environ)
request.body = self.get_request_body(params={'grade': '0,5'})
response = self.xmodule.grade_handler(request, '')
real_response = self.get_response_values(response)
expected_response = {
'action': None,
'code_major': 'failure',
'description': 'The request has failed.',
'messageIdentifier': 'unknown',
}
self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)
def test_unsupported_action(self):
"""
Action returned from Tool Provider isn't supported.
`replaceResultRequest` is supported only.
"""
self.xmodule.verify_oauth_body_sign = Mock()
request = Request(self.environ)
request.body = self.get_request_body({'action': 'wrongAction'})
response = self.xmodule.grade_handler(request, '')
real_response = self.get_response_values(response)
expected_response = {
'action': None,
'code_major': 'unsupported',
'description': 'Target does not support the requested operation.',
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
}
self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)
def test_good_request(self):
"""
Response from Tool Provider is correct.
"""
self.xmodule.verify_oauth_body_sign = Mock()
request = Request(self.environ)
request.body = self.get_request_body()
response = self.xmodule.grade_handler(request, '')
code_major, description, messageIdentifier, action = self.get_response_values(response)
description_expected = 'Score for {sourcedId} is now {score}'.format(
sourcedId=self.DEFAULTS['sourcedId'],
score=self.DEFAULTS['grade'],
)
real_response = self.get_response_values(response)
expected_response = {
'action': 'replaceResultResponse',
'code_major': 'success',
'description': description_expected,
'messageIdentifier': self.DEFAULTS['messageIdentifier'],
}
self.assertEqual(response.status_code, 200)
self.assertDictEqual(expected_response, real_response)
def test_user_id(self):
expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id))
real_user_id = self.xmodule.get_user_id()
self.assertEqual(real_user_id, expected_user_id)
def test_outcome_service_url(self):
expected_outcome_service_url = 'http://{host}{path}'.format(
host=self.xmodule.runtime.hostname,
path=self.xmodule.runtime.handler_url(self.xmodule, 'grade_handler', thirdparty=True).rstrip('/?')
)
real_outcome_service_url = self.xmodule.get_outcome_service_url()
self.assertEqual(real_outcome_service_url, expected_outcome_service_url)
def test_resource_link_id(self):
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
mock_id.return_value = self.module_id
expected_resource_link_id = unicode(urllib.quote(self.module_id))
real_resource_link_id = self.xmodule.get_resource_link_id()
self.assertEqual(real_resource_link_id, expected_resource_link_id)
def test_lis_result_sourcedid(self):
with patch('xmodule.lti_module.LTIModule.id', new_callable=PropertyMock) as mock_id:
mock_id.return_value = self.module_id
expected_sourcedId = u':'.join(urllib.quote(i) for i in (self.lti_id, self.module_id, self.user_id))
real_lis_result_sourcedid = self.xmodule.get_lis_result_sourcedid()
self.assertEqual(real_lis_result_sourcedid, expected_sourcedId)
def test_verify_oauth_body_sign(self):
pass
def test_client_key_secret(self):
pass

View File

@@ -371,7 +371,6 @@ class XModule(XModuleMixin, HTMLSnippet, XBlock): # pylint: disable=abstract-me
See the HTML module for a simple example.
"""
has_score = descriptor_attr('has_score')
_field_data_cache = descriptor_attr('_field_data_cache')
_field_data = descriptor_attr('_field_data')
@@ -968,7 +967,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
anonymous_student_id='', course_id=None,
open_ended_grading_interface=None, s3_interface=None,
cache=None, can_execute_unsafe_code=None, replace_course_urls=None,
replace_jump_to_id_urls=None, error_descriptor_class=None, **kwargs):
replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None, **kwargs):
"""
Create a closure around the system environment.
@@ -1053,6 +1052,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs
self.error_descriptor_class = error_descriptor_class
self.xmodule_instance = None
self.get_real_user = get_real_user
def get(self, attr):
""" provide uniform access to attributes (like etree)."""
return self.__dict__.get(attr)