diff --git a/common/djangoapps/terrain/stubs/lti.py b/common/djangoapps/terrain/stubs/lti.py
index d2ca7998bc..87ed5ef214 100644
--- a/common/djangoapps/terrain/stubs/lti.py
+++ b/common/djangoapps/terrain/stubs/lti.py
@@ -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:
' + 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 {}
'.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 {}
'.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("""
-
- """).format(submit_url)
+
+
+ """).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):
"""
diff --git a/common/djangoapps/terrain/stubs/tests/test_lti_stub.py b/common/djangoapps/terrain/stubs/tests/test_lti_stub.py
index 40a5bf37b1..0ea2e2dcd6 100644
--- a/common/djangoapps/terrain/stubs/tests/test_lti_stub.py
+++ b/common/djangoapps/terrain/stubs/tests/test_lti_stub.py
@@ -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())
diff --git a/common/lib/xmodule/xmodule/css/lti/lti.scss b/common/lib/xmodule/xmodule/css/lti/lti.scss
index 00a9c6b969..3261912db0 100644
--- a/common/lib/xmodule/xmodule/css/lti/lti.scss
+++ b/common/lib/xmodule/xmodule/css/lti/lti.scss
@@ -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;
+
+ }
}
diff --git a/common/lib/xmodule/xmodule/lti_2_util.py b/common/lib/xmodule/xmodule/lti_2_util.py
new file mode 100644
index 0000000000..309c76e29e
--- /dev/null
+++ b/common/lib/xmodule/xmodule/lti_2_util.py
@@ -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\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/
+ so suffix is of the form "user/"
+ 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/"
+
+ 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/". 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', "")
diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py
index bd23579fe0..c6c7492a77 100644
--- a/common/lib/xmodule/xmodule/lti_module.py
+++ b/common/lib/xmodule/xmodule/lti_module.py
@@ -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 = (
+ ""
+ "the edX LTI documentation"
+)
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."
+ "
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."
+ "
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."
+ "
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')
diff --git a/common/lib/xmodule/xmodule/tests/test_lti20_unit.py b/common/lib/xmodule/xmodule/tests/test_lti20_unit.py
new file mode 100644
index 0000000000..93ca074d51
--- /dev/null
+++ b/common/lib/xmodule/xmodule/tests/test_lti20_unit.py
@@ -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 ", u"a <script>alert(3)</script>"), # encodes scripts
+ (u"bold 包", u"bold 包"), # unicode, and 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/
+ """
+ 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/
+ """
+ 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)
diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py
index 86cdabb3e7..c6a3d58054 100644
--- a/common/lib/xmodule/xmodule/tests/test_lti_unit.py
+++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py
@@ -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):
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 49d4789260..e05f4f89d6 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -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)."""
diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature
index 9804d865c9..eb888daede 100644
--- a/lms/djangoapps/courseware/features/lti.feature
+++ b/lms/djangoapps/courseware/features/lti.feature
@@ -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)"
diff --git a/lms/djangoapps/courseware/features/lti.py b/lms/djangoapps/courseware/features/lti.py
index e32118f421..836306f161 100644
--- a/lms/djangoapps/courseware/features/lti.py
+++ b/lms/djangoapps/courseware/features/lti.py
@@ -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]))
diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py
index b204c59551..bc34f7f431 100644
--- a/lms/djangoapps/courseware/model_data.py
+++ b/lms/djangoapps/courseware/model_data.py
@@ -289,7 +289,6 @@ class DjangoKeyValueStore(KeyValueStore):
Scope.user_info,
)
-
def __init__(self, field_data_cache):
self._field_data_cache = field_data_cache
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index 704247acaf..4baa15e988 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -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
diff --git a/lms/djangoapps/courseware/tests/test_lti_integration.py b/lms/djangoapps/courseware/tests/test_lti_integration.py
index 0686767668..d1de8f3a44 100644
--- a/lms/djangoapps/courseware/tests/test_lti_integration.py
+++ b/lms/djangoapps/courseware/tests/test_lti_integration.py
@@ -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)
diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py
index 55e26619fb..76f19a208c 100644
--- a/lms/djangoapps/courseware/tests/test_module_render.py
+++ b/lms/djangoapps/courseware/tests/test_module_render.py
@@ -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)
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index cb34b55de2..54d505446f 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -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')
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 4593269314..4e6c527fa4 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -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)
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index add0d48c12..4a2b9517e0 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -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
diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py
index 895e8f2c74..b8ec36d192 100644
--- a/lms/lib/xblock/runtime.py
+++ b/lms/lib/xblock/runtime.py
@@ -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):
diff --git a/lms/lib/xblock/test/test_runtime.py b/lms/lib/xblock/test/test_runtime.py
index f4ec165fed..75d0f38c3f 100644
--- a/lms/lib/xblock/test/test_runtime.py
+++ b/lms/lib/xblock/test/test_runtime.py
@@ -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"""
diff --git a/lms/templates/lti.html b/lms/templates/lti.html
index 0018554528..91348a8a5e 100644
--- a/lms/templates/lti.html
+++ b/lms/templates/lti.html
@@ -1,17 +1,31 @@
<%! import json %>
<%! from django.utils.translation import ugettext as _ %>
+
+
+% if has_score and weight:
+
+ % 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
+
+% endif
+
-% 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:
diff --git a/lms/urls.py b/lms/urls.py
index 28c0ca7071..e9a033130a 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -326,6 +326,9 @@ if settings.COURSEWARE_ENABLED:
url(r'^courses/(?P
[^/]+/[^/]+/[^/]+)/notes$', 'notes.views.notes', name='notes'),
url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/notes/', include('notes.urls')),
+ # LTI endpoints listing
+ url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/lti_rest_endpoints/',
+ 'courseware.views.get_course_lti_endpoints', name='lti_rest_endpoints'),
)
# allow course staff to change to student view of courseware