Merge pull request #2685 from edx/jbau/lti-20-outcomes-rest
LTI 2.0 results service endpoint
This commit is contained in:
@@ -13,7 +13,7 @@ from uuid import uuid4
|
||||
import textwrap
|
||||
import urllib
|
||||
import re
|
||||
from oauthlib.oauth1.rfc5849 import signature
|
||||
from oauthlib.oauth1.rfc5849 import signature, parameters
|
||||
import oauthlib.oauth1
|
||||
import hashlib
|
||||
import base64
|
||||
@@ -46,7 +46,16 @@ class StubLtiHandler(StubHttpRequestHandler):
|
||||
status_message = 'LTI consumer (edX) responded with XML content:<br>' + self.server.grade_data['TC answer']
|
||||
content = self._create_content(status_message)
|
||||
self.send_response(200, content)
|
||||
|
||||
elif 'lti2_outcome' in self.path and self._send_lti2_outcome().status_code == 200:
|
||||
status_message = 'LTI consumer (edX) responded with HTTP {}<br>'.format(
|
||||
self.server.grade_data['status_code'])
|
||||
content = self._create_content(status_message)
|
||||
self.send_response(200, content)
|
||||
elif 'lti2_delete' in self.path and self._send_lti2_delete().status_code == 200:
|
||||
status_message = 'LTI consumer (edX) responded with HTTP {}<br>'.format(
|
||||
self.server.grade_data['status_code'])
|
||||
content = self._create_content(status_message)
|
||||
self.send_response(200, content)
|
||||
# Respond to request with correct lti endpoint
|
||||
elif self._is_correct_lti_request():
|
||||
params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'}
|
||||
@@ -57,7 +66,7 @@ class StubLtiHandler(StubHttpRequestHandler):
|
||||
# Set data for grades what need to be stored as server data
|
||||
if 'lis_outcome_service_url' in self.post_dict:
|
||||
self.server.grade_data = {
|
||||
'callback_url': self.post_dict.get('lis_outcome_service_url'),
|
||||
'callback_url': self.post_dict.get('lis_outcome_service_url').replace('https', 'http'),
|
||||
'sourcedId': self.post_dict.get('lis_result_sourcedid')
|
||||
}
|
||||
|
||||
@@ -122,16 +131,75 @@ class StubLtiHandler(StubHttpRequestHandler):
|
||||
self.server.grade_data['TC answer'] = response.content
|
||||
return response
|
||||
|
||||
def _send_lti2_outcome(self):
|
||||
"""
|
||||
Send a grade back to consumer
|
||||
"""
|
||||
payload = textwrap.dedent("""
|
||||
{{
|
||||
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@type" : "Result",
|
||||
"resultScore" : {score},
|
||||
"comment" : "This is awesome."
|
||||
}}
|
||||
""")
|
||||
data = payload.format(score=0.8)
|
||||
return self._send_lti2(data)
|
||||
|
||||
def _send_lti2_delete(self):
|
||||
"""
|
||||
Send a delete back to consumer
|
||||
"""
|
||||
payload = textwrap.dedent("""
|
||||
{
|
||||
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@type" : "Result"
|
||||
}
|
||||
""")
|
||||
return self._send_lti2(payload)
|
||||
|
||||
def _send_lti2(self, payload):
|
||||
"""
|
||||
Send lti2 json result service request.
|
||||
"""
|
||||
### We compute the LTI V2.0 service endpoint from the callback_url (which is set by the launch call)
|
||||
url = self.server.grade_data['callback_url']
|
||||
url_parts = url.split('/')
|
||||
url_parts[-1] = "lti_2_0_result_rest_handler"
|
||||
anon_id = self.server.grade_data['sourcedId'].split(":")[-1]
|
||||
url_parts.extend(["user", anon_id])
|
||||
new_url = '/'.join(url_parts)
|
||||
|
||||
content_type = 'application/vnd.ims.lis.v2.result+json'
|
||||
headers = {
|
||||
'Content-Type': content_type,
|
||||
'Authorization': self._oauth_sign(new_url, payload,
|
||||
method='PUT',
|
||||
content_type=content_type)
|
||||
}
|
||||
|
||||
# Send request ignoring verifirecation of SSL certificate
|
||||
response = requests.put(new_url, data=payload, headers=headers, verify=False)
|
||||
self.server.grade_data['status_code'] = response.status_code
|
||||
self.server.grade_data['TC answer'] = response.content
|
||||
return response
|
||||
|
||||
def _create_content(self, response_text, submit_url=None):
|
||||
"""
|
||||
Return content (str) either for launch, send grade or get result from TC.
|
||||
"""
|
||||
if submit_url:
|
||||
submit_form = textwrap.dedent("""
|
||||
<form action="{}/grade" method="post">
|
||||
<form action="{submit_url}/grade" method="post">
|
||||
<input type="submit" name="submit-button" value="Submit">
|
||||
</form>
|
||||
""").format(submit_url)
|
||||
<form action="{submit_url}/lti2_outcome" method="post">
|
||||
<input type="submit" name="submit-lti2-button" value="Submit">
|
||||
</form>
|
||||
<form action="{submit_url}/lti2_delete" method="post">
|
||||
<input type="submit" name="submit-lti2-delete-button" value="Submit">
|
||||
</form>
|
||||
""").format(submit_url=submit_url)
|
||||
else:
|
||||
submit_form = ''
|
||||
|
||||
@@ -169,9 +237,9 @@ class StubLtiHandler(StubHttpRequestHandler):
|
||||
lti_endpoint = self.server.config.get('lti_endpoint', self.DEFAULT_LTI_ENDPOINT)
|
||||
return lti_endpoint in self.path
|
||||
|
||||
def _oauth_sign(self, url, body):
|
||||
def _oauth_sign(self, url, body, content_type=u'application/x-www-form-urlencoded', method=u'POST'):
|
||||
"""
|
||||
Signs request and returns signed body and headers.
|
||||
Signs request and returns signed Authorization header.
|
||||
"""
|
||||
client_key = self.server.config.get('client_key', self.DEFAULT_CLIENT_KEY)
|
||||
client_secret = self.server.config.get('client_secret', self.DEFAULT_CLIENT_SECRET)
|
||||
@@ -181,21 +249,27 @@ class StubLtiHandler(StubHttpRequestHandler):
|
||||
)
|
||||
headers = {
|
||||
# This is needed for body encoding:
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Type': content_type,
|
||||
}
|
||||
|
||||
# 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.digest())
|
||||
__, headers, __ = client.sign(
|
||||
unicode(url.strip()),
|
||||
http_method=u'POST',
|
||||
body={u'oauth_body_hash': oauth_body_hash},
|
||||
headers=headers
|
||||
oauth_body_hash = unicode(base64.b64encode(sha1.digest())) # pylint: disable=too-many-function-args
|
||||
params = client.get_oauth_params()
|
||||
params.append((u'oauth_body_hash', oauth_body_hash))
|
||||
mock_request = mock.Mock(
|
||||
uri=unicode(urllib.unquote(url)),
|
||||
headers=headers,
|
||||
body=u"",
|
||||
decoded_body=u"",
|
||||
oauth_params=params,
|
||||
http_method=unicode(method),
|
||||
)
|
||||
headers = headers['Authorization'] + ', oauth_body_hash="{}"'.format(oauth_body_hash)
|
||||
return headers
|
||||
sig = client.get_oauth_signature(mock_request)
|
||||
mock_request.oauth_params.append((u'oauth_signature', sig))
|
||||
new_headers = parameters.prepare_headers(mock_request.oauth_params, headers, realm=None)
|
||||
return new_headers['Authorization']
|
||||
|
||||
def _check_oauth_signature(self, params, client_signature):
|
||||
"""
|
||||
|
||||
@@ -62,7 +62,7 @@ class StubLtiServiceTest(unittest.TestCase):
|
||||
self.assertIn('This is LTI tool. Success.', response.content)
|
||||
|
||||
@patch('terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True)
|
||||
def test_send_graded_result(self, verify_hmac):
|
||||
def test_send_graded_result(self, verify_hmac): # pylint: disable=unused-argument
|
||||
response = requests.post(self.launch_uri, data=self.payload)
|
||||
self.assertIn('This is LTI tool. Success.', response.content)
|
||||
grade_uri = self.uri + 'grade'
|
||||
@@ -70,3 +70,23 @@ class StubLtiServiceTest(unittest.TestCase):
|
||||
mocked_post.return_value = Mock(content='Test response', status_code=200)
|
||||
response = urllib2.urlopen(grade_uri, data='')
|
||||
self.assertIn('Test response', response.read())
|
||||
|
||||
@patch('terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True)
|
||||
def test_lti20_outcomes_put(self, verify_hmac): # pylint: disable=unused-argument
|
||||
response = requests.post(self.launch_uri, data=self.payload)
|
||||
self.assertIn('This is LTI tool. Success.', response.content)
|
||||
grade_uri = self.uri + 'lti2_outcome'
|
||||
with patch('terrain.stubs.lti.requests.put') as mocked_put:
|
||||
mocked_put.return_value = Mock(status_code=200)
|
||||
response = urllib2.urlopen(grade_uri, data='')
|
||||
self.assertIn('LTI consumer (edX) responded with HTTP 200', response.read())
|
||||
|
||||
@patch('terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True)
|
||||
def test_lti20_outcomes_put_like_delete(self, verify_hmac): # pylint: disable=unused-argument
|
||||
response = requests.post(self.launch_uri, data=self.payload)
|
||||
self.assertIn('This is LTI tool. Success.', response.content)
|
||||
grade_uri = self.uri + 'lti2_delete'
|
||||
with patch('terrain.stubs.lti.requests.put') as mocked_put:
|
||||
mocked_put.return_value = Mock(status_code=200)
|
||||
response = urllib2.urlopen(grade_uri, data='')
|
||||
self.assertIn('LTI consumer (edX) responded with HTTP 200', response.read())
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
h2.problem-header {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
div.problem-progress {
|
||||
display: inline-block;
|
||||
padding-left: 5px;
|
||||
color: #666;
|
||||
font-weight: 100;
|
||||
font-size: em(16);
|
||||
}
|
||||
|
||||
|
||||
div.lti {
|
||||
// align center
|
||||
margin: 0 auto;
|
||||
@@ -31,4 +44,16 @@ div.lti {
|
||||
display: block;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
h4.problem-feedback-label {
|
||||
font-weight: 100;
|
||||
font-size: em(16);
|
||||
font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif;
|
||||
}
|
||||
|
||||
div.problem-feedback {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
363
common/lib/xmodule/xmodule/lti_2_util.py
Normal file
363
common/lib/xmodule/xmodule/lti_2_util.py
Normal file
@@ -0,0 +1,363 @@
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
"""
|
||||
A mixin class for LTI 2.0 functionality. This is really just done to refactor the code to
|
||||
keep the LTIModule class from getting too big
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import mock
|
||||
import urllib
|
||||
import hashlib
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from webob import Response
|
||||
from xblock.core import XBlock
|
||||
from oauthlib.oauth1 import Client
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LTI_2_0_REST_SUFFIX_PARSER = re.compile(r"^user/(?P<anon_id>\w+)", re.UNICODE)
|
||||
LTI_2_0_JSON_CONTENT_TYPE = 'application/vnd.ims.lis.v2.result+json'
|
||||
|
||||
|
||||
class LTIError(Exception):
|
||||
"""Error class for LTIModule and LTI20ModuleMixin"""
|
||||
pass
|
||||
|
||||
|
||||
class LTI20ModuleMixin(object):
|
||||
"""
|
||||
This class MUST be mixed into LTIModule. It does not do anything on its own. It's just factored
|
||||
out for modularity.
|
||||
"""
|
||||
|
||||
# LTI 2.0 Result Service Support
|
||||
@XBlock.handler
|
||||
def lti_2_0_result_rest_handler(self, request, suffix):
|
||||
"""
|
||||
Handler function for LTI 2.0 JSON/REST result service.
|
||||
|
||||
See http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
|
||||
An example JSON object:
|
||||
{
|
||||
"@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@type" : "Result",
|
||||
"resultScore" : 0.83,
|
||||
"comment" : "This is exceptional work."
|
||||
}
|
||||
For PUTs, the content type must be "application/vnd.ims.lis.v2.result+json".
|
||||
We use the "suffix" parameter to parse out the user from the end of the URL. An example endpoint url is
|
||||
http://localhost:8000/courses/org/num/run/xblock/i4x:;_;_org;_num;_lti;_GUID/handler_noauth/lti_2_0_result_rest_handler/user/<anon_id>
|
||||
so suffix is of the form "user/<anon_id>"
|
||||
Failures result in 401, 404, or 500s without any body. Successes result in 200. Again see
|
||||
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
|
||||
(Note: this prevents good debug messages for the client, so we might want to change this, or the spec)
|
||||
|
||||
Arguments:
|
||||
request (xblock.django.request.DjangoWebobRequest): Request object for current HTTP request
|
||||
suffix (unicode): request path after "lti_2_0_result_rest_handler/". expected to be "user/<anon_id>"
|
||||
|
||||
Returns:
|
||||
webob.response: response to this request. See above for details.
|
||||
"""
|
||||
if self.system.debug:
|
||||
self._log_correct_authorization_header(request)
|
||||
|
||||
try:
|
||||
anon_id = self.parse_lti_2_0_handler_suffix(suffix)
|
||||
except LTIError:
|
||||
return Response(status=404) # 404 because a part of the URL (denoting the anon user id) is invalid
|
||||
try:
|
||||
self.verify_lti_2_0_result_rest_headers(request, verify_content_type=True)
|
||||
except LTIError:
|
||||
return Response(status=401) # Unauthorized in this case. 401 is right
|
||||
|
||||
real_user = self.system.get_real_user(anon_id)
|
||||
if not real_user: # that means we can't save to database, as we do not have real user id.
|
||||
msg = "[LTI]: Real user not found against anon_id: {}".format(anon_id)
|
||||
log.info(msg)
|
||||
return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body
|
||||
if request.method == "PUT":
|
||||
return self._lti_2_0_result_put_handler(request, real_user)
|
||||
elif request.method == "GET":
|
||||
return self._lti_2_0_result_get_handler(request, real_user)
|
||||
elif request.method == "DELETE":
|
||||
return self._lti_2_0_result_del_handler(request, real_user)
|
||||
else:
|
||||
return Response(status=404) # have to do 404 due to spec, but 405 is better, with error msg in body
|
||||
|
||||
def _log_correct_authorization_header(self, request):
|
||||
"""
|
||||
Helper function that logs proper HTTP Authorization header for a given request
|
||||
|
||||
Used only in debug situations, this logs the correct Authorization header based on
|
||||
the request header and body according to OAuth 1 Body signing
|
||||
|
||||
Arguments:
|
||||
request (xblock.django.request.DjangoWebobRequest): Request object to log Authorization header for
|
||||
|
||||
Returns:
|
||||
nothing
|
||||
"""
|
||||
sha1 = hashlib.sha1()
|
||||
sha1.update(request.body)
|
||||
oauth_body_hash = unicode(base64.b64encode(sha1.digest())) # pylint: disable=too-many-function-args
|
||||
log.debug("[LTI] oauth_body_hash = {}".format(oauth_body_hash))
|
||||
client_key, client_secret = self.get_client_key_secret()
|
||||
client = Client(client_key, client_secret)
|
||||
params = client.get_oauth_params()
|
||||
params.append((u'oauth_body_hash', oauth_body_hash))
|
||||
mock_request = mock.Mock(
|
||||
uri=unicode(urllib.unquote(request.url)),
|
||||
headers=request.headers,
|
||||
body=u"",
|
||||
decoded_body=u"",
|
||||
oauth_params=params,
|
||||
http_method=unicode(request.method),
|
||||
)
|
||||
sig = client.get_oauth_signature(mock_request)
|
||||
mock_request.oauth_params.append((u'oauth_signature', sig))
|
||||
|
||||
_, headers, _ = client._render(mock_request) # pylint: disable=protected-access
|
||||
log.debug("\n\n#### COPY AND PASTE AUTHORIZATION HEADER ####\n{}\n####################################\n\n"
|
||||
.format(headers['Authorization']))
|
||||
|
||||
def parse_lti_2_0_handler_suffix(self, suffix):
|
||||
"""
|
||||
Parser function for HTTP request path suffixes
|
||||
|
||||
parses the suffix argument (the trailing parts of the URL) of the LTI2.0 REST handler.
|
||||
must be of the form "user/<anon_id>". Returns anon_id if match found, otherwise raises LTIError
|
||||
|
||||
Arguments:
|
||||
suffix (unicode): suffix to parse
|
||||
|
||||
Returns:
|
||||
unicode: anon_id if match found
|
||||
|
||||
Raises:
|
||||
LTIError if suffix cannot be parsed or is not in its expected form
|
||||
"""
|
||||
if suffix:
|
||||
match_obj = LTI_2_0_REST_SUFFIX_PARSER.match(suffix)
|
||||
if match_obj:
|
||||
return match_obj.group('anon_id')
|
||||
# fall-through handles all error cases
|
||||
msg = "No valid user id found in endpoint URL"
|
||||
log.info("[LTI]: {}".format(msg))
|
||||
raise LTIError(msg)
|
||||
|
||||
def _lti_2_0_result_get_handler(self, request, real_user): # pylint: disable=unused-argument
|
||||
"""
|
||||
Helper request handler for GET requests to LTI 2.0 result endpoint
|
||||
|
||||
GET handler for lti_2_0_result. Assumes all authorization has been checked.
|
||||
|
||||
Arguments:
|
||||
request (xblock.django.request.DjangoWebobRequest): Request object (unused)
|
||||
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
|
||||
|
||||
Returns:
|
||||
webob.response: response to this request, in JSON format with status 200 if success
|
||||
"""
|
||||
base_json_obj = {
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@type": "Result"
|
||||
}
|
||||
self.system.rebind_noauth_module_to_user(self, real_user)
|
||||
if self.module_score is None: # In this case, no score has been ever set
|
||||
return Response(json.dumps(base_json_obj), content_type=LTI_2_0_JSON_CONTENT_TYPE)
|
||||
|
||||
# Fall through to returning grade and comment
|
||||
base_json_obj['resultScore'] = round(self.module_score, 2)
|
||||
base_json_obj['comment'] = self.score_comment
|
||||
return Response(json.dumps(base_json_obj), content_type=LTI_2_0_JSON_CONTENT_TYPE)
|
||||
|
||||
def _lti_2_0_result_del_handler(self, request, real_user): # pylint: disable=unused-argument
|
||||
"""
|
||||
Helper request handler for DELETE requests to LTI 2.0 result endpoint
|
||||
|
||||
DELETE handler for lti_2_0_result. Assumes all authorization has been checked.
|
||||
|
||||
Arguments:
|
||||
request (xblock.django.request.DjangoWebobRequest): Request object (unused)
|
||||
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
|
||||
|
||||
Returns:
|
||||
webob.response: response to this request. status 200 if success
|
||||
"""
|
||||
self.clear_user_module_score(real_user)
|
||||
return Response(status=200)
|
||||
|
||||
def _lti_2_0_result_put_handler(self, request, real_user):
|
||||
"""
|
||||
Helper request handler for PUT requests to LTI 2.0 result endpoint
|
||||
|
||||
PUT handler for lti_2_0_result. Assumes all authorization has been checked.
|
||||
|
||||
Arguments:
|
||||
request (xblock.django.request.DjangoWebobRequest): Request object
|
||||
real_user (django.contrib.auth.models.User): Actual user linked to anon_id in request path suffix
|
||||
|
||||
Returns:
|
||||
webob.response: response to this request. status 200 if success. 404 if body of PUT request is malformed
|
||||
"""
|
||||
try:
|
||||
(score, comment) = self.parse_lti_2_0_result_json(request.body)
|
||||
except LTIError:
|
||||
return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body
|
||||
|
||||
# According to http://www.imsglobal.org/lti/ltiv2p0/ltiIMGv2p0.html#_Toc361225514
|
||||
# PUTting a JSON object with no "resultScore" field is equivalent to a DELETE.
|
||||
if score is None:
|
||||
self.clear_user_module_score(real_user)
|
||||
return Response(status=200)
|
||||
|
||||
# Fall-through record the score and the comment in the module
|
||||
self.set_user_module_score(real_user, score, self.max_score(), comment)
|
||||
return Response(status=200)
|
||||
|
||||
def clear_user_module_score(self, user):
|
||||
"""
|
||||
Clears the module user state, including grades and comments, and also scoring in db's courseware_studentmodule
|
||||
|
||||
Arguments:
|
||||
user (django.contrib.auth.models.User): Actual user whose module state is to be cleared
|
||||
|
||||
Returns:
|
||||
nothing
|
||||
"""
|
||||
self.set_user_module_score(user, None, None)
|
||||
|
||||
def set_user_module_score(self, user, score, max_score, comment=u""):
|
||||
"""
|
||||
Sets the module user state, including grades and comments, and also scoring in db's courseware_studentmodule
|
||||
|
||||
Arguments:
|
||||
user (django.contrib.auth.models.User): Actual user whose module state is to be set
|
||||
score (float): user's numeric score to set. Must be in the range [0.0, 1.0]
|
||||
max_score (float): max score that could have been achieved on this module
|
||||
comment (unicode): comments provided by the grader as feedback to the student
|
||||
|
||||
Returns:
|
||||
nothing
|
||||
"""
|
||||
if score is not None and max_score is not None:
|
||||
scaled_score = score * max_score
|
||||
else:
|
||||
scaled_score = None
|
||||
|
||||
self.system.rebind_noauth_module_to_user(self, user)
|
||||
|
||||
# have to publish for the progress page...
|
||||
self.system.publish(
|
||||
self,
|
||||
'grade',
|
||||
{
|
||||
'value': scaled_score,
|
||||
'max_value': max_score,
|
||||
'user_id': user.id,
|
||||
},
|
||||
)
|
||||
self.module_score = scaled_score
|
||||
self.score_comment = comment
|
||||
|
||||
def verify_lti_2_0_result_rest_headers(self, request, verify_content_type=True):
|
||||
"""
|
||||
Helper method to validate LTI 2.0 REST result service HTTP headers. returns if correct, else raises LTIError
|
||||
|
||||
Arguments:
|
||||
request (xblock.django.request.DjangoWebobRequest): Request object
|
||||
verify_content_type (bool): If true, verifies the content type of the request is that spec'ed by LTI 2.0
|
||||
|
||||
Returns:
|
||||
nothing, but will only return if verification succeeds
|
||||
|
||||
Raises:
|
||||
LTIError if verification fails
|
||||
"""
|
||||
content_type = request.headers.get('Content-Type')
|
||||
if verify_content_type and content_type != LTI_2_0_JSON_CONTENT_TYPE:
|
||||
log.info("[LTI]: v2.0 result service -- bad Content-Type: {}".format(content_type))
|
||||
raise LTIError(
|
||||
"For LTI 2.0 result service, Content-Type must be {}. Got {}".format(LTI_2_0_JSON_CONTENT_TYPE,
|
||||
content_type))
|
||||
try:
|
||||
self.verify_oauth_body_sign(request, content_type=LTI_2_0_JSON_CONTENT_TYPE)
|
||||
except (ValueError, LTIError) as err:
|
||||
log.info("[LTI]: v2.0 result service -- OAuth body verification failed: {}".format(err.message))
|
||||
raise LTIError(err.message)
|
||||
|
||||
def parse_lti_2_0_result_json(self, json_str):
|
||||
"""
|
||||
Helper method for verifying LTI 2.0 JSON object contained in the body of the request.
|
||||
|
||||
The json_str must be loadable. It can either be an dict (object) or an array whose first element is an dict,
|
||||
in which case that first dict is considered.
|
||||
The dict must have the "@type" key with value equal to "Result",
|
||||
"resultScore" key with value equal to a number [0, 1],
|
||||
The "@context" key must be present, but we don't do anything with it. And the "comment" key may be
|
||||
present, in which case it must be a string.
|
||||
|
||||
Arguments:
|
||||
json_str (unicode): The body of the LTI 2.0 results service request, which is a JSON string]
|
||||
|
||||
Returns:
|
||||
(float, str): (score, [optional]comment) if verification checks out
|
||||
|
||||
Raises:
|
||||
LTIError (with message) if verification fails
|
||||
"""
|
||||
try:
|
||||
json_obj = json.loads(json_str)
|
||||
except (ValueError, TypeError):
|
||||
msg = "Supplied JSON string in request body could not be decoded: {}".format(json_str)
|
||||
log.info("[LTI] {}".format(msg))
|
||||
raise LTIError(msg)
|
||||
|
||||
# the standard supports a list of objects, who knows why. It must contain at least 1 element, and the
|
||||
# first element must be a dict
|
||||
if type(json_obj) != dict:
|
||||
if type(json_obj) == list and len(json_obj) >= 1 and type(json_obj[0]) == dict:
|
||||
json_obj = json_obj[0]
|
||||
else:
|
||||
msg = ("Supplied JSON string is a list that does not contain an object as the first element. {}"
|
||||
.format(json_str))
|
||||
log.info("[LTI] {}".format(msg))
|
||||
raise LTIError(msg)
|
||||
|
||||
# '@type' must be "Result"
|
||||
result_type = json_obj.get("@type")
|
||||
if result_type != "Result":
|
||||
msg = "JSON object does not contain correct @type attribute (should be 'Result', is {})".format(result_type)
|
||||
log.info("[LTI] {}".format(msg))
|
||||
raise LTIError(msg)
|
||||
|
||||
# '@context' must be present as a key
|
||||
REQUIRED_KEYS = ["@context"] # pylint: disable=invalid-name
|
||||
for key in REQUIRED_KEYS:
|
||||
if key not in json_obj:
|
||||
msg = "JSON object does not contain required key {}".format(key)
|
||||
log.info("[LTI] {}".format(msg))
|
||||
raise LTIError(msg)
|
||||
|
||||
# 'resultScore' is not present. If this was a PUT this means it's actually a DELETE according
|
||||
# to the LTI spec. We will indicate this by returning None as score, "" as comment.
|
||||
# The actual delete will be handled by the caller
|
||||
if "resultScore" not in json_obj:
|
||||
return None, json_obj.get('comment', "")
|
||||
|
||||
# if present, 'resultScore' must be a number between 0 and 1 inclusive
|
||||
try:
|
||||
score = float(json_obj.get('resultScore', "unconvertable")) # Check if float is present and the right type
|
||||
if not 0 <= score <= 1:
|
||||
msg = 'score value outside the permitted range of 0-1.'
|
||||
log.info("[LTI] {}".format(msg))
|
||||
raise LTIError(msg)
|
||||
except (TypeError, ValueError) as err:
|
||||
msg = "Could not convert resultScore to float: {}".format(err.message)
|
||||
log.info("[LTI] {}".format(msg))
|
||||
raise LTIError(msg)
|
||||
|
||||
return score, json_obj.get('comment', "")
|
||||
@@ -22,6 +22,11 @@ A resource to test the LTI protocol (PHP realization):
|
||||
|
||||
http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php
|
||||
|
||||
We have also begun to add support for LTI 1.2/2.0. We will keep this
|
||||
docstring in synch with what support is available. The first LTI 2.0
|
||||
feature to be supported is the REST API results service, see specification
|
||||
at
|
||||
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
|
||||
|
||||
What is supported:
|
||||
------------------
|
||||
@@ -30,9 +35,20 @@ What is supported:
|
||||
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.
|
||||
A) LTI 1.1.1 XML endpoint
|
||||
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.
|
||||
B) LTI 2.0 Result Service JSON REST endpoint
|
||||
(http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html)
|
||||
a.) Discovery of all such LTI http endpoints for a course. External tools GET from this discovery
|
||||
endpoint and receive URLs for interacting with individual grading units.
|
||||
(see lms/djangoapps/courseware/views.py:get_course_lti_endpoints)
|
||||
b.) GET, PUT and DELETE in LTI Result JSON binding
|
||||
(http://www.imsglobal.org/lti/ltiv2p0/mediatype/application/vnd/ims/lis/v2/result+json/index.html)
|
||||
for a provider to synchronize grades into edx-platform. Reading, Setting, and Deleteing
|
||||
Numeric grades between 0 and 1 and text + basic HTML feedback comments are supported, via
|
||||
GET / PUT / DELETE HTTP methods respectively
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -42,6 +58,7 @@ import hashlib
|
||||
import base64
|
||||
import urllib
|
||||
import textwrap
|
||||
import bleach
|
||||
from lxml import etree
|
||||
from webob import Response
|
||||
import mock
|
||||
@@ -51,15 +68,18 @@ from xmodule.editing_module import MetadataOnlyEditingDescriptor
|
||||
from xmodule.raw_module import EmptyDataRawDescriptor
|
||||
from xmodule.x_module import XModule, module_attr
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.lti_2_util import LTI20ModuleMixin, LTIError
|
||||
from pkg_resources import resource_string
|
||||
from xblock.core import String, Scope, List, XBlock
|
||||
from xblock.fields import Boolean, Float
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LTIError(Exception):
|
||||
pass
|
||||
DOCS_ANCHOR_TAG = (
|
||||
"<a target='_blank'"
|
||||
"href='http://edx.readthedocs.org/projects/ca/en/latest/exercises_tools/lti_component.html'>"
|
||||
"the edX LTI documentation</a>"
|
||||
)
|
||||
|
||||
|
||||
class LTIFields(object):
|
||||
@@ -82,22 +102,95 @@ class LTIFields(object):
|
||||
|
||||
https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136
|
||||
"""
|
||||
display_name = String(display_name="Display Name", help="Display name for this module", scope=Scope.settings, default="LTI")
|
||||
lti_id = String(help="Id of the tool", default='', scope=Scope.settings)
|
||||
launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings)
|
||||
custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings)
|
||||
open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings)
|
||||
graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings)
|
||||
display_name = String(
|
||||
display_name="Display Name",
|
||||
help=(
|
||||
"Enter the name that students see for this component. "
|
||||
"Analytics reports may also use the display name to identify this component."
|
||||
),
|
||||
scope=Scope.settings,
|
||||
default="LTI",
|
||||
)
|
||||
lti_id = String(
|
||||
display_name="LTI ID",
|
||||
help=(
|
||||
"Enter the LTI ID for the external LTI provider. "
|
||||
"This value must be the same LTI ID that you entered in the "
|
||||
"LTI Passports setting on the Advanced Settings page."
|
||||
"<br />See " + DOCS_ANCHOR_TAG + " for more details on this setting."
|
||||
),
|
||||
default='',
|
||||
scope=Scope.settings
|
||||
)
|
||||
launch_url = String(
|
||||
display_name="LTI URL",
|
||||
help=(
|
||||
"Enter the URL of the external tool that this component launches. "
|
||||
"This setting is only used when Hide External Tool is set to False."
|
||||
"<br />See " + DOCS_ANCHOR_TAG + " for more details on this setting."
|
||||
),
|
||||
default='http://www.example.com',
|
||||
scope=Scope.settings)
|
||||
custom_parameters = List(
|
||||
display_name="Custom Parameters",
|
||||
help=(
|
||||
"Add the key/value pair for any custom parameters, such as the page your e-book should open to or "
|
||||
"the background color for this component."
|
||||
"<br />See " + DOCS_ANCHOR_TAG + " for more details on this setting."
|
||||
),
|
||||
scope=Scope.settings)
|
||||
open_in_a_new_page = Boolean(
|
||||
display_name="Open in New Page",
|
||||
help=(
|
||||
"Select True if you want students to click a link that opens the LTI tool in a new window. "
|
||||
"Select False if you want the LTI content to open in an IFrame in the current page. "
|
||||
"This setting is only used when Hide External Tool is set to False. "
|
||||
),
|
||||
default=True,
|
||||
scope=Scope.settings
|
||||
)
|
||||
has_score = Boolean(
|
||||
display_name="Scored",
|
||||
help=(
|
||||
"Select True if this component will receive a numerical score from the external LTI system."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
weight = Float(
|
||||
help="Weight for student grades.",
|
||||
display_name="Weight",
|
||||
help=(
|
||||
"Enter the number of points possible for this component. "
|
||||
"The default value is 1.0. "
|
||||
"This setting is only used when Scored is set to True."
|
||||
),
|
||||
default=1.0,
|
||||
scope=Scope.settings,
|
||||
values={"min": 0},
|
||||
)
|
||||
has_score = Boolean(help="Does this LTI module have score?", default=False, scope=Scope.settings)
|
||||
module_score = Float(
|
||||
help="The score kept in the xblock KVS -- duplicate of the published score in django DB",
|
||||
default=None,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
score_comment = String(
|
||||
help="Comment as returned from grader, LTI2.0 spec",
|
||||
default="",
|
||||
scope=Scope.user_state
|
||||
)
|
||||
hide_launch = Boolean(
|
||||
display_name="Hide External Tool",
|
||||
help=(
|
||||
"Select True if you want to use this component as a placeholder for syncing with an external grading "
|
||||
"system rather than launch an external tool. "
|
||||
"This setting hides the Launch button and any IFrames for this component."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
|
||||
class LTIModule(LTIFields, XModule):
|
||||
class LTIModule(LTIFields, LTI20ModuleMixin, XModule):
|
||||
"""
|
||||
Module provides LTI integration to course.
|
||||
|
||||
@@ -247,6 +340,18 @@ class LTIModule(LTIFields, XModule):
|
||||
"""
|
||||
Returns a context.
|
||||
"""
|
||||
# use bleach defaults. see https://github.com/jsocol/bleach/blob/master/bleach/__init__.py
|
||||
# ALLOWED_TAGS are
|
||||
# ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'strong', 'ul']
|
||||
#
|
||||
# ALLOWED_ATTRIBUTES are
|
||||
# 'a': ['href', 'title'],
|
||||
# 'abbr': ['title'],
|
||||
# 'acronym': ['title'],
|
||||
#
|
||||
# This lets all plaintext through.
|
||||
sanitized_comment = bleach.clean(self.score_comment)
|
||||
|
||||
return {
|
||||
'input_fields': self.get_input_fields(),
|
||||
|
||||
@@ -257,6 +362,11 @@ class LTIModule(LTIFields, XModule):
|
||||
'open_in_a_new_page': self.open_in_a_new_page,
|
||||
'display_name': self.display_name,
|
||||
'form_url': self.runtime.handler_url(self, 'preview_handler').rstrip('/?'),
|
||||
'hide_launch': self.hide_launch,
|
||||
'has_score': self.has_score,
|
||||
'weight': self.weight,
|
||||
'module_score': self.module_score,
|
||||
'comment': sanitized_comment,
|
||||
}
|
||||
|
||||
def get_html(self):
|
||||
@@ -278,7 +388,7 @@ class LTIModule(LTIFields, XModule):
|
||||
assert user_id is not None
|
||||
return unicode(urllib.quote(user_id))
|
||||
|
||||
def get_outcome_service_url(self):
|
||||
def get_outcome_service_url(self, service_name="grade_handler"):
|
||||
"""
|
||||
Return URL for storing grades.
|
||||
|
||||
@@ -286,14 +396,10 @@ class LTIModule(LTIFields, XModule):
|
||||
|
||||
While testing locally and on Jenkins, mock_lti_server use http.referer
|
||||
to obtain scheme, so it is ok to have http(s) anyway.
|
||||
|
||||
The scheme logic is handled in lms/lib/xblock/runtime.py
|
||||
"""
|
||||
scheme = 'http' if 'sandbox' in self.system.hostname or self.system.debug else 'https'
|
||||
uri = '{scheme}://{host}{path}'.format(
|
||||
scheme=scheme,
|
||||
host=self.system.hostname,
|
||||
path=self.runtime.handler_url(self, 'grade_handler', thirdparty=True).rstrip('/?')
|
||||
)
|
||||
return uri
|
||||
return self.runtime.handler_url(self, service_name, thirdparty=True).rstrip('/?')
|
||||
|
||||
def get_resource_link_id(self):
|
||||
"""
|
||||
@@ -453,9 +559,8 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
def max_score(self):
|
||||
return self.weight if self.has_score else None
|
||||
|
||||
|
||||
@XBlock.handler
|
||||
def grade_handler(self, request, dispatch):
|
||||
def grade_handler(self, request, suffix): # pylint: disable=unused-argument
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
|
||||
@@ -554,15 +659,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
return Response(response_xml_template.format(**failure_values), content_type="application/xml")
|
||||
|
||||
if action == 'replaceResultRequest':
|
||||
self.system.publish(
|
||||
self,
|
||||
'grade',
|
||||
{
|
||||
'value': score * self.max_score(),
|
||||
'max_value': self.max_score(),
|
||||
'user_id': real_user.id,
|
||||
}
|
||||
)
|
||||
self.set_user_module_score(real_user, score, self.max_score())
|
||||
|
||||
values = {
|
||||
'imsx_codeMajor': 'success',
|
||||
@@ -607,7 +704,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
|
||||
return imsx_messageIdentifier, sourcedId, score, action
|
||||
|
||||
def verify_oauth_body_sign(self, request):
|
||||
def verify_oauth_body_sign(self, request, content_type='application/x-www-form-urlencoded'):
|
||||
"""
|
||||
Verify grade request from LTI provider using OAuth body signing.
|
||||
|
||||
@@ -625,26 +722,26 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'}
|
||||
|
||||
client_key, client_secret = self.get_client_key_secret()
|
||||
headers = {
|
||||
'Authorization':unicode(request.headers.get('Authorization')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': unicode(request.headers.get('Authorization')),
|
||||
'Content-Type': content_type,
|
||||
}
|
||||
|
||||
sha1 = hashlib.sha1()
|
||||
sha1.update(request.body)
|
||||
oauth_body_hash = base64.b64encode(sha1.digest())
|
||||
|
||||
oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False)
|
||||
oauth_headers =dict(oauth_params)
|
||||
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'):
|
||||
raise LTIError("OAuth body hash verification is failed.")
|
||||
|
||||
if not signature.verify_hmac_sha1(mock_request, client_secret):
|
||||
raise LTIError("OAuth signature verification is failed.")
|
||||
|
||||
@@ -674,3 +771,6 @@ class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescri
|
||||
module_class = LTIModule
|
||||
grade_handler = module_attr('grade_handler')
|
||||
preview_handler = module_attr('preview_handler')
|
||||
lti_2_0_result_rest_handler = module_attr('lti_2_0_result_rest_handler')
|
||||
clear_user_module_score = module_attr('clear_user_module_score')
|
||||
get_outcome_service_url = module_attr('get_outcome_service_url')
|
||||
|
||||
372
common/lib/xmodule/xmodule/tests/test_lti20_unit.py
Normal file
372
common/lib/xmodule/xmodule/tests/test_lti20_unit.py
Normal file
@@ -0,0 +1,372 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for LTI Xmodule LTIv2.0 functional logic."""
|
||||
import textwrap
|
||||
|
||||
from mock import Mock
|
||||
from xmodule.lti_module import LTIDescriptor
|
||||
from xmodule.lti_2_util import LTIError
|
||||
|
||||
from . import LogicTest
|
||||
|
||||
|
||||
class LTI20RESTResultServiceTest(LogicTest):
|
||||
"""Logic tests for LTI module. LTI2.0 REST ResultService"""
|
||||
descriptor_class = LTIDescriptor
|
||||
|
||||
def setUp(self):
|
||||
super(LTI20RESTResultServiceTest, self).setUp()
|
||||
self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'}
|
||||
self.system.get_real_user = Mock()
|
||||
self.system.publish = Mock()
|
||||
self.system.rebind_noauth_module_to_user = Mock()
|
||||
self.user_id = self.xmodule.runtime.anonymous_student_id
|
||||
self.lti_id = self.xmodule.lti_id
|
||||
|
||||
def test_sanitize_get_context(self):
|
||||
"""Tests that the get_context function does basic sanitization"""
|
||||
# get_context, unfortunately, requires a lot of mocking machinery
|
||||
mocked_course = Mock(lti_passports=['lti_id:test_client:test_secret'])
|
||||
modulestore = Mock()
|
||||
modulestore.get_item.return_value = mocked_course
|
||||
runtime = Mock(modulestore=modulestore)
|
||||
self.xmodule.descriptor.runtime = runtime
|
||||
self.xmodule.lti_id = "lti_id"
|
||||
self.xmodule.scope_ids.usage_id = "mocked"
|
||||
|
||||
test_cases = ( # (before sanitize, after sanitize)
|
||||
(u"plaintext", u"plaintext"),
|
||||
(u"a <script>alert(3)</script>", u"a <script>alert(3)</script>"), # encodes scripts
|
||||
(u"<b>bold 包</b>", u"<b>bold 包</b>"), # unicode, and <b> tags pass through
|
||||
)
|
||||
for case in test_cases:
|
||||
self.xmodule.score_comment = case[0]
|
||||
self.assertEqual(
|
||||
case[1],
|
||||
self.xmodule.get_context()['comment']
|
||||
)
|
||||
|
||||
def test_lti20_rest_bad_contenttype(self):
|
||||
"""
|
||||
Input with bad content type
|
||||
"""
|
||||
with self.assertRaisesRegexp(LTIError, "Content-Type must be"):
|
||||
request = Mock(headers={u'Content-Type': u'Non-existent'})
|
||||
self.xmodule.verify_lti_2_0_result_rest_headers(request)
|
||||
|
||||
def test_lti20_rest_failed_oauth_body_verify(self):
|
||||
"""
|
||||
Input with bad oauth body hash verification
|
||||
"""
|
||||
err_msg = "OAuth body verification failed"
|
||||
self.xmodule.verify_oauth_body_sign = Mock(side_effect=LTIError(err_msg))
|
||||
with self.assertRaisesRegexp(LTIError, err_msg):
|
||||
request = Mock(headers={u'Content-Type': u'application/vnd.ims.lis.v2.result+json'})
|
||||
self.xmodule.verify_lti_2_0_result_rest_headers(request)
|
||||
|
||||
def test_lti20_rest_good_headers(self):
|
||||
"""
|
||||
Input with good oauth body hash verification
|
||||
"""
|
||||
self.xmodule.verify_oauth_body_sign = Mock(return_value=True)
|
||||
|
||||
request = Mock(headers={u'Content-Type': u'application/vnd.ims.lis.v2.result+json'})
|
||||
self.xmodule.verify_lti_2_0_result_rest_headers(request)
|
||||
# We just want the above call to complete without exceptions, and to have called verify_oauth_body_sign
|
||||
self.assertTrue(self.xmodule.verify_oauth_body_sign.called)
|
||||
|
||||
BAD_DISPATCH_INPUTS = [
|
||||
None,
|
||||
u"",
|
||||
u"abcd"
|
||||
u"notuser/abcd"
|
||||
u"user/"
|
||||
u"user//"
|
||||
u"user/gbere/"
|
||||
u"user/gbere/xsdf"
|
||||
u"user/ಠ益ಠ" # not alphanumeric
|
||||
]
|
||||
|
||||
def test_lti20_rest_bad_dispatch(self):
|
||||
"""
|
||||
Test the error cases for the "dispatch" argument to the LTI 2.0 handler. Anything that doesn't
|
||||
fit the form user/<anon_id>
|
||||
"""
|
||||
for einput in self.BAD_DISPATCH_INPUTS:
|
||||
with self.assertRaisesRegexp(LTIError, "No valid user id found in endpoint URL"):
|
||||
self.xmodule.parse_lti_2_0_handler_suffix(einput)
|
||||
|
||||
GOOD_DISPATCH_INPUTS = [
|
||||
(u"user/abcd3", u"abcd3"),
|
||||
(u"user/Äbcdè2", u"Äbcdè2"), # unicode, just to make sure
|
||||
]
|
||||
|
||||
def test_lti20_rest_good_dispatch(self):
|
||||
"""
|
||||
Test the good cases for the "dispatch" argument to the LTI 2.0 handler. Anything that does
|
||||
fit the form user/<anon_id>
|
||||
"""
|
||||
for ginput, expected in self.GOOD_DISPATCH_INPUTS:
|
||||
self.assertEquals(self.xmodule.parse_lti_2_0_handler_suffix(ginput), expected)
|
||||
|
||||
BAD_JSON_INPUTS = [
|
||||
# (bad inputs, error message expected)
|
||||
([
|
||||
u"kk", # ValueError
|
||||
u"{{}", # ValueError
|
||||
u"{}}", # ValueError
|
||||
3, # TypeError
|
||||
{}, # TypeError
|
||||
], u"Supplied JSON string in request body could not be decoded"),
|
||||
([
|
||||
u"3", # valid json, not array or object
|
||||
u"[]", # valid json, array too small
|
||||
u"[3, {}]", # valid json, 1st element not an object
|
||||
], u"Supplied JSON string is a list that does not contain an object as the first element"),
|
||||
([
|
||||
u'{"@type": "NOTResult"}', # @type key must have value 'Result'
|
||||
], u"JSON object does not contain correct @type attribute"),
|
||||
([
|
||||
# @context missing
|
||||
u'{"@type": "Result", "resultScore": 0.1}',
|
||||
], u"JSON object does not contain required key"),
|
||||
([
|
||||
u'''
|
||||
{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"resultScore": 100}''' # score out of range
|
||||
], u"score value outside the permitted range of 0-1."),
|
||||
([
|
||||
u'''
|
||||
{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"resultScore": "1b"}''', # score ValueError
|
||||
u'''
|
||||
{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"resultScore": {}}''', # score TypeError
|
||||
], u"Could not convert resultScore to float"),
|
||||
]
|
||||
|
||||
def test_lti20_bad_json(self):
|
||||
"""
|
||||
Test that bad json_str to parse_lti_2_0_result_json inputs raise appropriate LTI Error
|
||||
"""
|
||||
for error_inputs, error_message in self.BAD_JSON_INPUTS:
|
||||
for einput in error_inputs:
|
||||
with self.assertRaisesRegexp(LTIError, error_message):
|
||||
self.xmodule.parse_lti_2_0_result_json(einput)
|
||||
|
||||
GOOD_JSON_INPUTS = [
|
||||
(u'''
|
||||
{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"resultScore": 0.1}''', u""), # no comment means we expect ""
|
||||
(u'''
|
||||
[{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@id": "anon_id:abcdef0123456789",
|
||||
"resultScore": 0.1}]''', u""), # OK to have array of objects -- just take the first. @id is okay too
|
||||
(u'''
|
||||
{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"resultScore": 0.1,
|
||||
"comment": "ಠ益ಠ"}''', u"ಠ益ಠ"), # unicode comment
|
||||
]
|
||||
|
||||
def test_lti20_good_json(self):
|
||||
"""
|
||||
Test the parsing of good comments
|
||||
"""
|
||||
for json_str, expected_comment in self.GOOD_JSON_INPUTS:
|
||||
score, comment = self.xmodule.parse_lti_2_0_result_json(json_str)
|
||||
self.assertEqual(score, 0.1)
|
||||
self.assertEqual(comment, expected_comment)
|
||||
|
||||
GOOD_JSON_PUT = textwrap.dedent(u"""
|
||||
{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@id": "anon_id:abcdef0123456789",
|
||||
"resultScore": 0.1,
|
||||
"comment": "ಠ益ಠ"}
|
||||
""").encode('utf-8')
|
||||
|
||||
GOOD_JSON_PUT_LIKE_DELETE = textwrap.dedent(u"""
|
||||
{"@type": "Result",
|
||||
"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@id": "anon_id:abcdef0123456789",
|
||||
"comment": "ಠ益ಠ"}
|
||||
""").encode('utf-8')
|
||||
|
||||
def get_signed_lti20_mock_request(self, body, method=u'PUT'):
|
||||
"""
|
||||
Example of signed from LTI 2.0 Provider. Signatures and hashes are example only and won't verify
|
||||
"""
|
||||
mock_request = Mock()
|
||||
mock_request.headers = {
|
||||
'Content-Type': 'application/vnd.ims.lis.v2.result+json',
|
||||
'Authorization': (
|
||||
u'OAuth oauth_nonce="135685044251684026041377608307", '
|
||||
u'oauth_timestamp="1234567890", oauth_version="1.0", '
|
||||
u'oauth_signature_method="HMAC-SHA1", '
|
||||
u'oauth_consumer_key="test_client_key", '
|
||||
u'oauth_signature="my_signature%3D", '
|
||||
u'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="'
|
||||
)
|
||||
}
|
||||
mock_request.url = u'http://testurl'
|
||||
mock_request.http_method = method
|
||||
mock_request.method = method
|
||||
mock_request.body = body
|
||||
return mock_request
|
||||
|
||||
USER_STANDIN = Mock()
|
||||
USER_STANDIN.id = 999
|
||||
|
||||
def setup_system_xmodule_mocks_for_lti20_request_test(self):
|
||||
"""
|
||||
Helper fn to set up mocking for lti 2.0 request test
|
||||
"""
|
||||
self.system.get_real_user = Mock(return_value=self.USER_STANDIN)
|
||||
self.xmodule.max_score = Mock(return_value=1.0)
|
||||
self.xmodule.get_client_key_secret = Mock(return_value=('test_client_key', u'test_client_secret'))
|
||||
self.xmodule.verify_oauth_body_sign = Mock()
|
||||
|
||||
def test_lti20_put_like_delete_success(self):
|
||||
"""
|
||||
The happy path for LTI 2.0 PUT that acts like a delete
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
SCORE = 0.55 # pylint: disable=invalid-name
|
||||
COMMENT = u"ಠ益ಠ" # pylint: disable=invalid-name
|
||||
self.xmodule.module_score = SCORE
|
||||
self.xmodule.score_comment = COMMENT
|
||||
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT_LIKE_DELETE)
|
||||
# Now call the handler
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
# Now assert there's no score
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(self.xmodule.module_score)
|
||||
self.assertEqual(self.xmodule.score_comment, u"")
|
||||
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
|
||||
self.assertEqual(called_grade_obj, {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None})
|
||||
self.assertEqual(evt_type, 'grade')
|
||||
|
||||
def test_lti20_delete_success(self):
|
||||
"""
|
||||
The happy path for LTI 2.0 DELETE
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
SCORE = 0.55 # pylint: disable=invalid-name
|
||||
COMMENT = u"ಠ益ಠ" # pylint: disable=invalid-name
|
||||
self.xmodule.module_score = SCORE
|
||||
self.xmodule.score_comment = COMMENT
|
||||
mock_request = self.get_signed_lti20_mock_request("", method=u'DELETE')
|
||||
# Now call the handler
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
# Now assert there's no score
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIsNone(self.xmodule.module_score)
|
||||
self.assertEqual(self.xmodule.score_comment, u"")
|
||||
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
|
||||
self.assertEqual(called_grade_obj, {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None})
|
||||
self.assertEqual(evt_type, 'grade')
|
||||
|
||||
def test_lti20_put_set_score_success(self):
|
||||
"""
|
||||
The happy path for LTI 2.0 PUT that sets a score
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
|
||||
# Now call the handler
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
# Now assert
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.xmodule.module_score, 0.1)
|
||||
self.assertEqual(self.xmodule.score_comment, u"ಠ益ಠ")
|
||||
(_, evt_type, called_grade_obj), _ = self.system.publish.call_args
|
||||
self.assertEqual(evt_type, 'grade')
|
||||
self.assertEqual(called_grade_obj, {'user_id': self.USER_STANDIN.id, 'value': 0.1, 'max_value': 1.0})
|
||||
|
||||
def test_lti20_get_no_score_success(self):
|
||||
"""
|
||||
The happy path for LTI 2.0 GET when there's no score
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
mock_request = self.get_signed_lti20_mock_request("", method=u'GET')
|
||||
# Now call the handler
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
# Now assert
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json, {"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@type": "Result"})
|
||||
|
||||
def test_lti20_get_with_score_success(self):
|
||||
"""
|
||||
The happy path for LTI 2.0 GET when there is a score
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
SCORE = 0.55 # pylint: disable=invalid-name
|
||||
COMMENT = u"ಠ益ಠ" # pylint: disable=invalid-name
|
||||
self.xmodule.module_score = SCORE
|
||||
self.xmodule.score_comment = COMMENT
|
||||
mock_request = self.get_signed_lti20_mock_request("", method=u'GET')
|
||||
# Now call the handler
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
# Now assert
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json, {"@context": "http://purl.imsglobal.org/ctx/lis/v2/Result",
|
||||
"@type": "Result",
|
||||
"resultScore": SCORE,
|
||||
"comment": COMMENT})
|
||||
|
||||
UNSUPPORTED_HTTP_METHODS = ["OPTIONS", "HEAD", "POST", "TRACE", "CONNECT"]
|
||||
|
||||
def test_lti20_unsupported_method_error(self):
|
||||
"""
|
||||
Test we get a 404 when we don't GET or PUT
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
|
||||
for bad_method in self.UNSUPPORTED_HTTP_METHODS:
|
||||
mock_request.method = bad_method
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_lti20_request_handler_bad_headers(self):
|
||||
"""
|
||||
Test that we get a 401 when header verification fails
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
self.xmodule.verify_lti_2_0_result_rest_headers = Mock(side_effect=LTIError())
|
||||
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_lti20_request_handler_bad_dispatch_user(self):
|
||||
"""
|
||||
Test that we get a 404 when there's no (or badly formatted) user specified in the url
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, None)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_lti20_request_handler_bad_json(self):
|
||||
"""
|
||||
Test that we get a 404 when json verification fails
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
self.xmodule.parse_lti_2_0_result_json = Mock(side_effect=LTIError())
|
||||
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_lti20_request_handler_bad_user(self):
|
||||
"""
|
||||
Test that we get a 404 when the supplied user does not exist
|
||||
"""
|
||||
self.setup_system_xmodule_mocks_for_lti20_request_test()
|
||||
self.system.get_real_user = Mock(return_value=None)
|
||||
mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT)
|
||||
response = self.xmodule.lti_2_0_result_rest_handler(mock_request, "user/abcd")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -2,21 +2,15 @@
|
||||
"""Test for LTI Xmodule functional logic."""
|
||||
|
||||
from mock import Mock, patch, PropertyMock
|
||||
import mock
|
||||
import textwrap
|
||||
import json
|
||||
from lxml import etree
|
||||
import json
|
||||
from webob.request import Request
|
||||
from copy import copy
|
||||
from collections import OrderedDict
|
||||
import urllib
|
||||
import oauthlib
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
|
||||
from xmodule.lti_module import LTIDescriptor, LTIError
|
||||
from xmodule.lti_module import LTIDescriptor
|
||||
from xmodule.lti_2_util import LTIError
|
||||
|
||||
from . import LogicTest
|
||||
|
||||
@@ -56,6 +50,7 @@ class LTIModuleTest(LogicTest):
|
||||
""")
|
||||
self.system.get_real_user = Mock()
|
||||
self.system.publish = Mock()
|
||||
self.system.rebind_noauth_module_to_user = Mock()
|
||||
|
||||
self.user_id = self.xmodule.runtime.anonymous_student_id
|
||||
self.lti_id = self.xmodule.lti_id
|
||||
@@ -239,6 +234,7 @@ class LTIModuleTest(LogicTest):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertDictEqual(expected_response, real_response)
|
||||
self.assertEqual(self.xmodule.module_score, float(self.DEFAULTS['grade']))
|
||||
|
||||
def test_user_id(self):
|
||||
expected_user_id = unicode(urllib.quote(self.xmodule.runtime.anonymous_student_id))
|
||||
@@ -246,13 +242,16 @@ class LTIModuleTest(LogicTest):
|
||||
self.assertEqual(real_user_id, expected_user_id)
|
||||
|
||||
def test_outcome_service_url(self):
|
||||
expected_outcome_service_url = '{scheme}://{host}{path}'.format(
|
||||
scheme='http' if self.xmodule.runtime.debug else 'https',
|
||||
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)
|
||||
mock_url_prefix = 'https://hostname/'
|
||||
test_service_name = "test_service"
|
||||
|
||||
def mock_handler_url(block, handler_name, **kwargs): # pylint: disable=unused-argument
|
||||
"""Mock function for returning fully-qualified handler urls"""
|
||||
return mock_url_prefix + handler_name
|
||||
|
||||
self.xmodule.runtime.handler_url = Mock(side_effect=mock_handler_url)
|
||||
real_outcome_service_url = self.xmodule.get_outcome_service_url(service_name=test_service_name)
|
||||
self.assertEqual(real_outcome_service_url, mock_url_prefix + test_service_name)
|
||||
|
||||
def test_resource_link_id(self):
|
||||
with patch('xmodule.lti_module.LTIModule.location', new_callable=PropertyMock) as mock_location:
|
||||
@@ -398,13 +397,11 @@ class LTIModuleTest(LogicTest):
|
||||
def test_max_score(self):
|
||||
self.xmodule.weight = 100.0
|
||||
|
||||
self.xmodule.graded = True
|
||||
self.assertFalse(self.xmodule.has_score)
|
||||
self.assertEqual(self.xmodule.max_score(), None)
|
||||
|
||||
self.xmodule.has_score = True
|
||||
self.assertEqual(self.xmodule.max_score(), 100.0)
|
||||
|
||||
self.xmodule.graded = False
|
||||
self.assertEqual(self.xmodule.max_score(), 100.0)
|
||||
|
||||
def test_context_id(self):
|
||||
|
||||
@@ -1134,7 +1134,7 @@ class XMLParsingSystem(DescriptorSystem):
|
||||
self.process_xml = process_xml
|
||||
|
||||
|
||||
class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
|
||||
class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # pylint: disable=abstract-method
|
||||
"""
|
||||
This is an abstraction such that x_modules can function independent
|
||||
of the courseware (e.g. import into other types of courseware, LMS,
|
||||
@@ -1154,7 +1154,7 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
|
||||
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, get_real_user=None,
|
||||
field_data=None, get_user_role=None,
|
||||
field_data=None, get_user_role=None, rebind_noauth_module_to_user=None,
|
||||
**kwargs):
|
||||
"""
|
||||
Create a closure around the system environment.
|
||||
@@ -1213,6 +1213,9 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
|
||||
for LMS and Studio.
|
||||
|
||||
field_data - the `FieldData` to use for backing XBlock storage.
|
||||
|
||||
rebind_noauth_module_to_user - rebinds module bound to AnonymousUser to a real user...used in LTI
|
||||
modules, which have an anonymous handler, to set legitimate users' data
|
||||
"""
|
||||
|
||||
# Usage_store is unused, and field_data is often supplanted with an
|
||||
@@ -1251,6 +1254,7 @@ class ModuleSystem(MetricsMixin,ConfigurableFragmentWrapper, Runtime): # pylint
|
||||
|
||||
self.get_user_role = get_user_role
|
||||
self.descriptor_runtime = descriptor_runtime
|
||||
self.rebind_noauth_module_to_user = rebind_noauth_module_to_user
|
||||
|
||||
def get(self, attr):
|
||||
""" provide uniform access to attributes (like etree)."""
|
||||
|
||||
@@ -46,7 +46,7 @@ Feature: LMS.LTI component
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | is_graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI question
|
||||
And I submit answer to LTI 1 question
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 5/10"
|
||||
And I see graph with total progress "5%"
|
||||
@@ -72,7 +72,65 @@ Feature: LMS.LTI component
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | is_graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI question
|
||||
And I submit answer to LTI 1 question
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 5/10"
|
||||
And I see graph with total progress "5%"
|
||||
|
||||
#9
|
||||
Scenario: Graded LTI component in LMS is correctly works with LTI2.0 PUT callback
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | is_graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI 2 question
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 8/10"
|
||||
And I see graph with total progress "8%"
|
||||
Then I click on the "Instructor" tab
|
||||
And I click on the "Gradebook" tab
|
||||
And I see in the gradebook table that "HW" is "80"
|
||||
And I see in the gradebook table that "Total" is "8"
|
||||
And I visit the LTI component
|
||||
Then I see LTI component progress with text "(8.0 / 10.0 points)"
|
||||
Then I see LTI component feedback with text "This is awesome."
|
||||
|
||||
#10
|
||||
Scenario: Graded LTI component in LMS is correctly works with LTI2.0 PUT delete callback
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | is_graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI 2 question
|
||||
And I visit the LTI component
|
||||
Then I see LTI component progress with text "(8.0 / 10.0 points)"
|
||||
Then I see LTI component feedback with text "This is awesome."
|
||||
And the LTI provider deletes my grade and feedback
|
||||
And I visit the LTI component (have to reload)
|
||||
Then I see LTI component progress with text "(10.0 points possible)"
|
||||
Then in the LTI component I do not see feedback
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 0/10"
|
||||
And I see graph with total progress "0%"
|
||||
Then I click on the "Instructor" tab
|
||||
And I click on the "Gradebook" tab
|
||||
And I see in the gradebook table that "HW" is "0"
|
||||
And I see in the gradebook table that "Total" is "0"
|
||||
|
||||
#11
|
||||
Scenario: LTI component that set to hide_launch and open_in_a_new_page shows no button
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | hide_launch |
|
||||
| False | True |
|
||||
Then in the LTI component I do not see a launch button
|
||||
Then I see LTI component module title with text "LTI (EXTERNAL RESOURCE)"
|
||||
|
||||
#12
|
||||
Scenario: LTI component that set to hide_launch and not open_in_a_new_page shows no iframe
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | hide_launch |
|
||||
| True | True |
|
||||
Then in the LTI component I do not see an provider iframe
|
||||
Then I see LTI component module title with text "LTI (EXTERNAL RESOURCE)"
|
||||
|
||||
@@ -2,24 +2,17 @@
|
||||
import datetime
|
||||
import os
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from mock import patch
|
||||
from pytz import UTC
|
||||
from nose.tools import assert_equal
|
||||
from splinter.exceptions import ElementDoesNotExist
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from nose.tools import assert_true, assert_equal, assert_in
|
||||
from lettuce import world, step
|
||||
from lettuce.django import django_url
|
||||
|
||||
from common import course_id, visit_scenario_item
|
||||
from courseware.tests.factories import InstructorFactory, BetaTesterFactory
|
||||
|
||||
from courseware.access import has_access
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
from nose.tools import assert_equals
|
||||
from common import course_id, visit_scenario_item
|
||||
|
||||
|
||||
@@ -248,6 +241,22 @@ def check_lti_popup():
|
||||
world.browser.switch_to_window(parent_window) # Switch to the main window again
|
||||
|
||||
|
||||
@step('visit the LTI component')
|
||||
def visit_lti_component(_step):
|
||||
visit_scenario_item('LTI')
|
||||
|
||||
|
||||
@step('I see LTI component (.*) with text "([^"]*)"$')
|
||||
def see_elem_text(_step, elem, text):
|
||||
selector_map = {
|
||||
'progress': '.problem-progress',
|
||||
'feedback': '.problem-feedback',
|
||||
'module title': '.problem-header'
|
||||
}
|
||||
assert_in(elem, selector_map)
|
||||
assert_true(world.css_has_text(selector_map[elem], text))
|
||||
|
||||
|
||||
@step('I see text "([^"]*)"$')
|
||||
def check_progress(_step, text):
|
||||
assert world.browser.is_text_present(text)
|
||||
@@ -255,37 +264,53 @@ def check_progress(_step, text):
|
||||
|
||||
@step('I see graph with total progress "([^"]*)"$')
|
||||
def see_graph(_step, progress):
|
||||
SELECTOR = 'grade-detail-graph'
|
||||
XPATH = '//div[@id="{parent}"]//div[text()="{progress}"]'.format(
|
||||
parent=SELECTOR,
|
||||
selector = 'grade-detail-graph'
|
||||
xpath = '//div[@id="{parent}"]//div[text()="{progress}"]'.format(
|
||||
parent=selector,
|
||||
progress=progress,
|
||||
)
|
||||
node = world.browser.find_by_xpath(XPATH)
|
||||
node = world.browser.find_by_xpath(xpath)
|
||||
|
||||
assert node
|
||||
|
||||
|
||||
@step('I see in the gradebook table that "([^"]*)" is "([^"]*)"$')
|
||||
def see_value_in_the_gradebook(_step, label, text):
|
||||
TABLE_SELECTOR = '.grade-table'
|
||||
table_selector = '.grade-table'
|
||||
index = 0
|
||||
table_headers = world.css_find('{0} thead th'.format(TABLE_SELECTOR))
|
||||
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)
|
||||
assert_true(world.css_has_text('{0} tbody td'.format(table_selector), text, index=index))
|
||||
|
||||
|
||||
@step('I submit answer to LTI question$')
|
||||
def click_grade(_step):
|
||||
@step('I submit answer to LTI (.*) question$')
|
||||
def click_grade(_step, version):
|
||||
version_map = {
|
||||
'1': {'selector': 'submit-button', 'expected_text': 'LTI consumer (edX) responded with XML content'},
|
||||
'2': {'selector': 'submit-lti2-button', 'expected_text': 'LTI consumer (edX) responded with HTTP 200'},
|
||||
}
|
||||
assert_in(version, version_map)
|
||||
location = world.scenario_dict['LTI'].location.html_id()
|
||||
iframe_name = 'ltiFrame-' + 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')
|
||||
iframe.find_by_name(version_map[version]['selector']).first.click()
|
||||
assert iframe.is_text_present(version_map[version]['expected_text'])
|
||||
|
||||
|
||||
@step('LTI provider deletes my grade and feedback$')
|
||||
def click_delete_button(_step):
|
||||
with world.browser.get_iframe(get_lti_frame_name()) as iframe:
|
||||
iframe.find_by_name('submit-lti2-delete-button').first.click()
|
||||
|
||||
|
||||
def get_lti_frame_name():
|
||||
location = world.scenario_dict['LTI'].location.html_id()
|
||||
return 'ltiFrame-' + location
|
||||
|
||||
|
||||
@step('I see in iframe that LTI role is (.*)$')
|
||||
@@ -310,3 +335,14 @@ def switch_view(_step, view):
|
||||
world.css_click('#staffstatus')
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step("in the LTI component I do not see (.*)$")
|
||||
def check_lti_component_no_elem(_step, text):
|
||||
selector_map = {
|
||||
'a launch button': '.link_lti_new_window',
|
||||
'an provider iframe': '.ltiLaunchFrame',
|
||||
'feedback': '.problem-feedback',
|
||||
'progress': '.problem-progress',
|
||||
}
|
||||
assert_in(text, selector_map)
|
||||
assert_true(world.is_css_not_present(selector_map[text]))
|
||||
|
||||
@@ -289,7 +289,6 @@ class DjangoKeyValueStore(KeyValueStore):
|
||||
Scope.user_info,
|
||||
)
|
||||
|
||||
|
||||
def __init__(self, field_data_cache):
|
||||
self._field_data_cache = field_data_cache
|
||||
|
||||
|
||||
@@ -60,6 +60,13 @@ xqueue_interface = XQueueInterface(
|
||||
)
|
||||
|
||||
|
||||
class LmsModuleRenderError(Exception):
|
||||
"""
|
||||
An exception class for exceptions thrown by module_render that don't fit well elsewhere
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def make_track_function(request):
|
||||
'''
|
||||
Make a tracking function that logs what happened.
|
||||
@@ -210,25 +217,31 @@ def get_module_for_descriptor(user, request, descriptor, field_data_cache, cours
|
||||
static_asset_path)
|
||||
|
||||
|
||||
def get_module_for_descriptor_internal(user, descriptor, field_data_cache, course_id,
|
||||
track_function, xqueue_callback_url_prefix,
|
||||
position=None, wrap_xmodule_display=True, grade_bucket_type=None,
|
||||
static_asset_path=''):
|
||||
def get_module_system_for_user(user, field_data_cache,
|
||||
# Arguments preceding this comment have user binding, those following don't
|
||||
descriptor, course_id, track_function, xqueue_callback_url_prefix,
|
||||
position=None, wrap_xmodule_display=True, grade_bucket_type=None,
|
||||
static_asset_path=''):
|
||||
"""
|
||||
Actually implement get_module, without requiring a request.
|
||||
Helper function that returns a module system and student_data bound to a user and a descriptor.
|
||||
|
||||
See get_module() docstring for further details.
|
||||
The purpose of this function is to factor out everywhere a user is implicitly bound when creating a module,
|
||||
to allow an existing module to be re-bound to a user. Most of the user bindings happen when creating the
|
||||
closures that feed the instantiation of ModuleSystem.
|
||||
|
||||
The arguments fall into two categories: those that have explicit or implicit user binding, which are user
|
||||
and field_data_cache, and those don't and are just present so that ModuleSystem can be instantiated, which
|
||||
are all the other arguments. Ultimately, this isn't too different than how get_module_for_descriptor_internal
|
||||
was before refactoring.
|
||||
|
||||
Arguments:
|
||||
see arguments for get_module()
|
||||
|
||||
Returns:
|
||||
(LmsModuleSystem, KvsFieldData): (module system, student_data) bound to, primarily, the user and descriptor
|
||||
"""
|
||||
|
||||
# Do not check access when it's a noauth request.
|
||||
if getattr(user, 'known', True):
|
||||
# Short circuit--if the user shouldn't have access, bail without doing any work
|
||||
if not has_access(user, descriptor, 'load', course_id):
|
||||
return None
|
||||
|
||||
student_data = KvsFieldData(DjangoKeyValueStore(field_data_cache))
|
||||
|
||||
|
||||
def make_xqueue_callback(dispatch='score_update'):
|
||||
# Fully qualified callback URL for external queueing system
|
||||
relative_xqueue_callback_url = reverse(
|
||||
@@ -333,6 +346,49 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
else:
|
||||
track_function(event_type, event)
|
||||
|
||||
def rebind_noauth_module_to_user(module, real_user):
|
||||
"""
|
||||
A function that allows a module to get re-bound to a real user if it was previously bound to an AnonymousUser.
|
||||
|
||||
Will only work within a module bound to an AnonymousUser, e.g. one that's instantiated by the noauth_handler.
|
||||
|
||||
Arguments:
|
||||
module (any xblock type): the module to rebind
|
||||
real_user (django.contrib.auth.models.User): the user to bind to
|
||||
|
||||
Returns:
|
||||
nothing (but the side effect is that module is re-bound to real_user)
|
||||
"""
|
||||
if user.is_authenticated():
|
||||
err_msg = ("rebind_noauth_module_to_user can only be called from a module bound to "
|
||||
"an anonymous user")
|
||||
log.error(err_msg)
|
||||
raise LmsModuleRenderError(err_msg)
|
||||
|
||||
field_data_cache_real_user = FieldDataCache.cache_for_descriptor_descendents(
|
||||
course_id,
|
||||
real_user,
|
||||
module.descriptor
|
||||
)
|
||||
|
||||
(inner_system, inner_student_data) = get_module_system_for_user(
|
||||
real_user, field_data_cache_real_user, # These have implicit user bindings, rest of args considered not to
|
||||
module.descriptor, course_id, track_function, xqueue_callback_url_prefix, position, wrap_xmodule_display,
|
||||
grade_bucket_type, static_asset_path
|
||||
)
|
||||
# rebinds module to a different student. We'll change system, student_data, and scope_ids
|
||||
module.descriptor.bind_for_student(
|
||||
inner_system,
|
||||
LmsFieldData(module.descriptor._field_data, inner_student_data) # pylint: disable=protected-access
|
||||
)
|
||||
module.descriptor.scope_ids = (
|
||||
module.descriptor.scope_ids._replace(user_id=real_user.id) # pylint: disable=protected-access
|
||||
)
|
||||
module.scope_ids = module.descriptor.scope_ids # this is needed b/c NamedTuples are immutable
|
||||
# now bind the module to the new ModuleSystem instance and vice-versa
|
||||
module.runtime = inner_system
|
||||
inner_system.xmodule_instance = module
|
||||
|
||||
# Build a list of wrapping functions that will be applied in order
|
||||
# to the Fragment content coming out of the xblocks that are about to be rendered.
|
||||
block_wrappers = []
|
||||
@@ -433,6 +489,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
},
|
||||
get_user_role=lambda: get_user_role(user, course_id),
|
||||
descriptor_runtime=descriptor.runtime,
|
||||
rebind_noauth_module_to_user=rebind_noauth_module_to_user,
|
||||
)
|
||||
|
||||
# pass position specified in URL to module through ModuleSystem
|
||||
@@ -451,6 +508,31 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
else:
|
||||
system.error_descriptor_class = NonStaffErrorDescriptor
|
||||
|
||||
return system, student_data
|
||||
|
||||
|
||||
def get_module_for_descriptor_internal(user, descriptor, field_data_cache, course_id, # pylint: disable=invalid-name
|
||||
track_function, xqueue_callback_url_prefix,
|
||||
position=None, wrap_xmodule_display=True, grade_bucket_type=None,
|
||||
static_asset_path=''):
|
||||
"""
|
||||
Actually implement get_module, without requiring a request.
|
||||
|
||||
See get_module() docstring for further details.
|
||||
"""
|
||||
|
||||
# Do not check access when it's a noauth request.
|
||||
if getattr(user, 'known', True):
|
||||
# Short circuit--if the user shouldn't have access, bail without doing any work
|
||||
if not has_access(user, descriptor, 'load', course_id):
|
||||
return None
|
||||
|
||||
(system, student_data) = get_module_system_for_user(
|
||||
user, field_data_cache, # These have implicit user bindings, the rest of args are considered not to
|
||||
descriptor, course_id, track_function, xqueue_callback_url_prefix, position, wrap_xmodule_display,
|
||||
grade_bucket_type, static_asset_path
|
||||
)
|
||||
|
||||
descriptor.bind_for_student(system, LmsFieldData(descriptor._field_data, student_data)) # pylint: disable=protected-access
|
||||
descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id) # pylint: disable=protected-access
|
||||
return descriptor
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
"""LTI integration tests"""
|
||||
|
||||
import oauthlib
|
||||
from . import BaseTestXmodule
|
||||
from collections import OrderedDict
|
||||
import mock
|
||||
import urllib
|
||||
import json
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
from courseware.tests import BaseTestXmodule
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
|
||||
from courseware.views import get_course_lti_endpoints
|
||||
from lms.lib.xblock.runtime import quote_slashes
|
||||
|
||||
class TestLTI(BaseTestXmodule):
|
||||
"""
|
||||
@@ -71,7 +83,13 @@ class TestLTI(BaseTestXmodule):
|
||||
'element_id': self.item_descriptor.location.html_id(),
|
||||
'launch_url': 'http://www.example.com', # default value
|
||||
'open_in_a_new_page': True,
|
||||
'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_descriptor, 'preview_handler').rstrip('/?'),
|
||||
'form_url': self.item_descriptor.xmodule_runtime.handler_url(self.item_descriptor,
|
||||
'preview_handler').rstrip('/?'),
|
||||
'hide_launch': False,
|
||||
'has_score': False,
|
||||
'module_score': None,
|
||||
'comment': u'',
|
||||
'weight': 1.0,
|
||||
}
|
||||
|
||||
def mocked_sign(self, *args, **kwargs):
|
||||
@@ -95,10 +113,111 @@ class TestLTI(BaseTestXmodule):
|
||||
|
||||
def test_lti_constructor(self):
|
||||
generated_content = self.item_descriptor.render('student_view').content
|
||||
expected_content = self.runtime.render_template('lti.html', self.expected_context)
|
||||
expected_content = self.runtime.render_template('lti.html', self.expected_context)
|
||||
self.assertEqual(generated_content, expected_content)
|
||||
|
||||
def test_lti_preview_handler(self):
|
||||
generated_content = self.item_descriptor.preview_handler(None, None).body
|
||||
expected_content = self.runtime.render_template('lti_form.html', self.expected_context)
|
||||
self.assertEqual(generated_content, expected_content)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class TestLTIModuleListing(ModuleStoreTestCase):
|
||||
"""
|
||||
a test for the rest endpoint that lists LTI modules in a course
|
||||
"""
|
||||
# arbitrary constant
|
||||
COURSE_SLUG = "100"
|
||||
COURSE_NAME = "test_course"
|
||||
|
||||
def setUp(self):
|
||||
"""Create course, 2 chapters, 2 sections"""
|
||||
self.course = CourseFactory.create(display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
|
||||
self.chapter1 = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
display_name="chapter1",
|
||||
category='chapter')
|
||||
self.section1 = ItemFactory.create(
|
||||
parent_location=self.chapter1.location,
|
||||
display_name="section1",
|
||||
category='sequential')
|
||||
self.chapter2 = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
display_name="chapter2",
|
||||
category='chapter')
|
||||
self.section2 = ItemFactory.create(
|
||||
parent_location=self.chapter2.location,
|
||||
display_name="section2",
|
||||
category='sequential')
|
||||
|
||||
self.published_location_dict = {'tag': 'i4x',
|
||||
'org': self.course.location.org,
|
||||
'category': 'lti',
|
||||
'course': self.course.location.course,
|
||||
'name': 'lti_published'}
|
||||
self.draft_location_dict = {'tag': 'i4x',
|
||||
'org': self.course.location.org,
|
||||
'category': 'lti',
|
||||
'course': self.course.location.course,
|
||||
'name': 'lti_draft',
|
||||
'revision': 'draft'}
|
||||
# creates one draft and one published lti module, in different sections
|
||||
self.lti_published = ItemFactory.create(
|
||||
parent_location=self.section1.location,
|
||||
display_name="lti published",
|
||||
category="lti",
|
||||
location=Location(self.published_location_dict)
|
||||
)
|
||||
self.lti_draft = ItemFactory.create(
|
||||
parent_location=self.section2.location,
|
||||
display_name="lti draft",
|
||||
category="lti",
|
||||
location=Location(self.draft_location_dict)
|
||||
)
|
||||
|
||||
def expected_handler_url(self, handler):
|
||||
"""convenience method to get the reversed handler urls"""
|
||||
return "https://{}{}".format(settings.SITE_NAME, reverse(
|
||||
'courseware.module_render.handle_xblock_callback_noauth',
|
||||
args=[
|
||||
self.course.id,
|
||||
quote_slashes(unicode(self.lti_published.scope_ids.usage_id).encode('utf-8')),
|
||||
handler
|
||||
]
|
||||
))
|
||||
|
||||
def test_lti_rest_bad_course(self):
|
||||
"""Tests what happens when the lti listing rest endpoint gets a bad course_id"""
|
||||
bad_ids = [u"sf", u"dne/dne/dne", u"fo/ey/\u5305"]
|
||||
request = mock.Mock()
|
||||
request.method = 'GET'
|
||||
for bad_course_id in bad_ids:
|
||||
response = get_course_lti_endpoints(request, bad_course_id)
|
||||
self.assertEqual(404, response.status_code)
|
||||
|
||||
def test_lti_rest_listing(self):
|
||||
"""tests that the draft lti module is not a part of the endpoint response, but the published one is"""
|
||||
request = mock.Mock()
|
||||
request.method = 'GET'
|
||||
response = get_course_lti_endpoints(request, self.course.id)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('application/json', response['Content-Type'])
|
||||
|
||||
expected = {
|
||||
"lti_1_1_result_service_xml_endpoint": self.expected_handler_url('grade_handler'),
|
||||
"lti_2_0_result_service_json_endpoint":
|
||||
self.expected_handler_url('lti_2_0_result_rest_handler') + "/user/{anon_user_id}",
|
||||
"display_name": self.lti_published.display_name
|
||||
}
|
||||
self.assertEqual([expected], json.loads(response.content))
|
||||
|
||||
def test_lti_rest_non_get(self):
|
||||
"""tests that the endpoint returns 404 when hit with NON-get"""
|
||||
DISALLOWED_METHODS = ("POST", "PUT", "DELETE", "HEAD", "OPTIONS") # pylint: disable=invalid-name
|
||||
for method in DISALLOWED_METHODS:
|
||||
request = mock.Mock()
|
||||
request.method = method
|
||||
response = get_course_lti_endpoints(request, self.course.id)
|
||||
self.assertEqual(405, response.status_code)
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from capa.tests.response_xml_factory import OptionResponseXMLFactory
|
||||
from xblock.field_data import FieldData
|
||||
@@ -27,13 +28,16 @@ from xmodule.x_module import XModuleDescriptor
|
||||
from courseware import module_render as render
|
||||
from courseware.courses import get_course_with_access, course_image_url, get_course_info_section
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.models import StudentModule
|
||||
from courseware.tests.factories import StudentModuleFactory, UserFactory, GlobalStaffFactory
|
||||
from courseware.tests.tests import LoginEnrollmentTestCase
|
||||
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE
|
||||
from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE
|
||||
from courseware.tests.test_submitting_problems import TestSubmittingProblems
|
||||
|
||||
from student.models import anonymous_id_for_user
|
||||
from lms.lib.xblock.runtime import quote_slashes
|
||||
|
||||
|
||||
@@ -96,7 +100,6 @@ class ModuleRenderTestCase(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
# note if the URL mapping changes then this assertion will break
|
||||
self.assertIn('/courses/' + self.course_id + '/jump_to_id/vertical_test', html)
|
||||
|
||||
|
||||
def test_xqueue_callback_success(self):
|
||||
"""
|
||||
Test for happy-path xqueue_callback
|
||||
@@ -874,3 +877,106 @@ class TestModuleTrackingContext(ModuleStoreTestCase):
|
||||
def test_missing_display_name(self, mock_tracker):
|
||||
actual_display_name = self.handle_callback_and_get_display_name_from_event(mock_tracker)
|
||||
self.assertTrue(actual_display_name.startswith('problem'))
|
||||
|
||||
|
||||
class TestXmoduleRuntimeEvent(TestSubmittingProblems):
|
||||
"""
|
||||
Inherit from TestSubmittingProblems to get functionality that set up a course and problems structure
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestXmoduleRuntimeEvent, self).setUp()
|
||||
self.homework = self.add_graded_section_to_course('homework')
|
||||
self.problem = self.add_dropdown_to_section(self.homework.location, 'p1', 1)
|
||||
self.grade_dict = {'value': 0.18, 'max_value': 32, 'user_id': self.student_user.id}
|
||||
self.delete_dict = {'value': None, 'max_value': None, 'user_id': self.student_user.id}
|
||||
|
||||
def get_module_for_user(self, user):
|
||||
"""Helper function to get useful module at self.location in self.course_id for user"""
|
||||
mock_request = MagicMock()
|
||||
mock_request.user = user
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
self.course.id, user, self.course, depth=2)
|
||||
|
||||
return render.get_module( # pylint: disable=protected-access
|
||||
user,
|
||||
mock_request,
|
||||
self.problem.id,
|
||||
field_data_cache,
|
||||
self.course.id)._xmodule
|
||||
|
||||
def set_module_grade_using_publish(self, grade_dict):
|
||||
"""Publish the user's grade, takes grade_dict as input"""
|
||||
module = self.get_module_for_user(self.student_user)
|
||||
module.system.publish(module, 'grade', grade_dict)
|
||||
return module
|
||||
|
||||
def test_xmodule_runtime_publish(self):
|
||||
"""Tests the publish mechanism"""
|
||||
self.set_module_grade_using_publish(self.grade_dict)
|
||||
student_module = StudentModule.objects.get(student=self.student_user, module_state_key=self.problem.id)
|
||||
self.assertEqual(student_module.grade, self.grade_dict['value'])
|
||||
self.assertEqual(student_module.max_grade, self.grade_dict['max_value'])
|
||||
|
||||
def test_xmodule_runtime_publish_delete(self):
|
||||
"""Test deleting the grade using the publish mechanism"""
|
||||
module = self.set_module_grade_using_publish(self.grade_dict)
|
||||
module.system.publish(module, 'grade', self.delete_dict)
|
||||
student_module = StudentModule.objects.get(student=self.student_user, module_state_key=self.problem.id)
|
||||
self.assertIsNone(student_module.grade)
|
||||
self.assertIsNone(student_module.max_grade)
|
||||
|
||||
|
||||
class TestRebindModule(TestSubmittingProblems):
|
||||
"""
|
||||
Tests to verify the functionality of rebinding a module.
|
||||
Inherit from TestSubmittingProblems to get functionality that set up a course structure
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestRebindModule, self).setUp()
|
||||
self.homework = self.add_graded_section_to_course('homework')
|
||||
self.lti = ItemFactory.create(category='lti', parent=self.homework)
|
||||
self.user = UserFactory.create()
|
||||
self.anon_user = AnonymousUser()
|
||||
|
||||
def get_module_for_user(self, user):
|
||||
"""Helper function to get useful module at self.location in self.course_id for user"""
|
||||
mock_request = MagicMock()
|
||||
mock_request.user = user
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
self.course.id, user, self.course, depth=2)
|
||||
|
||||
return render.get_module( # pylint: disable=protected-access
|
||||
user,
|
||||
mock_request,
|
||||
self.lti.id,
|
||||
field_data_cache,
|
||||
self.course.id)._xmodule
|
||||
|
||||
def test_rebind_noauth_module_to_user_not_anonymous(self):
|
||||
"""
|
||||
Tests that an exception is thrown when rebind_noauth_module_to_user is run from a
|
||||
module bound to a real user
|
||||
"""
|
||||
module = self.get_module_for_user(self.user)
|
||||
user2 = UserFactory()
|
||||
user2.id = 2
|
||||
with self.assertRaisesRegexp(
|
||||
render.LmsModuleRenderError,
|
||||
"rebind_noauth_module_to_user can only be called from a module bound to an anonymous user"
|
||||
):
|
||||
self.assertTrue(module.system.rebind_noauth_module_to_user(module, user2))
|
||||
|
||||
def test_rebind_noauth_module_to_user_anonymous(self):
|
||||
"""
|
||||
Tests that get_user_module_for_noauth succeeds when rebind_noauth_module_to_user is run from a
|
||||
module bound to AnonymousUser
|
||||
"""
|
||||
module = self.get_module_for_user(self.anon_user)
|
||||
user2 = UserFactory()
|
||||
user2.id = 2
|
||||
module.system.rebind_noauth_module_to_user(module, user2)
|
||||
self.assertTrue(module)
|
||||
self.assertEqual(module.system.anonymous_student_id, anonymous_id_for_user(user2, self.course.id))
|
||||
self.assertEqual(module.scope_ids.user_id, user2.id)
|
||||
self.assertEqual(module.descriptor.scope_ids.user_id, user2.id)
|
||||
|
||||
@@ -4,6 +4,7 @@ Courseware views functions
|
||||
|
||||
import logging
|
||||
import urllib
|
||||
import json
|
||||
|
||||
from collections import defaultdict
|
||||
from django.utils.translation import ugettext as _
|
||||
@@ -12,8 +13,9 @@ from django.conf import settings
|
||||
from django.core.context_processors import csrf
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.models import User, AnonymousUser
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from edxmako.shortcuts import render_to_response, render_to_string
|
||||
@@ -24,8 +26,7 @@ from markupsafe import escape
|
||||
|
||||
from courseware import grades
|
||||
from courseware.access import has_access
|
||||
from courseware.courses import get_courses, get_course_with_access, get_studio_url, sort_by_announcement
|
||||
|
||||
from courseware.courses import get_courses, get_course, get_studio_url, get_course_with_access, sort_by_announcement
|
||||
from courseware.masquerade import setup_masquerade
|
||||
from courseware.model_data import FieldDataCache
|
||||
from .module_render import toc_for_course, get_module_for_descriptor, get_module
|
||||
@@ -98,7 +99,6 @@ def render_accordion(request, course, chapter, section, field_data_cache):
|
||||
|
||||
Returns the html string
|
||||
"""
|
||||
|
||||
# grab the table of contents
|
||||
user = User.objects.prefetch_related("groups").get(id=request.user.id)
|
||||
request.user = user # keep just one instance of User
|
||||
@@ -828,3 +828,58 @@ def get_static_tab_contents(request, course, tab):
|
||||
)
|
||||
|
||||
return html
|
||||
|
||||
|
||||
@require_GET
|
||||
def get_course_lti_endpoints(request, course_id):
|
||||
"""
|
||||
View that, given a course_id, returns the a JSON object that enumerates all of the LTI endpoints for that course.
|
||||
|
||||
The LTI 2.0 result service spec at
|
||||
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
|
||||
says "This specification document does not prescribe a method for discovering the endpoint URLs." This view
|
||||
function implements one way of discovering these endpoints, returning a JSON array when accessed.
|
||||
|
||||
Arguments:
|
||||
request (django request object): the HTTP request object that triggered this view function
|
||||
course_id (unicode): id associated with the course
|
||||
|
||||
Returns:
|
||||
(django response object): HTTP response. 404 if course is not found, otherwise 200 with JSON body.
|
||||
"""
|
||||
try:
|
||||
course = get_course(course_id, depth=2)
|
||||
except ValueError: # get_course raises ValueError if course_id is invalid or doesn't refer to a course
|
||||
return HttpResponse(status=404)
|
||||
|
||||
anonymous_user = AnonymousUser()
|
||||
anonymous_user.known = False # make these "noauth" requests like module_render.handle_xblock_callback_noauth
|
||||
lti_descriptors = modulestore().get_items(Location("i4x", course.org, course.number, "lti", None), course.id)
|
||||
|
||||
lti_noauth_modules = [
|
||||
get_module_for_descriptor(
|
||||
anonymous_user,
|
||||
request,
|
||||
descriptor,
|
||||
FieldDataCache.cache_for_descriptor_descendents(
|
||||
course_id,
|
||||
anonymous_user,
|
||||
descriptor
|
||||
),
|
||||
course_id
|
||||
)
|
||||
for descriptor in lti_descriptors
|
||||
]
|
||||
|
||||
endpoints = [
|
||||
{
|
||||
'display_name': module.display_name,
|
||||
'lti_2_0_result_service_json_endpoint': module.get_outcome_service_url(
|
||||
service_name='lti_2_0_result_rest_handler') + "/user/{anon_user_id}",
|
||||
'lti_1_1_result_service_xml_endpoint': module.get_outcome_service_url(
|
||||
service_name='grade_handler'),
|
||||
}
|
||||
for module in lti_noauth_modules
|
||||
]
|
||||
|
||||
return HttpResponse(json.dumps(endpoints), content_type='application/json')
|
||||
|
||||
@@ -134,6 +134,7 @@ EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is loca
|
||||
EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25
|
||||
EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False
|
||||
SITE_NAME = ENV_TOKENS['SITE_NAME']
|
||||
HTTPS = ENV_TOKENS.get('HTTPS', HTTPS)
|
||||
SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE)
|
||||
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
REGISTRATION_EXTRA_FIELDS = ENV_TOKENS.get('REGISTRATION_EXTRA_FIELDS', REGISTRATION_EXTRA_FIELDS)
|
||||
|
||||
@@ -18,6 +18,7 @@ from logsettings import get_logger_config
|
||||
DEBUG = True
|
||||
TEMPLATE_DEBUG = True
|
||||
|
||||
HTTPS = 'off'
|
||||
FEATURES['DISABLE_START_DATES'] = False
|
||||
FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
|
||||
FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up
|
||||
|
||||
@@ -5,7 +5,7 @@ Module implementing `xblock.runtime.Runtime` functionality for the LMS
|
||||
import re
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from django.conf import settings
|
||||
from user_api import user_service
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.x_module import ModuleSystem
|
||||
@@ -100,6 +100,15 @@ class LmsHandlerUrls(object):
|
||||
if query:
|
||||
url += '?' + query
|
||||
|
||||
# If third-party, return fully-qualified url
|
||||
if thirdparty:
|
||||
scheme = "https" if settings.HTTPS == "on" else "http"
|
||||
url = '{scheme}://{host}{path}'.format(
|
||||
scheme=scheme,
|
||||
host=settings.SITE_NAME,
|
||||
path=url
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
def local_resource_url(self, block, uri):
|
||||
|
||||
@@ -3,6 +3,7 @@ Tests of the LMS XBlock Runtime and associated utilities
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from ddt import ddt, data
|
||||
from mock import Mock
|
||||
from unittest import TestCase
|
||||
@@ -87,6 +88,18 @@ class TestHandlerUrl(TestCase):
|
||||
self.assertIn('handler1', self._parsed_path('handler1'))
|
||||
self.assertIn('handler_a', self._parsed_path('handler_a'))
|
||||
|
||||
def test_thirdparty_fq(self):
|
||||
"""Testing the Fully-Qualified URL returned by thirdparty=True"""
|
||||
parsed_fq_url = urlparse(self.runtime.handler_url(self.block, 'handler', thirdparty=True))
|
||||
self.assertEqual(parsed_fq_url.scheme, 'https')
|
||||
self.assertEqual(parsed_fq_url.hostname, settings.SITE_NAME)
|
||||
|
||||
def test_not_thirdparty_rel(self):
|
||||
"""Testing the Fully-Qualified URL returned by thirdparty=False"""
|
||||
parsed_fq_url = urlparse(self.runtime.handler_url(self.block, 'handler', thirdparty=False))
|
||||
self.assertEqual(parsed_fq_url.scheme, '')
|
||||
self.assertIsNone(parsed_fq_url.hostname)
|
||||
|
||||
|
||||
class TestUserServiceAPI(TestCase):
|
||||
"""Test the user service interface"""
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
<%! import json %>
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<h2 class="problem-header">
|
||||
## Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS
|
||||
${display_name} (${_('External resource')})
|
||||
</h2>
|
||||
|
||||
% if has_score and weight:
|
||||
<div class="problem-progress">
|
||||
% if module_score is not None:
|
||||
## Translators: "points" is the student's achieved score on this LTI unit, and "total_points" is the maximum number of points achievable.
|
||||
(${_("{points} / {total_points} points").format(points=module_score, total_points=weight)})
|
||||
% else:
|
||||
## Translators: "total_points" is the maximum number of points achievable on this LTI unit
|
||||
(${_("{total_points} points possible").format(total_points=weight)})
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
|
||||
<div
|
||||
id="${element_id}"
|
||||
class="${element_class}"
|
||||
>
|
||||
|
||||
% if launch_url and launch_url != 'http://www.example.com':
|
||||
% if launch_url and launch_url != 'http://www.example.com' and not hide_launch:
|
||||
% if open_in_a_new_page:
|
||||
<div class="wrapper-lti-link">
|
||||
<h3 class="title">
|
||||
${display_name} (${_('External resource')})
|
||||
</h3>
|
||||
<p class="lti-link external"><a target="_blank" class="link_lti_new_window" href="${form_url}">
|
||||
${_('View resource in a new window')}
|
||||
<i class="icon-external-link"></i>
|
||||
@@ -25,9 +39,18 @@
|
||||
src="${form_url}"
|
||||
></iframe>
|
||||
% endif
|
||||
% else:
|
||||
% elif not hide_launch:
|
||||
<h3 class="error_message">
|
||||
${_('Please provide launch_url. Click "Edit", and fill in the required fields.')}
|
||||
</h3>
|
||||
%endif
|
||||
|
||||
% if has_score and comment:
|
||||
<h4 class="problem-feedback-label">${_("Feedback on your work from the grader:")}</h4>
|
||||
<div class="problem-feedback">
|
||||
## sanitized with bleach in view
|
||||
${comment}
|
||||
</div>
|
||||
% endif
|
||||
|
||||
</div>
|
||||
|
||||
@@ -326,6 +326,9 @@ if settings.COURSEWARE_ENABLED:
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/notes$', 'notes.views.notes', name='notes'),
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/notes/', include('notes.urls')),
|
||||
|
||||
# LTI endpoints listing
|
||||
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/lti_rest_endpoints/',
|
||||
'courseware.views.get_course_lti_endpoints', name='lti_rest_endpoints'),
|
||||
)
|
||||
|
||||
# allow course staff to change to student view of courseware
|
||||
|
||||
Reference in New Issue
Block a user