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:
@@ -9,6 +9,10 @@ LMS: Add feature for providing background grade report generation via Celery
|
||||
instructor task, with reports uploaded to S3. Feature is visible on the beta
|
||||
instructor dashboard. LMS-58
|
||||
|
||||
Blades: Added grading support for LTI module. LTI providers can now grade
|
||||
student's work and send edX scores. OAuth1 based authentication
|
||||
implemented. BLD-384.
|
||||
|
||||
LMS: Beta-tester status is now set on a per-course-run basis, rather than being valid
|
||||
across all runs with the same course name. Old group membership will still work
|
||||
across runs, but new beta-testers will only be added to a single course run.
|
||||
|
||||
@@ -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')
|
||||
|
||||
251
common/lib/xmodule/xmodule/tests/test_lti_unit.py
Normal file
251
common/lib/xmodule/xmodule/tests/test_lti_unit.py
Normal 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
97
docs/course_authors/source/create_lti.rst
Normal file
97
docs/course_authors/source/create_lti.rst
Normal file
@@ -0,0 +1,97 @@
|
||||
**********************
|
||||
Create a LTI Component
|
||||
**********************
|
||||
|
||||
Description
|
||||
===========
|
||||
|
||||
The LTI XModule is based on the `IMS Global Learning Tools Interoperability <http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html>`_ Version 1.1.1 specifications.
|
||||
|
||||
Enabling LTI
|
||||
============
|
||||
|
||||
It is not available from the list of general components. To turn it on, add
|
||||
"lti" to the "advanced_modules" key on the Advanced Settings page.
|
||||
|
||||
The module supports 2 modes of operation.
|
||||
|
||||
1. Simple display of external LTI content
|
||||
2. Display of LTI content that will be graded by external provider
|
||||
|
||||
In both cases, before an LTI component from an external provider can be
|
||||
included in a unit, the following pieces of information must be known/decided
|
||||
upon:
|
||||
|
||||
**LTI id** [string]
|
||||
Internal string representing the external LTI provider. Can contain multi-
|
||||
case alphanumeric characters, and underscore.
|
||||
|
||||
**Client key** [string]
|
||||
Used for OAuth authentication. Issued by external LTI provider.
|
||||
|
||||
**Client secret** [string]
|
||||
Used for OAuth authentication. Issued by external LTI provider.
|
||||
|
||||
LTI id is necessary to differentiate between multiple available external LTI
|
||||
providers that are added to an edX course.
|
||||
|
||||
The three fields above must be entered in "lti_passports" field in the format::
|
||||
|
||||
[
|
||||
"{lti_id}:{client_key}:{client_secret}"
|
||||
]
|
||||
|
||||
Multiple external LTI providers are separated by commas::
|
||||
|
||||
[
|
||||
"{lti_id_1}:{client_key_1}:{client_secret_1}",
|
||||
"{lti_id_2}:{client_key_2}:{client_secret_2}",
|
||||
"{lti_id_3}:{client_key_3}:{client_secret_3}"
|
||||
]
|
||||
|
||||
Adding LTI to a unit
|
||||
====================
|
||||
|
||||
After LTI has been enabled, and an external provider has been registered, an
|
||||
instance of it can be added to a unit.
|
||||
|
||||
LTI will be available from the Advanced Component category. After adding an LTI
|
||||
component to a unit, it can be configured by Editing it's settings (the Edit
|
||||
dialog). The following settings are available:
|
||||
|
||||
**Display Name** [string]
|
||||
Title of the new LTI component instance
|
||||
|
||||
**custom_parameters** [string]
|
||||
With the "+ Add" button, multiple custom parameters can be
|
||||
added. Basically, each individual external LTI provider can have a separate
|
||||
format custom parameters. For example::
|
||||
|
||||
key=value
|
||||
|
||||
**graded** [boolean]
|
||||
Whether or not the grade for this particular LTI instance problem will be
|
||||
counted towards student's total grade.
|
||||
|
||||
**launch_url** [string]
|
||||
If `rgaded` above is set to `true`, then this must be
|
||||
the URL that will be passed to the external LTI provider for it to respond with
|
||||
a grade.
|
||||
|
||||
**lti_id** [string]
|
||||
Internal string representing the external LTI provider that
|
||||
will be used to display content. The same as was entered on the Advanced
|
||||
Settings page.
|
||||
|
||||
**open_in_a_new_page** [boolean]
|
||||
If set to `true`, a link will be present for the student
|
||||
to click. When the link is clicked, a new window will open with the external
|
||||
LTI content. If set to `false`, the external LTI content will be loaded in the
|
||||
page in an iframe.
|
||||
|
||||
**weight** [float]
|
||||
If the problem will be graded by an external LTI provider,
|
||||
the raw grade will be in the range [0.0, 1.0]. In order to change this range,
|
||||
set the `weight`. The grade that will be stored is calculated by the formula::
|
||||
|
||||
stored_grade = raw_grade * weight
|
||||
@@ -20,6 +20,7 @@ Contents
|
||||
create_video
|
||||
create_discussion
|
||||
create_html_component
|
||||
create_lti
|
||||
create_problem
|
||||
set_content_releasedates
|
||||
establish_course_settings
|
||||
@@ -34,13 +35,13 @@ Contents
|
||||
checking_student_progress
|
||||
change_log
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Appendices
|
||||
|
||||
|
||||
|
||||
Appendices
|
||||
==========
|
||||
|
||||
.. toctree::
|
||||
|
||||
@@ -25,6 +25,7 @@ Specific Problem Types
|
||||
course_data_formats/drag_and_drop/drag_and_drop_input.rst
|
||||
course_data_formats/graphical_slider_tool/graphical_slider_tool.rst
|
||||
course_data_formats/poll_module/poll_module.rst
|
||||
course_data_formats/lti_module/lti.rst
|
||||
course_data_formats/conditional_module/conditional_module.rst
|
||||
course_data_formats/word_cloud/word_cloud.rst
|
||||
course_data_formats/custom_response.rst
|
||||
|
||||
@@ -2,27 +2,57 @@
|
||||
Feature: LMS.LTI component
|
||||
As a student, I want to view LTI component in LMS.
|
||||
|
||||
#1
|
||||
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 no_launch_url fields, new_page is false
|
||||
And the course has an LTI component with no_launch_url fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI and error is shown
|
||||
|
||||
#2
|
||||
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 with incorrect_lti_id fields, new_page is false
|
||||
And the course has an LTI component with incorrect_lti_id fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI but incorrect_signature warning is rendered
|
||||
|
||||
#3
|
||||
Scenario: LTI component in LMS is rendered incorrectly
|
||||
Given the course has incorrect LTI credentials
|
||||
And the course has an LTI component with correct fields, new_page is false
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI but incorrect_signature warning is rendered
|
||||
|
||||
#4
|
||||
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
|
||||
And the course has an LTI component with correct fields
|
||||
Then I view the LTI and it is rendered in new page
|
||||
|
||||
#5
|
||||
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
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI and it is rendered in iframe
|
||||
|
||||
#6
|
||||
Scenario: Graded LTI component in LMS is correctly works
|
||||
Given the course has correct LTI credentials
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | is_graded |
|
||||
| False | 10 | True |
|
||||
And I submit answer to LTI question
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 5/10"
|
||||
And I see graph with total progress "5%"
|
||||
Then I click on the "Instructor" tab
|
||||
And I click on the "Gradebook" tab
|
||||
And I see in the gradebook table that "HW" is "50"
|
||||
And I see in the gradebook table that "Total" is "5"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pylint: disable=C0111
|
||||
|
||||
import os
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
@@ -86,36 +87,41 @@ def set_incorrect_lti_passport(_step):
|
||||
}
|
||||
i_am_registered_for_the_course(coursenum, metadata)
|
||||
|
||||
|
||||
@step('the course has an LTI component with (.*) fields, new_page is(.*)$')
|
||||
def add_correct_lti_to_course(_step, fields, new_page):
|
||||
@step('the course has an LTI component with (.*) fields(?:\:)?$') #, new_page is(.*), is_graded is(.*)
|
||||
def add_correct_lti_to_course(_step, fields):
|
||||
category = 'lti'
|
||||
lti_id = 'correct_lti_id'
|
||||
launch_url = world.lti_server.oauth_settings['lti_base'] + world.lti_server.oauth_settings['lti_endpoint']
|
||||
metadata = {
|
||||
'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'
|
||||
metadata.update({
|
||||
'lti_id': 'incorrect_lti_id'
|
||||
})
|
||||
elif fields.strip() == 'correct': # correct fields
|
||||
pass
|
||||
elif fields.strip() == 'no_launch_url':
|
||||
launch_url = u''
|
||||
metadata.update({
|
||||
'launch_url': u''
|
||||
})
|
||||
else: # incorrect parameter
|
||||
assert False
|
||||
|
||||
if new_page.strip().lower() == 'false':
|
||||
new_page = False
|
||||
else: # default is True
|
||||
new_page = True
|
||||
if _step.hashes:
|
||||
metadata.update(_step.hashes[0])
|
||||
|
||||
world.scenario_dict['LTI'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['SEQUENTIAL'].location,
|
||||
category=category,
|
||||
display_name='LTI',
|
||||
metadata={
|
||||
'lti_id': lti_id,
|
||||
'launch_url': launch_url,
|
||||
'open_in_a_new_page': new_page
|
||||
}
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
setattr(world.scenario_dict['LTI'], 'TEST_BASE_PATH', '{host}:{port}'.format(
|
||||
host=world.browser.host,
|
||||
port=world.browser.port,
|
||||
))
|
||||
|
||||
course = world.scenario_dict["COURSE"]
|
||||
chapter_name = world.scenario_dict['SECTION'].display_name.replace(
|
||||
" ", "_")
|
||||
@@ -138,6 +144,20 @@ def create_course(course, metadata):
|
||||
# This also ensures that the necessary templates are loaded
|
||||
world.clear_courses()
|
||||
|
||||
weight = 0.1
|
||||
grading_policy = {
|
||||
"GRADER": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"short_label": "HW",
|
||||
"weight": weight
|
||||
},
|
||||
]
|
||||
}
|
||||
metadata.update(grading_policy)
|
||||
|
||||
# Create the course
|
||||
# We always use the same org and display name,
|
||||
# but vary the course identifier (e.g. 600x or 191x)
|
||||
@@ -145,18 +165,30 @@ def create_course(course, metadata):
|
||||
org='edx',
|
||||
number=course,
|
||||
display_name='Test Course',
|
||||
metadata=metadata
|
||||
metadata=metadata,
|
||||
grading_policy={
|
||||
"GRADER": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"short_label": "HW",
|
||||
"weight": weight
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# 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'
|
||||
display_name='Test Section',
|
||||
)
|
||||
world.scenario_dict['SEQUENTIAL'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['SECTION'].location,
|
||||
category='sequential',
|
||||
display_name='Test Section')
|
||||
display_name='Test Section',
|
||||
metadata={'graded': True, 'format': 'Homework'})
|
||||
|
||||
|
||||
def i_am_registered_for_the_course(course, metadata):
|
||||
@@ -170,6 +202,7 @@ def i_am_registered_for_the_course(course, metadata):
|
||||
# If the user is not already enrolled, enroll the user.
|
||||
CourseEnrollment.enroll(usr, course_id(course))
|
||||
|
||||
world.add_to_course_staff('robot', world.scenario_dict['COURSE'].number)
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@@ -196,3 +229,41 @@ def check_lti_popup():
|
||||
world.browser.switch_to_window(parent_window) # Switch to the main window again
|
||||
|
||||
|
||||
@step('I see text "([^"]*)"$')
|
||||
def check_progress(_step, text):
|
||||
assert world.browser.is_text_present(text)
|
||||
|
||||
|
||||
@step('I see graph with total progress "([^"]*)"$')
|
||||
def see_graph(_step, progress):
|
||||
SELECTOR = 'grade-detail-graph'
|
||||
node = world.browser.find_by_xpath('//div[@id="{parent}"]//div[text()="{progress}"]'.format(
|
||||
parent=SELECTOR,
|
||||
progress=progress,
|
||||
))
|
||||
|
||||
assert node
|
||||
|
||||
|
||||
@step('I see in the gradebook table that "([^"]*)" is "([^"]*)"$')
|
||||
def see_value_in_the_gradebook(_step, label, text):
|
||||
TABLE_SELECTOR = '.grade-table'
|
||||
index = 0
|
||||
table_headers = world.css_find('{0} thead th'.format(TABLE_SELECTOR))
|
||||
|
||||
for i, element in enumerate(table_headers):
|
||||
if element.text.strip() == label:
|
||||
index = i
|
||||
break;
|
||||
|
||||
assert world.css_has_text('{0} tbody td'.format(TABLE_SELECTOR), text, index=index)
|
||||
|
||||
|
||||
@step('I submit answer to LTI question$')
|
||||
def click_grade(_step):
|
||||
location = world.scenario_dict['LTI'].location.html_id()
|
||||
iframe_name = 'ltiLaunchFrame-' + location
|
||||
with world.browser.get_iframe(iframe_name) as iframe:
|
||||
iframe.find_by_name('submit-button').first.click()
|
||||
assert iframe.is_text_present('LTI consumer (edX) responded with XML content')
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ def setup_mock_lti_server():
|
||||
server_thread.daemon = True
|
||||
server_thread.start()
|
||||
|
||||
server.server_host = server_host
|
||||
server.oauth_settings = {
|
||||
'client_key': 'test_client_key',
|
||||
'client_secret': 'test_client_secret',
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
|
||||
from uuid import uuid4
|
||||
import textwrap
|
||||
import urlparse
|
||||
from oauthlib.oauth1.rfc5849 import signature
|
||||
import oauthlib.oauth1
|
||||
import hashlib
|
||||
import base64
|
||||
import mock
|
||||
import sys
|
||||
import requests
|
||||
import textwrap
|
||||
|
||||
from logging import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -13,6 +21,7 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
||||
'''
|
||||
|
||||
protocol = "HTTP/1.0"
|
||||
callback_url = None
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Log an arbitrary message."""
|
||||
@@ -23,24 +32,42 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
||||
self.log_date_time_string(),
|
||||
format % args))
|
||||
|
||||
def do_HEAD(self):
|
||||
self._send_head()
|
||||
def do_GET(self):
|
||||
'''
|
||||
Handle a GET request from the client and sends response back.
|
||||
'''
|
||||
|
||||
self.send_response(200, 'OK')
|
||||
self.send_header('Content-type', 'html')
|
||||
self.end_headers()
|
||||
|
||||
response_str = """<html><head><title>TEST TITLE</title></head>
|
||||
<body>I have stored grades.</body></html>"""
|
||||
|
||||
self.wfile.write(response_str)
|
||||
|
||||
self._send_graded_result()
|
||||
|
||||
|
||||
|
||||
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),
|
||||
str(self.post_dict),
|
||||
self.path)
|
||||
) # Log the request
|
||||
|
||||
# Respond only to requests with correct lti endpoint:
|
||||
if self._is_correct_lti_request():
|
||||
'''
|
||||
# Respond to grade request
|
||||
if 'grade' in self.path and self._send_graded_result().status_code == 200:
|
||||
status_message = 'LTI consumer (edX) responded with XML content:<br>' + self.server.grade_data['TC answer']
|
||||
self.server.grade_data['callback_url'] = None
|
||||
# Respond to request with correct lti endpoint:
|
||||
elif self._is_correct_lti_request():
|
||||
self.post_dict = self._post_dict()
|
||||
correct_keys = [
|
||||
'user_id',
|
||||
'role',
|
||||
@@ -55,31 +82,41 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
||||
'oauth_callback',
|
||||
'lis_outcome_service_url',
|
||||
'lis_result_sourcedid',
|
||||
'launch_presentation_return_url'
|
||||
'launch_presentation_return_url',
|
||||
# 'lis_person_sourcedid', optional, not used now.
|
||||
'resource_link_id',
|
||||
]
|
||||
|
||||
if sorted(correct_keys) != sorted(post_dict.keys()):
|
||||
if sorted(correct_keys) != sorted(self.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']):
|
||||
params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'}
|
||||
if self.server.check_oauth_signature(params, self.post_dict['oauth_signature']):
|
||||
status_message = "This is LTI tool. Success."
|
||||
else:
|
||||
status_message = "Wrong LTI signature"
|
||||
# set data for grades
|
||||
# what need to be stored as server data
|
||||
self.server.grade_data = {
|
||||
'callback_url': self.post_dict["lis_outcome_service_url"],
|
||||
'sourcedId': self.post_dict['lis_result_sourcedid']
|
||||
}
|
||||
else:
|
||||
status_message = "Invalid request URL"
|
||||
|
||||
self._send_head()
|
||||
self._send_response(status_message)
|
||||
|
||||
def _send_head(self):
|
||||
'''
|
||||
Send the response code and MIME headers
|
||||
'''
|
||||
self.send_response(200)
|
||||
'''
|
||||
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()
|
||||
|
||||
@@ -100,18 +137,91 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
||||
# the correct fields, it won't find them,
|
||||
# and will therefore send an error response
|
||||
return {}
|
||||
try:
|
||||
cookie = self.headers.getheader('cookie')
|
||||
self.server.cookie = {k.strip(): v[0] for k, v in urlparse.parse_qs(cookie).items()}
|
||||
except:
|
||||
self.server.cookie = {}
|
||||
referer = urlparse.urlparse(self.headers.getheader('referer'))
|
||||
self.server.referer_host = "{}://{}".format(referer.scheme, referer.netloc)
|
||||
self.server.referer_netloc = referer.netloc
|
||||
return post_dict
|
||||
|
||||
def _send_graded_result(self):
|
||||
|
||||
values = {
|
||||
'textString': 0.5,
|
||||
'sourcedId': self.server.grade_data['sourcedId'],
|
||||
'imsx_messageIdentifier': uuid4().hex,
|
||||
}
|
||||
|
||||
payload = 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>{imsx_messageIdentifier}</imsx_messageIdentifier> /
|
||||
</imsx_POXRequestHeaderInfo>
|
||||
</imsx_POXHeader>
|
||||
<imsx_POXBody>
|
||||
<replaceResultRequest>
|
||||
<resultRecord>
|
||||
<sourcedGUID>
|
||||
<sourcedId>{sourcedId}</sourcedId>
|
||||
</sourcedGUID>
|
||||
<result>
|
||||
<resultScore>
|
||||
<language>en-us</language>
|
||||
<textString>{textString}</textString>
|
||||
</resultScore>
|
||||
</result>
|
||||
</resultRecord>
|
||||
</replaceResultRequest>
|
||||
</imsx_POXBody>
|
||||
</imsx_POXEnvelopeRequest>
|
||||
""")
|
||||
data = payload.format(**values)
|
||||
# temporarily changed to get for easy view in browser
|
||||
# get relative part, because host name is different in a) manual tests b) acceptance tests c) demos
|
||||
relative_url = urlparse.urlparse(self.server.grade_data['callback_url']).path
|
||||
url = self.server.referer_host + relative_url
|
||||
|
||||
headers = {'Content-Type': 'application/xml', 'X-Requested-With': 'XMLHttpRequest'}
|
||||
|
||||
headers['Authorization'] = self.oauth_sign(url, data)
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
data=data,
|
||||
headers=headers
|
||||
)
|
||||
self.server.grade_data['TC answer'] = response.content
|
||||
return response
|
||||
|
||||
def _send_response(self, message):
|
||||
'''
|
||||
Send message back to the client
|
||||
'''
|
||||
response_str = """<html><head><title>TEST TITLE</title></head>
|
||||
<body>
|
||||
<div><h2>IFrame loaded</h2> \
|
||||
<h3>Server response is:</h3>\
|
||||
<h3 class="result">{}</h3></div>
|
||||
</body></html>""".format(message)
|
||||
|
||||
if self.server.grade_data['callback_url']:
|
||||
response_str = """<html><head><title>TEST TITLE</title></head>
|
||||
<body>
|
||||
<div><h2>Graded IFrame loaded</h2> \
|
||||
<h3>Server response is:</h3>\
|
||||
<h3 class="result">{}</h3></div>
|
||||
<form action="{url}/grade" method="post">
|
||||
<input type="submit" name="submit-button" value="Submit">
|
||||
</form>
|
||||
|
||||
</body></html>""".format(message, url="http://%s:%s" % self.server.server_address)
|
||||
else:
|
||||
response_str = """<html><head><title>TEST TITLE</title></head>
|
||||
<body>
|
||||
<div><h2>IFrame loaded</h2> \
|
||||
<h3>Server response is:</h3>\
|
||||
<h3 class="result">{}</h3></div>
|
||||
</body></html>""".format(message)
|
||||
|
||||
# Log the response
|
||||
logger.debug("LTI: sent response {}".format(response_str))
|
||||
@@ -122,6 +232,34 @@ class MockLTIRequestHandler(BaseHTTPRequestHandler):
|
||||
'''If url to LTI tool is correct.'''
|
||||
return self.server.oauth_settings['lti_endpoint'] in self.path
|
||||
|
||||
def oauth_sign(self, url, body):
|
||||
"""
|
||||
Signs request and returns signed body and headers.
|
||||
|
||||
"""
|
||||
|
||||
client = oauthlib.oauth1.Client(
|
||||
client_key=unicode(self.server.oauth_settings['client_key']),
|
||||
client_secret=unicode(self.server.oauth_settings['client_secret'])
|
||||
)
|
||||
headers = {
|
||||
# This is needed for body encoding:
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
|
||||
#Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html
|
||||
sha1 = hashlib.sha1()
|
||||
sha1.update(body)
|
||||
oauth_body_hash = base64.b64encode(sha1.hexdigest())
|
||||
__, headers, __ = client.sign(
|
||||
unicode(url.strip()),
|
||||
http_method=u'POST',
|
||||
body={u'oauth_body_hash': oauth_body_hash},
|
||||
headers=headers
|
||||
)
|
||||
headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash)
|
||||
return headers
|
||||
|
||||
|
||||
class MockLTIServer(HTTPServer):
|
||||
'''
|
||||
@@ -172,6 +310,5 @@ class MockLTIServer(HTTPServer):
|
||||
request.uri = unicode(url)
|
||||
request.http_method = u'POST'
|
||||
request.signature = unicode(client_signature)
|
||||
|
||||
return signature.verify_hmac_sha1(request, client_secret)
|
||||
|
||||
|
||||
25
lms/djangoapps/courseware/mock_lti_server/server_start.py
Normal file
25
lms/djangoapps/courseware/mock_lti_server/server_start.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Mock LTI server for manual testing.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from mock_lti_server import MockLTIServer
|
||||
|
||||
server_port = 8034
|
||||
server_host = 'localhost'
|
||||
address = (server_host, server_port)
|
||||
|
||||
server = MockLTIServer(address)
|
||||
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'
|
||||
}
|
||||
server.server_host = server_host
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print('^C received, shutting down server')
|
||||
server.socket.close()
|
||||
@@ -1,12 +1,15 @@
|
||||
"""
|
||||
Test for Mock_LTI_Server
|
||||
"""
|
||||
import mock
|
||||
from mock import Mock
|
||||
import unittest
|
||||
import threading
|
||||
import textwrap
|
||||
import urllib
|
||||
import requests
|
||||
from mock_lti_server import MockLTIServer
|
||||
|
||||
from nose.plugins.skip import SkipTest
|
||||
|
||||
|
||||
class MockLTIServerTest(unittest.TestCase):
|
||||
@@ -19,14 +22,9 @@ class MockLTIServerTest(unittest.TestCase):
|
||||
|
||||
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'
|
||||
server_host = 'localhost'
|
||||
address = (server_host, server_port)
|
||||
self.server = MockLTIServer(address)
|
||||
self.server.oauth_settings = {
|
||||
@@ -45,12 +43,12 @@ class MockLTIServerTest(unittest.TestCase):
|
||||
# Stop the server, freeing up the port
|
||||
self.server.shutdown()
|
||||
|
||||
def test_request(self):
|
||||
def test_wrong_signature(self):
|
||||
"""
|
||||
Tests that LTI server processes request with right program
|
||||
path, and responses with incorrect signature.
|
||||
path and responses with incorrect signature.
|
||||
"""
|
||||
request = {
|
||||
payload = {
|
||||
'user_id': 'default_user_id',
|
||||
'role': 'student',
|
||||
'oauth_nonce': '',
|
||||
@@ -64,12 +62,46 @@ class MockLTIServerTest(unittest.TestCase):
|
||||
'oauth_callback': 'about:blank',
|
||||
'launch_presentation_return_url': '',
|
||||
'lis_outcome_service_url': '',
|
||||
'lis_result_sourcedid': ''
|
||||
'lis_result_sourcedid': '',
|
||||
'resource_link_id':'',
|
||||
}
|
||||
|
||||
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)
|
||||
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
|
||||
headers = {'referer': 'http://localhost:8000/'}
|
||||
response = requests.post(uri, data=payload, headers=headers)
|
||||
|
||||
self.assertTrue('Wrong LTI signature' in response.content)
|
||||
|
||||
|
||||
def test_success_response_launch_lti(self):
|
||||
"""
|
||||
Success lti launch.
|
||||
"""
|
||||
|
||||
payload = {
|
||||
'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': '',
|
||||
'resource_link_id':'',
|
||||
"lis_outcome_service_url": '',
|
||||
}
|
||||
self.server.check_oauth_signature = Mock(return_value=True)
|
||||
|
||||
uri = self.server.oauth_settings['lti_base'] + self.server.oauth_settings['lti_endpoint']
|
||||
headers = {'referer': 'http://localhost:8000/'}
|
||||
|
||||
response = requests.post(uri, data=payload, headers=headers)
|
||||
|
||||
self.assertTrue('This is LTI tool. Success.' in response.content)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from lms.lib.xblock.field_data import LmsFieldData
|
||||
from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
|
||||
from student.models import unique_id_for_user
|
||||
from student.models import anonymous_id_for_user, user_by_anonymous_id
|
||||
from util.json_request import JsonResponse
|
||||
from util.sandboxing import can_execute_unsafe_code
|
||||
from xblock.fields import Scope
|
||||
@@ -37,6 +37,7 @@ from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xblock
|
||||
from xmodule.lti_module import LTIModule
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -285,15 +286,20 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
position, wrap_xmodule_display, grade_bucket_type,
|
||||
static_asset_path)
|
||||
|
||||
def publish(event):
|
||||
def publish(event, custom_user=None):
|
||||
"""A function that allows XModules to publish events. This only supports grade changes right now."""
|
||||
if event.get('event_name') != 'grade':
|
||||
return
|
||||
|
||||
if custom_user:
|
||||
user_id = custom_user.id
|
||||
else:
|
||||
user_id = user.id
|
||||
|
||||
# Construct the key for the module
|
||||
key = KeyValueStore.Key(
|
||||
scope=Scope.user_state,
|
||||
user_id=user.id,
|
||||
user_id=user_id,
|
||||
block_scope_id=descriptor.location,
|
||||
field_name='grade'
|
||||
)
|
||||
@@ -361,6 +367,17 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
if has_access(user, descriptor, 'staff', course_id):
|
||||
block_wrappers.append(partial(add_histogram, user))
|
||||
|
||||
# These modules store data using the anonymous_student_id as a key.
|
||||
# To prevent loss of data, we will continue to provide old modules with
|
||||
# the per-student anonymized id (as we have in the past),
|
||||
# while giving selected modules a per-course anonymized id.
|
||||
# As we have the time to manually test more modules, we can add to the list
|
||||
# of modules that get the per-course anonymized id.
|
||||
if issubclass(getattr(descriptor, 'module_class', None), LTIModule):
|
||||
anonymous_student_id = anonymous_id_for_user(user, course_id)
|
||||
else:
|
||||
anonymous_student_id = anonymous_id_for_user(user, '')
|
||||
|
||||
system = LmsModuleSystem(
|
||||
track_function=track_function,
|
||||
render_template=render_to_string,
|
||||
@@ -392,7 +409,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
),
|
||||
node_path=settings.NODE_PATH,
|
||||
publish=publish,
|
||||
anonymous_student_id=unique_id_for_user(user),
|
||||
anonymous_student_id=anonymous_student_id,
|
||||
course_id=course_id,
|
||||
open_ended_grading_interface=open_ended_grading_interface,
|
||||
s3_interface=s3_interface,
|
||||
@@ -401,6 +418,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
# TODO: When we merge the descriptor and module systems, we can stop reaching into the mixologist (cpennington)
|
||||
mixins=descriptor.runtime.mixologist._mixins, # pylint: disable=protected-access
|
||||
wrappers=block_wrappers,
|
||||
get_real_user=user_by_anonymous_id,
|
||||
)
|
||||
|
||||
# pass position specified in URL to module through ModuleSystem
|
||||
|
||||
@@ -4,6 +4,9 @@ import oauthlib
|
||||
from . import BaseTestXmodule
|
||||
from collections import OrderedDict
|
||||
import mock
|
||||
import urllib
|
||||
from xmodule.lti_module import LTIModule
|
||||
from mock import Mock
|
||||
|
||||
|
||||
class TestLTI(BaseTestXmodule):
|
||||
@@ -26,21 +29,33 @@ class TestLTI(BaseTestXmodule):
|
||||
mocked_signature_after_sign = u'my_signature%3D'
|
||||
mocked_decoded_signature = u'my_signature='
|
||||
|
||||
lti_id = self.item_module.lti_id
|
||||
module_id = unicode(urllib.quote(self.item_module.id))
|
||||
user_id = unicode(self.item_descriptor.xmodule_runtime.anonymous_student_id)
|
||||
|
||||
sourcedId = u':'.join(urllib.quote(i) for i in (lti_id, module_id, user_id))
|
||||
|
||||
lis_outcome_service_url = 'http://{host}{path}'.format(
|
||||
host=self.item_descriptor.xmodule_runtime.hostname,
|
||||
path=self.item_descriptor.xmodule_runtime.handler_url(self.item_module, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||
)
|
||||
self.correct_headers = {
|
||||
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',
|
||||
|
||||
u'resource_link_id': module_id,
|
||||
u'lis_outcome_service_url': lis_outcome_service_url,
|
||||
u'lis_result_sourcedid': sourcedId,
|
||||
|
||||
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.item_descriptor.xmodule_runtime.anonymous_student_id,
|
||||
u'role': u'student',
|
||||
u'oauth_signature': mocked_decoded_signature
|
||||
}
|
||||
|
||||
@@ -70,14 +85,16 @@ class TestLTI(BaseTestXmodule):
|
||||
Makes sure that all parameters extracted.
|
||||
"""
|
||||
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,
|
||||
'input_fields': self.correct_headers,
|
||||
'element_class': self.item_module.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,
|
||||
self.runtime.render_template('lti.html', expected_context),
|
||||
@@ -15,10 +15,11 @@ from django.test.utils import override_settings
|
||||
from xblock.field_data import FieldData
|
||||
from xblock.runtime import Runtime
|
||||
from xblock.fields import ScopeIds
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.lti_module import LTIDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
import courseware.module_render as render
|
||||
from courseware.tests.tests import LoginEnrollmentTestCase
|
||||
@@ -521,7 +522,7 @@ class TestHtmlModifiers(ModuleStoreTestCase):
|
||||
result_fragment.content
|
||||
)
|
||||
|
||||
PER_COURSE_ANONYMIZED_DESCRIPTORS = ()
|
||||
PER_COURSE_ANONYMIZED_DESCRIPTORS = (LTIDescriptor, )
|
||||
|
||||
PER_STUDENT_ANONYMIZED_DESCRIPTORS = [
|
||||
class_ for (name, class_) in XModuleDescriptor.load_classes()
|
||||
|
||||
@@ -32,7 +32,7 @@ task :showdocs, [:options] do |t, args|
|
||||
elsif args.options == 'data'
|
||||
path = "docs/data"
|
||||
else
|
||||
path = "docs"
|
||||
path = "docs/developers"
|
||||
end
|
||||
|
||||
Launchy.open("#{path}/build/html/index.html")
|
||||
|
||||
Reference in New Issue
Block a user