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 _ %> +

+ ## Translators: "External resource" means that this learning module is hosted on a platform external to the edX LMS + ${display_name} (${_('External resource')}) +

+ +% 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 +