diff --git a/lms/djangoapps/courseware/tests/test_block_render.py b/lms/djangoapps/courseware/tests/test_block_render.py index 2744592610ec..9fcf8fd546e8 100644 --- a/lms/djangoapps/courseware/tests/test_block_render.py +++ b/lms/djangoapps/courseware/tests/test_block_render.py @@ -51,6 +51,7 @@ Mixologist, # pylint: disable=wrong-import-order ) from xblock.test.tools import TestRuntime # pylint: disable=wrong-import-order +from xblocks_contrib.lti import LTIBlock from xblocks_contrib.problem.capa.tests.response_xml_factory import ( OptionResponseXMLFactory, # pylint: disable=reimported ) @@ -97,7 +98,6 @@ from xmodule.capa_block import ProblemBlock from xmodule.contentstore.django import contentstore from xmodule.html_block import AboutBlock, CourseInfoBlock, HtmlBlock, StaticTabBlock -from xmodule.lti_block import LTIBlock from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import XBlockI18nService, modulestore from xmodule.modulestore.tests.django_utils import ( diff --git a/lms/djangoapps/courseware/tests/test_lti_integration.py b/lms/djangoapps/courseware/tests/test_lti_integration.py index 11d2c3a6b0ad..15160bd004e5 100644 --- a/lms/djangoapps/courseware/tests/test_lti_integration.py +++ b/lms/djangoapps/courseware/tests/test_lti_integration.py @@ -1,7 +1,6 @@ """LTI integration tests""" -import importlib import json import re import urllib @@ -11,7 +10,6 @@ import oauthlib from django.conf import settings -from django.test import override_settings from django.urls import reverse from xblock import plugin @@ -19,7 +17,6 @@ from lms.djangoapps.courseware.tests.helpers import BaseTestXmodule from lms.djangoapps.courseware.views.views import get_course_lti_endpoints from openedx.core.lib.url_utils import quote_slashes -from xmodule import lti_block from xmodule.modulestore.tests.django_utils import ( SharedModuleStoreTestCase, # pylint: disable=wrong-import-order ) @@ -45,7 +42,6 @@ class _TestLTIBase(BaseTestXmodule): def setUpClass(cls): super().setUpClass() plugin.PLUGIN_CACHE = {} - importlib.reload(lti_block) def setUp(self): """ @@ -137,25 +133,17 @@ def mocked_sign(self, *args, **kwargs): @patch('xblock.utils.resources.ResourceLoader.render_django_template', side_effect=mock_render_template) def test_lti_constructor(self, mock_render_django_template): generated_content = self.block.student_view(None).content - - if settings.USE_EXTRACTED_LTI_BLOCK: - # Remove i18n service from the extracted LTI Block's rendered `student_view` content - generated_content = re.sub(r"\{.*?}", "{}", generated_content) - expected_content = self.runtime.render_template('templates/lti.html', self.expected_context) - mock_render_django_template.assert_called_once() - else: - expected_content = self.runtime.render_template('lti.html', self.expected_context) + # Remove i18n service from the extracted LTI Block's rendered `student_view` content + generated_content = re.sub(r"\{.*?}", "{}", generated_content) + expected_content = self.runtime.render_template('templates/lti.html', self.expected_context) + mock_render_django_template.assert_called_once() assert generated_content == expected_content @patch('xblock.utils.resources.ResourceLoader.render_django_template', side_effect=mock_render_template) def test_lti_preview_handler(self, mock_render_django_template): generated_content = self.block.preview_handler(None, None).body - - if settings.USE_EXTRACTED_LTI_BLOCK: - expected_content = self.runtime.render_template('templates/lti_form.html', self.expected_context) - mock_render_django_template.assert_called_once() - else: - expected_content = self.runtime.render_template('lti_form.html', self.expected_context) + expected_content = self.runtime.render_template('templates/lti_form.html', self.expected_context) + mock_render_django_template.assert_called_once() assert generated_content.decode('utf-8') == expected_content @@ -251,21 +239,9 @@ def test_lti_rest_non_get(self): assert 405 == response.status_code -@override_settings(USE_EXTRACTED_LTI_BLOCK=True) -class TestLTIExtracted(_TestLTIBase): - __test__ = True - - -@override_settings(USE_EXTRACTED_LTI_BLOCK=False) -class TestLTIBuiltIn(_TestLTIBase): - __test__ = True - - -@override_settings(USE_EXTRACTED_LTI_BLOCK=True) -class TestLTIBlockListingExtracted(_TestLTIBlockListingBase): +class TestLTI(_TestLTIBase): __test__ = True -@override_settings(USE_EXTRACTED_LTI_BLOCK=False) -class TestLTIBlockListingBuiltIn(_TestLTIBlockListingBase): +class TestLTIBlockListing(_TestLTIBlockListingBase): __test__ = True diff --git a/lms/templates/lti.html b/lms/templates/lti.html deleted file mode 100644 index 05346ec3dc40..000000000000 --- a/lms/templates/lti.html +++ /dev/null @@ -1,69 +0,0 @@ -<%page expression_filter="h"/> -<%! -import json -from django.utils.translation import gettext 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 - -
- -% if launch_url and launch_url != 'http://www.example.com' and not hide_launch: - % if open_in_a_new_page: - - % else: - ## The result of the form submit will be rendered here. - - % endif -% elif not hide_launch: -

- ${_('Please provide launch_url. Click "Edit", and fill in the required fields.')} -

-% endif - -% if has_score and comment: -

${_("Feedback on your work from the grader:")}

-
- ## sanitized with nh3 in view - ${comment | n, decode.utf8} -
-% endif - -
diff --git a/lms/templates/lti_form.html b/lms/templates/lti_form.html deleted file mode 100644 index c0f1310fb6b9..000000000000 --- a/lms/templates/lti_form.html +++ /dev/null @@ -1,40 +0,0 @@ -<%page expression_filter="h"/> -<%! -import json -from django.utils.translation import gettext as _ -from openedx.core.djangolib.js_utils import js_escaped_string -%> - - - - - LTI - - - ## This form will be hidden. - ## LTI block JavaScript will trigger a "submit" on the form, and the - ## result will be rendered instead. - - - - diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 31b0015d34d9..c14f328d8e46 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -2086,14 +2086,6 @@ def add_optional_apps(optional_apps, installed_apps): # .. toggle_target_removal_date: 2026-04-10 USE_EXTRACTED_POLL_QUESTION_BLOCK = True -# .. toggle_name: USE_EXTRACTED_LTI_BLOCK -# .. toggle_default: True -# .. toggle_implementation: DjangoSetting -# .. toggle_description: Enables the use of the extracted LTI XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2024-11-10 -# .. toggle_target_removal_date: 2026-04-10 -USE_EXTRACTED_LTI_BLOCK = True # .. toggle_name: USE_EXTRACTED_HTML_BLOCK # .. toggle_default: True diff --git a/pyproject.toml b/pyproject.toml index 44488938ec95..e9d091bc619e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ itembank = "xmodule.item_bank_block:ItemBankBlock" image = "xmodule.template_block:TranslateCustomTagBlock" library = "xmodule.library_root_xblock:LibraryRoot" library_content = "xmodule.library_content_block:LegacyLibraryContentBlock" -lti = "xmodule.lti_block:LTIBlock" +lti = "xblocks_contrib:LTIBlock" poll_question = "xmodule.poll_block:PollBlock" problem = "xmodule.capa_block:ProblemBlock" randomize = "xmodule.randomize_block:RandomizeBlock" diff --git a/webpack.builtinblocks.config.js b/webpack.builtinblocks.config.js index 20d8790fc5b5..2f12b3e59bab 100644 --- a/webpack.builtinblocks.config.js +++ b/webpack.builtinblocks.config.js @@ -42,14 +42,6 @@ module.exports = { './xmodule/js/src/xmodule.js', './xmodule/js/src/vertical/edit.js' ], - LTIBlockDisplay: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/lti/lti.js' - ], - LTIBlockEditor: [ - './xmodule/js/src/xmodule.js', - './xmodule/js/src/raw/edit/metadata-only.js' - ], PollBlockDisplay: [ './xmodule/js/src/xmodule.js', './xmodule/js/src/javascript_loader.js', diff --git a/webpack.common.config.js b/webpack.common.config.js index cd9d0f53af59..577587a8b45c 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -412,15 +412,7 @@ module.exports = Merge.merge({ } ] }, - { - test: /xmodule\/js\/src\/lti\/lti.js/, - use: [ - { - loader: 'imports-loader', - options: 'this=>window' - } - ] - }, + { test: /xmodule\/js\/src\/poll\/poll.js/, use: [ diff --git a/xmodule/js/fixtures/lti.html b/xmodule/js/fixtures/lti.html deleted file mode 100644 index 8c433d047b9d..000000000000 --- a/xmodule/js/fixtures/lti.html +++ /dev/null @@ -1,37 +0,0 @@ -
-
- -
- - - - - - - - - - - - - - - - - -
- -
-
diff --git a/xmodule/js/src/lti/lti.js b/xmodule/js/src/lti/lti.js deleted file mode 100644 index 94dd16a6a3fc..000000000000 --- a/xmodule/js/src/lti/lti.js +++ /dev/null @@ -1,34 +0,0 @@ -(function() { - 'use strict'; - - /** - * This function will process all the attributes from the DOM element passed, taking all of - * the configuration attributes. It uses the request-username and request-email - * to prompt the user to decide if they want to share their personal information - * with the third party application connecting through LTI. - * @constructor - * @param {jQuery} element DOM element with the lti container. - */ - this.LTI = function(element) { - var dataAttrs = $(element).find('.lti').data(), - askToSendUsername = (dataAttrs.askToSendUsername === 'True'), - askToSendEmail = (dataAttrs.askToSendEmail === 'True'); - - // When the lti button is clicked, provide users the option to - // accept or reject sending their information to a third party - $(element).on('click', '.link_lti_new_window', function() { - if (askToSendUsername && askToSendEmail) { - // eslint-disable-next-line no-alert - return confirm(gettext('Click OK to have your username and e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); - } else if (askToSendUsername) { - // eslint-disable-next-line no-alert - return confirm(gettext('Click OK to have your username sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); - } else if (askToSendEmail) { - // eslint-disable-next-line no-alert - return confirm(gettext('Click OK to have your e-mail address sent to a 3rd party application.\n\nClick Cancel to return to this page without sending your information.')); - } else { - return true; - } - }); - }; -}).call(this); diff --git a/xmodule/lti_2_util.py b/xmodule/lti_2_util.py deleted file mode 100644 index ebd2e770d686..000000000000 --- a/xmodule/lti_2_util.py +++ /dev/null @@ -1,370 +0,0 @@ -""" -A mixin class for LTI 2.0 functionality. This is really just done to refactor the code to -keep the LTIBlock class from getting too big -""" - - -import base64 -import hashlib -import json -import logging -import re -from unittest import mock -from urllib import parse - -from django.conf import settings -from oauthlib.oauth1 import Client -from webob import Response -from xblock.core import XBlock - -from openedx.core.lib.grade_utils import round_away_from_zero - -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 LTIBlock and LTI20BlockMixin""" - - -class LTI20BlockMixin: - """ - This class MUST be mixed into LTIBlock. 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 settings.DEBUG: - self._log_correct_authorization_header(request) - - if not self.accept_grades_past_due and self.is_past_due(): - return Response(status=404) # have to do 404 due to spec, but 400 is better, with error msg in body - - 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.runtime.service(self, 'user').get_user_by_anonymous_id(anon_id) - if not real_user: # that means we can't save to database, as we do not have real user id. - msg = f"[LTI]: Real user not found against anon_id: {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 = str(base64.b64encode(sha1.digest())) - log.debug(f"[LTI] oauth_body_hash = {oauth_body_hash}") - client_key, client_secret = self.get_client_key_secret() - client = Client(client_key, client_secret) - mock_request = mock.Mock( - uri=str(parse.unquote(request.url)), - headers=request.headers, - body="", - decoded_body="", - http_method=str(request.method), - ) - params = client.get_oauth_params(mock_request) - mock_request.oauth_params = params - mock_request.oauth_params.append(('oauth_body_hash', oauth_body_hash)) - sig = client.get_oauth_signature(mock_request) - mock_request.oauth_params.append(('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(f"[LTI]: {msg}") - raise LTIError(msg) - - def _lti_2_0_result_get_handler(self, request, real_user): - """ - 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.runtime.service(self, 'rebind_user').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).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) - - # Fall through to returning grade and comment - base_json_obj['resultScore'] = round_away_from_zero(self.module_score, 2) - base_json_obj['comment'] = self.score_comment - return Response(json.dumps(base_json_obj).encode('utf-8'), content_type=LTI_2_0_JSON_CONTENT_TYPE) - - def _lti_2_0_result_del_handler(self, request, real_user): - """ - 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.decode('utf-8')) - 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 block - 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, score_deleted=True) - - def set_user_module_score(self, user, score, max_score, comment="", score_deleted=False): - """ - 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.runtime.service(self, 'rebind_user').rebind_noauth_module_to_user(self, user) - - # have to publish for the progress page... - self.runtime.publish( - self, - 'grade', - { - 'value': scaled_score, - 'max_value': max_score, - 'user_id': user.id, - 'score_deleted': score_deleted, - }, - ) - 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(f"[LTI]: v2.0 result service -- bad Content-Type: {content_type}") - raise LTIError( - "For LTI 2.0 result service, Content-Type must be {}. Got {}".format(LTI_2_0_JSON_CONTENT_TYPE, # noqa: UP032 # pylint: disable=line-too-long - content_type)) - try: - self.verify_oauth_body_sign(request, content_type=LTI_2_0_JSON_CONTENT_TYPE) - except (ValueError, LTIError) as err: - log.info(f"[LTI]: v2.0 result service -- OAuth body verification failed: {str(err)}") - raise LTIError(str(err)) # pylint: disable=raise-missing-from # noqa: B904 - - 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 = f"Supplied JSON string in request body could not be decoded: {json_str}" - log.info(f"[LTI] {msg}") - raise LTIError(msg) # pylint: disable=raise-missing-from # noqa: B904 - - # 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 not isinstance(json_obj, dict): - if isinstance(json_obj, list) and len(json_obj) >= 1 and isinstance(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. {}" # noqa: UP032 # pylint: disable=line-too-long - .format(json_str)) - log.info(f"[LTI] {msg}") - raise LTIError(msg) - - # '@type' must be "Result" - result_type = json_obj.get("@type") - if result_type != "Result": - msg = f"JSON object does not contain correct @type attribute (should be 'Result', is {result_type})" - log.info(f"[LTI] {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 = f"JSON object does not contain required key {key}" - log.info(f"[LTI] {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(f"[LTI] {msg}") - raise LTIError(msg) - except (TypeError, ValueError) as err: - msg = f"Could not convert resultScore to float: {str(err)}" - log.info(f"[LTI] {msg}") - raise LTIError(msg) # pylint: disable=raise-missing-from # noqa: B904 - - return score, json_obj.get('comment', "") diff --git a/xmodule/lti_block.py b/xmodule/lti_block.py deleted file mode 100644 index cad3df090d79..000000000000 --- a/xmodule/lti_block.py +++ /dev/null @@ -1,1016 +0,0 @@ -""" -THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer - -Learning Tools Interoperability (LTI) module. - - -Resources ---------- - -Theoretical background and detailed specifications of LTI can be found on: - - http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html - -This module is based on the version 1.1.1 of the LTI specifications by the -IMS Global authority. For authentication, it uses OAuth1. - -When responding back to the LTI tool provider, we must issue a correct -response. Types of responses and their message payload is available at: - - Table A1.2 Interpretation of the 'CodeMajor/severity' matrix. - http://www.imsglobal.org/gws/gwsv1p0/imsgws_wsdlBindv1p0.html - -A resource to test the LTI protocol (PHP realization): - - http://www.imsglobal.org/developers/LTI/test/v1p1/lms.php - -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: ------------------- - -1.) Display of simple LTI in iframe or a new window. -2.) Multiple LTI components on a single page. -3.) The use of multiple LTI providers per course. -4.) Use of advanced LTI component that provides back a grade. - A) 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/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 base64 -import datetime -import hashlib -import logging -import textwrap -import warnings -from unittest import mock -from urllib import parse -from xml.sax.saxutils import escape -from zoneinfo import ZoneInfo - -import nh3 -import oauthlib.oauth1 -from django.conf import settings -from lxml import etree -from oauthlib.oauth1.rfc5849 import signature -from opaque_keys.edx.keys import CourseKey -from web_fragments.fragment import Fragment -from webob import Response -from xblock.core import List, Scope, String, XBlock -from xblock.fields import Boolean, Float -from xblocks_contrib.lti import LTIBlock as _ExtractedLTIBlock - -from common.djangoapps.xblock_django.constants import ATTR_KEY_ANONYMOUS_USER_ID, ATTR_KEY_USER_ROLE -from openedx.core.djangolib.markup import HTML, Text -from xmodule.editing_block import EditingMixin -from xmodule.lti_2_util import LTI20BlockMixin, LTIError -from xmodule.mako_block import MakoTemplateBlockBase -from xmodule.raw_block import EmptyDataRawMixin -from xmodule.util.builtin_assets import add_css_to_fragment, add_webpack_js_to_fragment -from xmodule.x_module import ResourceTemplates, XModuleMixin, XModuleToXBlockMixin, shim_xmodule_js -from xmodule.xml_block import XmlMixin - -log = logging.getLogger(__name__) - -DOCS_ANCHOR_TAG_OPEN = ( - "" -) -BREAK_TAG = '
' - -# Make '_' a no-op so we can scrape strings. Using lambda instead of -# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file -_ = lambda text: text - - -class LTIFields: - """ - Fields to define and obtain LTI tool from provider are set here, - except credentials, which should be set in course settings:: - - `lti_id` is id to connect tool with credentials in course settings. It should not contain :: (double semicolon) - `launch_url` is launch URL of tool. - `custom_parameters` are additional parameters to navigate to proper book and book page. - - For example, for Vitalsource provider, `launch_url` should be - *https://bc-staging.vitalsource.com/books/book*, - and to get to proper book and book page, you should set custom parameters as:: - - vbid=put_book_id_here - book_location=page/put_page_number_here - - Default non-empty URL for `launch_url` is needed due to oauthlib demand (URL scheme should be presented):: - - https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 - """ - display_name = String( - display_name=_("Display Name"), - help=_( - "The display name 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=Text(_( - "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." - "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." - )).format( - break_tag=HTML(BREAK_TAG), - docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), - anchor_close=HTML("
") - ), - default='', - scope=Scope.settings - ) - launch_url = String( - display_name=_("LTI URL"), - help=Text(_( - "Enter the URL of the external tool that this component launches. " - "This setting is only used when Hide External Tool is set to False." - "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." - )).format( - break_tag=HTML(BREAK_TAG), - docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), - anchor_close=HTML("") - ), - default='http://www.example.com', - scope=Scope.settings) - custom_parameters = List( - display_name=_("Custom Parameters"), - help=Text(_( - "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." - "{break_tag}See {docs_anchor_open}the edX LTI documentation{anchor_close} for more details on this setting." - )).format( - break_tag=HTML(BREAK_TAG), - docs_anchor_open=HTML(DOCS_ANCHOR_TAG_OPEN), - anchor_close=HTML("") - ), - 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( - 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}, - ) - 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 - ) - - # Users will be presented with a message indicating that their e-mail/username would be sent to a third - # party application. When "Open in New Page" is not selected, the tool automatically appears without any user action. # pylint: disable=line-too-long - ask_to_send_username = Boolean( - display_name=_("Request user's username"), - # Translators: This is used to request the user's username for a third party service. - help=_("Select True to request the user's username."), - default=False, - scope=Scope.settings - ) - ask_to_send_email = Boolean( - display_name=_("Request user's email"), - # Translators: This is used to request the user's email for a third party service. - help=_("Select True to request the user's email address."), - default=False, - scope=Scope.settings - ) - - description = String( - display_name=_("LTI Application Information"), - help=_( - "Enter a description of the third party application. If requesting username and/or email, use this text box to inform users " # pylint: disable=line-too-long - "why their username and/or email will be forwarded to a third party application." - ), - default="", - scope=Scope.settings - ) - - button_text = String( - display_name=_("Button Text"), - help=_( - "Enter the text on the button used to launch the third party application." - ), - default="", - scope=Scope.settings - ) - - accept_grades_past_due = Boolean( - display_name=_("Accept grades past deadline"), - help=_("Select True to allow third party systems to post grades past the deadline."), - default=True, - scope=Scope.settings - ) - - -@XBlock.needs("i18n") -@XBlock.needs("mako") -@XBlock.needs("user") -@XBlock.needs("rebind_user") -class _BuiltInLTIBlock( - LTIFields, - LTI20BlockMixin, - EmptyDataRawMixin, - XmlMixin, - EditingMixin, - MakoTemplateBlockBase, - XModuleToXBlockMixin, - ResourceTemplates, - XModuleMixin, -): # pylint: disable=abstract-method - """ - THIS MODULE IS DEPRECATED IN FAVOR OF https://github.com/openedx/xblock-lti-consumer - - Module provides LTI integration to course. - - .. deprecated:: 2026-03 - This built-in LTI block is deprecated. Please use the extracted ``LTIBlock`` - from ``xblocks_contrib.lti`` instead. - - Except usual Xmodule structure it proceeds with OAuth signing. - How it works:: - - 1. Get credentials from course settings. - - 2. There is minimal set of parameters need to be signed (presented for Vitalsource):: - - user_id - oauth_callback - lis_outcome_service_url - lis_result_sourcedid - launch_presentation_return_url - lti_message_type - lti_version - roles - *+ all custom parameters* - - These parameters should be encoded and signed by *OAuth1* together with - `launch_url` and *POST* request type. - - 3. Signing proceeds with client key/secret pair obtained from course settings. - That pair should be obtained from LTI provider and set into course settings by course author. - After that signature and other OAuth data are generated. - - OAuth data which is generated after signing is usual:: - - oauth_callback - oauth_nonce - oauth_consumer_key - oauth_signature_method - oauth_timestamp - oauth_version - - - 4. All that data is passed to form and sent to LTI provider server by browser via - autosubmit via JavaScript. - - Form example:: - -
- - - - - - - - - - - - - - - - - - - - -
- - 5. LTI provider has same secret key and it signs data string via *OAuth1* and compares signatures. - - If signatures are correct, LTI provider redirects iframe source to LTI tool web page, - and LTI tool is rendered to iframe inside course. - - Otherwise error message from LTI provider is generated. - """ - is_extracted = False - resources_dir = None - uses_xmodule_styles_setup = True - - mako_template = 'widgets/metadata-only-edit.html' - - studio_js_module_name = 'MetadataOnlyEditingDescriptor' - - def studio_view(self, _context): - """ - Return the studio view. - """ - context = MakoTemplateBlockBase.get_context(self) - # Add our specific template information (the raw data body) - context.update({'data': self.data}) - fragment = Fragment( - self.runtime.service(self, 'mako').render_cms_template(self.mako_template, context) - ) - add_webpack_js_to_fragment(fragment, 'LTIBlockEditor') - shim_xmodule_js(fragment, self.studio_js_module_name) - return fragment - - def max_score(self): - return self.weight if self.has_score else None - - def get_input_fields(self): # pylint: disable=missing-function-docstring - # LTI provides a list of default parameters that might be passed as - # part of the POST data. These parameters should not be prefixed. - # Likewise, The creator of an LTI link can add custom key/value parameters - # to a launch which are to be included with the launch of the LTI link. - # In this case, we will automatically add `custom_` prefix before this parameters. - # See http://www.imsglobal.org/LTI/v1p1p1/ltiIMGv1p1p1.html#_Toc316828520 - PARAMETERS = [ - "lti_message_type", - "lti_version", - "resource_link_title", - "resource_link_description", - "user_image", - "lis_person_name_given", - "lis_person_name_family", - "lis_person_name_full", - "lis_person_contact_email_primary", - "lis_person_sourcedid", - "role_scope_mentor", - "context_type", - "context_title", - "context_label", - "launch_presentation_locale", - "launch_presentation_document_target", - "launch_presentation_css_url", - "launch_presentation_width", - "launch_presentation_height", - "launch_presentation_return_url", - "tool_consumer_info_product_family_code", - "tool_consumer_info_version", - "tool_consumer_instance_guid", - "tool_consumer_instance_name", - "tool_consumer_instance_description", - "tool_consumer_instance_url", - "tool_consumer_instance_contact_email", - ] - - client_key, client_secret = self.get_client_key_secret() - - # parsing custom parameters to dict - custom_parameters = {} - - for custom_parameter in self.custom_parameters: - try: - param_name, param_value = [p.strip() for p in custom_parameter.split('=', 1)] - except ValueError: - _ = self.runtime.service(self, "i18n").ugettext - msg = _('Could not parse custom parameter: {custom_parameter}. Should be "x=y" string.').format( - custom_parameter=f"{custom_parameter!r}" - ) - raise LTIError(msg) # pylint: disable=raise-missing-from # noqa: B904 - - # LTI specs: 'custom_' should be prepended before each custom parameter, as pointed in link above. - if param_name not in PARAMETERS: - param_name = 'custom_' + param_name - - custom_parameters[str(param_name)] = str(param_value) - - return self.oauth_params( - custom_parameters, - client_key, - client_secret, - ) - - def get_context(self): - """ - Returns a context. - """ - # nh3 defaults for - # ALLOWED_TAGS are - # { - # 'a', 'abbr', 'acronym', 'area', 'article', 'aside', 'b', 'bdi', 'bdo', - # 'blockquote', 'br', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', - # 'data', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', - # 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', - # 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'map', 'mark', 'nav', 'ol', 'p', 'pre', - # 'q', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small', 'span', 'strike', - # 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'th', 'thead', - # 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr' - # } - # - # ALLOWED_ATTRIBUTES are - # { - # 'a': {'href', 'hreflang'}, - # 'bdo': {'dir'}, - # 'blockquote': {'cite'}, - # 'col': {'charoff', 'char', 'align', 'span'}, - # 'colgroup': {'align', 'char', 'charoff', 'span'}, - # 'del': {'datetime', 'cite'}, - # 'hr': {'width', 'align', 'size'}, - # 'img': {'height', 'src', 'width', 'alt', 'align'}, - # 'ins': {'datetime', 'cite'}, - # 'ol': {'start'}, - # 'q': {'cite'}, - # 'table': {'align', 'char', 'charoff', 'summary'}, - # 'tbody': {'align', 'char', 'charoff'}, - # 'td': {'rowspan', 'headers', 'charoff', 'colspan', 'char', 'align'}, - # 'tfoot': {'align', 'char', 'charoff'}, - # 'th': {'rowspan', 'headers', 'charoff', 'colspan', 'scope', 'char', 'align'}, - # 'thead': {'charoff', 'char', 'align'}, - # 'tr': {'align', 'char', 'charoff'} - # } - # - # This lets all plaintext through. - sanitized_comment = nh3.clean(self.score_comment) - - return { - 'input_fields': self.get_input_fields(), - - # These parameters do not participate in OAuth signing. - 'launch_url': self.launch_url.strip(), - 'element_id': self.location.html_id(), - 'element_class': self.category, - '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, - 'description': self.description, - 'ask_to_send_username': self.ask_to_send_username, - 'ask_to_send_email': self.ask_to_send_email, - 'button_text': self.button_text, - 'accept_grades_past_due': self.accept_grades_past_due, - } - - def student_view(self, _context): - """ - Return the student view. - """ - fragment = Fragment() - fragment.add_content(self.runtime.service(self, 'mako').render_lms_template('lti.html', self.get_context())) - add_css_to_fragment(fragment, 'LTIBlockDisplay.css') - add_webpack_js_to_fragment(fragment, 'LTIBlockDisplay') - shim_xmodule_js(fragment, 'LTI') - return fragment - - @XBlock.handler - def preview_handler(self, _, __): - """ - This is called to get context with new oauth params to iframe. - """ - template = self.runtime.service(self, 'mako').render_lms_template('lti_form.html', self.get_context()) - return Response(template, content_type='text/html') - - def get_user_id(self): - """ - Returns the current user ID, URL-escaped so it is safe to use as a URL component. - """ - user_id = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) - assert user_id is not None - return str(parse.quote(user_id)) - - def get_outcome_service_url(self, service_name="grade_handler"): - """ - Return URL for storing grades. - - To test LTI on sandbox we must use http scheme. - - 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 - """ - return self.runtime.handler_url(self, service_name, thirdparty=True).rstrip('/?') - - def get_resource_link_id(self): - """ - This is an opaque unique identifier that the TC guarantees will be unique - within the TC for every placement of the link. - - If the tool / activity is placed multiple times in the same context, - each of those placements will be distinct. - - This value will also change if the item is exported from one system or - context and imported into another system or context. - - This parameter is required. - - Example: u'edx.org-i4x-2-3-lti-31de800015cf4afb973356dbe81496df' - - Hostname, edx.org, - makes resource_link_id change on import to another system. - - Last part of location, location.name - 31de800015cf4afb973356dbe81496df, - is random hash, updated by course_id, - this makes resource_link_id unique inside single course. - - First part of location is tag-org-course-category, i4x-2-3-lti. - - Location.name itself does not change on import to another course, - but org and course_id change. - - So together with org and course_id in a form of - i4x-2-3-lti-31de800015cf4afb973356dbe81496df this part of resource_link_id: - makes resource_link_id to be unique among courses inside same system. - """ - return str(parse.quote(f"{settings.LMS_BASE}-{self.location.html_id()}")) - - def get_lis_result_sourcedid(self): - """ - This field contains an identifier that indicates the LIS Result Identifier (if any) - associated with this launch. This field identifies a unique row and column within the - TC gradebook. This field is unique for every combination of context_id / resource_link_id / user_id. - This value may change for a particular resource_link_id / user_id from one launch to the next. - The TP should only retain the most recent value for this field for a particular resource_link_id / user_id. - This field is generally optional, but is required for grading. - """ - return "{context}:{resource_link}:{user_id}".format( # noqa: UP032 - context=parse.quote(self.context_id), - resource_link=self.get_resource_link_id(), - user_id=self.get_user_id() - ) - - def get_course(self): - """ - Return course by course id. - - Returns None if the current block is not part of a course (i.e part of a library). - """ - if isinstance(self.course_id, CourseKey): - return self.runtime.modulestore.get_course(self.course_id) - return None - - @property - def context_id(self): - """ - Return context_id. - - context_id is an opaque identifier that uniquely identifies the context (e.g., a course) - that contains the link being launched. - """ - return str(self.course_id) - - @property - def role(self): - """ - Get system user role and convert it to LTI role. - """ - roles = { - 'student': 'Student', - 'staff': 'Administrator', - 'instructor': 'Instructor', - } - user_role = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(ATTR_KEY_USER_ROLE) - return roles.get(user_role, 'Student') - - def get_icon_class(self): - """ Returns the icon class """ - if self.graded and self.has_score: # pylint: disable=no-member - return 'problem' - return 'other' - - def oauth_params(self, custom_parameters, client_key, client_secret): - """ - Signs request and returns signature and OAuth parameters. - - `custom_paramters` is dict of parsed `custom_parameter` field - `client_key` and `client_secret` are LTI tool credentials. - - Also *anonymous student id* is passed to template and therefore to LTI provider. - """ - - client = oauthlib.oauth1.Client( - client_key=str(client_key), - client_secret=str(client_secret) - ) - - # Must have parameters for correct signing from LTI: - body = { - 'user_id': self.get_user_id(), - 'oauth_callback': 'about:blank', - 'launch_presentation_return_url': '', - 'lti_message_type': 'basic-lti-launch-request', - 'lti_version': 'LTI-1p0', - 'roles': self.role, - - # Parameters required for grading: - 'resource_link_id': self.get_resource_link_id(), - 'lis_result_sourcedid': self.get_lis_result_sourcedid(), - - 'context_id': self.context_id, - } - - if self.has_score: - body.update({ - 'lis_outcome_service_url': self.get_outcome_service_url() - }) - - self.user_email = "" # pylint: disable=attribute-defined-outside-init - self.user_username = "" # pylint: disable=attribute-defined-outside-init - - # Username and email can't be sent in studio mode, because the user object is not defined. - # To test functionality test in LMS - - real_user_object = self.runtime.service(self, 'user').get_user_by_anonymous_id() - try: - self.user_email = real_user_object.email # pylint: disable=attribute-defined-outside-init - except AttributeError: - self.user_email = "" # pylint: disable=attribute-defined-outside-init - try: - self.user_username = real_user_object.username # pylint: disable=attribute-defined-outside-init - except AttributeError: - self.user_username = "" # pylint: disable=attribute-defined-outside-init - - if self.ask_to_send_username and self.user_username: - body["lis_person_sourcedid"] = self.user_username - if self.ask_to_send_email and self.user_email: - body["lis_person_contact_email_primary"] = self.user_email - - # Appending custom parameter for signing. - body.update(custom_parameters) - - headers = { - # This is needed for body encoding: - 'Content-Type': 'application/x-www-form-urlencoded', - } - - try: - __, headers, __ = client.sign( - str(self.launch_url.strip()), - http_method='POST', - body=body, - headers=headers) - except ValueError: # Scheme not in url. - # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 - # Stubbing headers for now: - log.info( - "LTI block %s in course %s does not have oauth parameters correctly configured.", - self.location, - self.location.course_key, - ) - headers = { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'OAuth oauth_nonce="80966668944732164491378916897", \ -oauth_timestamp="1378916897", oauth_version="1.0", oauth_signature_method="HMAC-SHA1", \ -oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} - - params = headers['Authorization'] - # Parse headers to pass to template as part of context: - params = dict([param.strip().replace('"', '').split('=') for param in params.split(',')]) - - params['oauth_nonce'] = params['OAuth oauth_nonce'] - del params['OAuth oauth_nonce'] - - # oauthlib encodes signature with - # 'Content-Type': 'application/x-www-form-urlencoded' - # so '='' becomes '%3D'. - # We send form via browser, so browser will encode it again, - # So we need to decode signature back: - params['oauth_signature'] = parse.unquote(params['oauth_signature']).encode('utf-8').decode('utf8') # pylint: disable=line-too-long - - # Add LTI parameters to OAuth parameters for sending in form. - params.update(body) - return params - - @XBlock.handler - def grade_handler(self, request, suffix): # pylint: disable=unused-argument - """ - This is called by courseware.block_render, to handle an AJAX call. - - Used only for grading. Returns XML response. - - Example of request body from LTI provider:: - - - - - - V1.0 - 528243ba5241b - - - - - - - feb-123-456-2929::28883 - - - - en-us - 0.4 - - - - - - - - Example of correct/incorrect answer XML body:: see response_xml_template. - """ - response_xml_template = textwrap.dedent("""\ - - - - - V1.0 - {imsx_messageIdentifier} - - {imsx_codeMajor} - status - {imsx_description} - - - - - - {response} - - """) - # Returns when `action` is unsupported. - # Supported actions: - # - replaceResultRequest. - unsupported_values = { - 'imsx_codeMajor': 'unsupported', - 'imsx_description': 'Target does not support the requested operation.', - 'imsx_messageIdentifier': 'unknown', - 'response': '' - } - # Returns if: - # - past due grades are not accepted and grade is past due - # - score is out of range - # - can't parse response from TP; - # - can't verify OAuth signing or OAuth signing is incorrect. - failure_values = { - 'imsx_codeMajor': 'failure', - 'imsx_description': 'The request has failed.', - 'imsx_messageIdentifier': 'unknown', - 'response': '' - } - - if not self.accept_grades_past_due and self.is_past_due(): - failure_values['imsx_description'] = "Grade is past due" - return Response(response_xml_template.format(**failure_values), content_type="application/xml") - - try: - imsx_messageIdentifier, sourcedId, score, action = self.parse_grade_xml_body(request.body) - except Exception as e: # pylint: disable=broad-except - error_message = "Request body XML parsing error: " + escape(str(e)) - log.debug("[LTI]: " + error_message) # pylint: disable=logging-not-lazy - failure_values['imsx_description'] = error_message - return Response(response_xml_template.format(**failure_values), content_type="application/xml") - - # Verify OAuth signing. - try: - self.verify_oauth_body_sign(request) - except (ValueError, LTIError) as e: - failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) - error_message = "OAuth verification error: " + escape(str(e)) - failure_values['imsx_description'] = error_message - log.debug("[LTI]: " + error_message) # pylint: disable=logging-not-lazy - return Response(response_xml_template.format(**failure_values), content_type="application/xml") - - real_user = self.runtime.service(self, 'user').get_user_by_anonymous_id(parse.unquote(sourcedId.split(':')[-1])) - if not real_user: # that means we can't save to database, as we do not have real user id. - failure_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) - failure_values['imsx_description'] = "User not found." - return Response(response_xml_template.format(**failure_values), content_type="application/xml") - - if action == 'replaceResultRequest': - self.set_user_module_score(real_user, score, self.max_score()) - - values = { - 'imsx_codeMajor': 'success', - 'imsx_description': f'Score for {sourcedId} is now {score}', - 'imsx_messageIdentifier': escape(imsx_messageIdentifier), - 'response': '' - } - log.debug("[LTI]: Grade is saved.") - return Response(response_xml_template.format(**values), content_type="application/xml") - - unsupported_values['imsx_messageIdentifier'] = escape(imsx_messageIdentifier) - log.debug("[LTI]: Incorrect action.") - return Response(response_xml_template.format(**unsupported_values), content_type='application/xml') - - @classmethod - def parse_grade_xml_body(cls, body): - """ - Parses XML from request.body and returns parsed data - - XML body should contain nsmap with namespace, that is specified in LTI specs. - - Returns tuple: imsx_messageIdentifier, sourcedId, score, action - - Raises Exception if can't parse. - """ - lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" - namespaces = {'def': lti_spec_namespace} - - data = body.strip() - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - root = etree.fromstring(data, parser=parser) - - imsx_messageIdentifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text or '' - sourcedId = root.xpath("//def:sourcedId", namespaces=namespaces)[0].text - score = root.xpath("//def:textString", namespaces=namespaces)[0].text - action = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0].getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') # pylint: disable=line-too-long - # Raise exception if score is not float or not in range 0.0-1.0 regarding spec. - score = float(score) - if not 0 <= score <= 1: - raise LTIError('score value outside the permitted range of 0-1.') - - return imsx_messageIdentifier, sourcedId, score, action - - def verify_oauth_body_sign(self, request, content_type='application/x-www-form-urlencoded'): - """ - Verify grade request from LTI provider using OAuth body signing. - - Uses http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html:: - - This specification extends the OAuth signature to include integrity checks on HTTP request bodies - with content types other than application/x-www-form-urlencoded. - - Arguments: - request: DjangoWebobRequest. - - Raises: - LTIError if request is incorrect. - """ - - client_key, client_secret = self.get_client_key_secret() # pylint: disable=unused-variable - headers = { - 'Authorization': str(request.headers.get('Authorization')), - 'Content-Type': content_type, - } - - sha1 = hashlib.sha1() - sha1.update(request.body) - oauth_body_hash = base64.b64encode(sha1.digest()).decode('utf-8') - oauth_params = signature.collect_parameters(headers=headers, exclude_oauth_signature=False) - oauth_headers = dict(oauth_params) - oauth_signature = oauth_headers.pop('oauth_signature') - mock_request_lti_1 = mock.Mock( - uri=str(parse.unquote(self.get_outcome_service_url())), - http_method=str(request.method), - params=list(oauth_headers.items()), - signature=oauth_signature - ) - mock_request_lti_2 = mock.Mock( - uri=str(parse.unquote(request.url)), - http_method=str(request.method), - params=list(oauth_headers.items()), - signature=oauth_signature - ) - if oauth_body_hash != oauth_headers.get('oauth_body_hash'): - log.error( - "OAuth body hash verification failed, provided: {}, " - "calculated: {}, for url: {}, body is: {}".format( - oauth_headers.get('oauth_body_hash'), - oauth_body_hash, - self.get_outcome_service_url(), - request.body - ) - ) - raise LTIError("OAuth body hash verification is failed.") - - if (not signature.verify_hmac_sha1(mock_request_lti_1, client_secret) and not - signature.verify_hmac_sha1(mock_request_lti_2, client_secret)): - log.error("OAuth signature verification failed, for " # noqa: UP032 - "headers:{} url:{} method:{}".format( - oauth_headers, - self.get_outcome_service_url(), - str(request.method) - )) - raise LTIError("OAuth signature verification has failed.") - - def get_client_key_secret(self): - """ - Obtains client_key and client_secret credentials from current course. - """ - course = self.get_course() - lti_passports = course.lti_passports if course else [] - for lti_passport in lti_passports: - try: - lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] - except ValueError: - _ = self.runtime.service(self, "i18n").ugettext - msg = _('Could not parse LTI passport: {lti_passport}. Should be "id:key:secret" string.').format( - lti_passport=f'{lti_passport!r}' - ) - raise LTIError(msg) # pylint: disable=raise-missing-from # noqa: B904 - - if lti_id == self.lti_id.strip(): - return key, secret - return '', '' - - def is_past_due(self): - """ - Is it now past this problem's due date, including grace period? - """ - due_date = self.due # pylint: disable=no-member - if self.graceperiod is not None and due_date: # pylint: disable=no-member - close_date = due_date + self.graceperiod # pylint: disable=no-member - else: - close_date = due_date - return close_date is not None and datetime.datetime.now(ZoneInfo("UTC")) > close_date - - -LTIBlock = None - - -def reset_class(): - """Reset class as per django settings flag""" - global LTIBlock - LTIBlock = ( - _ExtractedLTIBlock if settings.USE_EXTRACTED_LTI_BLOCK - else _BuiltInLTIBlock - ) - return LTIBlock - -reset_class() -LTIBlock.__name__ = "LTIBlock" - -if not settings.USE_EXTRACTED_LTI_BLOCK: - warnings.warn( - "The built-in `xmodule.lti_block` LTIBlock implementation is deprecated. " - "To fix this warning, enable `USE_EXTRACTED_LTI_BLOCK` (set it to True) to use " - "`xblocks_contrib.lti.LTIBlock` instead. " - "Support for the built-in implementation, and the `USE_EXTRACTED_LTI_BLOCK` setting, " - "will be removed in Willow.", - DeprecationWarning, - stacklevel=2, - ) diff --git a/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css b/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css deleted file mode 100644 index ab39520a5f28..000000000000 --- a/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css +++ /dev/null @@ -1,56 +0,0 @@ -@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); - - -.xmodule_display.xmodule_LTIBlock h2.problem-header { - display: inline-block; -} - -.xmodule_display.xmodule_LTIBlock div.problem-progress { - display: inline-block; - padding-left: calc((var(--baseline, 20px) / 4)); - color: #666; - font-weight: 100; - font-size: 1em; -} - -.xmodule_display.xmodule_LTIBlock div.lti { - margin: 0 auto; -} - -.xmodule_display.xmodule_LTIBlock div.lti .wrapper-lti-link { - font-size: 14px; - background-color: var(--sidebar-color, #f6f6f6); - padding: var(--baseline, 20px); -} - -.xmodule_display.xmodule_LTIBlock div.lti .wrapper-lti-link .lti-link { - margin-bottom: 0; - text-align: right; -} - -.xmodule_display.xmodule_LTIBlock div.lti .wrapper-lti-link .lti-link .link_lti_new_window { - font-size: 13px; - line-height: 20.72px; -} - -.xmodule_display.xmodule_LTIBlock div.lti form.ltiLaunchForm { - display: none; -} - -.xmodule_display.xmodule_LTIBlock div.lti iframe.ltiLaunchFrame { - width: 100%; - height: 800px; - display: block; - border: 0px; -} - -.xmodule_display.xmodule_LTIBlock div.lti h4.problem-feedback-label { - font-weight: 100; - font-size: 1em; - font-family: "Source Sans", "Open Sans", Verdana, Geneva, sans-serif, sans-serif; -} - -.xmodule_display.xmodule_LTIBlock div.lti div.problem-feedback { - margin-top: calc((var(--baseline, 20px) / 4)); - margin-bottom: calc((var(--baseline, 20px) / 4)); -} diff --git a/xmodule/tests/test_lti20_unit.py b/xmodule/tests/test_lti20_unit.py deleted file mode 100644 index 7fd67d3e4b8f..000000000000 --- a/xmodule/tests/test_lti20_unit.py +++ /dev/null @@ -1,409 +0,0 @@ -"""Tests for LTI Xmodule LTIv2.0 functional logic.""" - - -import datetime -import textwrap -from unittest.mock import Mock -from zoneinfo import ZoneInfo - -from django.conf import settings -from django.test import TestCase, override_settings -from xblock.field_data import DictFieldData -from xblocks_contrib.lti.lti_2_util import LTIError as ExtractedLTIError - -from xmodule import lti_block -from xmodule.lti_2_util import LTIError as BuiltInLTIError -from xmodule.tests.helpers import StubUserService - -from . import get_test_system - - -class _LTI20RESTResultServiceTestBase(TestCase): - """Logic tests for LTI block. LTI2.0 REST ResultService""" - - __test__ = False - USER_STANDIN = Mock() - USER_STANDIN.id = 999 - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.lti_class = lti_block.reset_class() - if settings.USE_EXTRACTED_LTI_BLOCK: - cls.LTIError = ExtractedLTIError - else: - cls.LTIError = BuiltInLTIError - - def setUp(self): - super().setUp() - self.runtime = get_test_system(user=self.USER_STANDIN) - self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - self.runtime.publish = Mock() - self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access - - self.xblock = self.lti_class(self.runtime, DictFieldData({}), Mock()) - self.lti_id = self.xblock.lti_id - self.xblock.due = None - self.xblock.graceperiod = None - - 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(name='mocked_course', lti_passports=['lti_id:test_client:test_secret']) - modulestore = Mock(name='modulestore') - modulestore.get_course.return_value = mocked_course - self.xblock.runtime.modulestore = modulestore - self.xblock.lti_id = "lti_id" - - test_cases = ( # (before sanitize, after sanitize) - ("plaintext", "plaintext"), - ("a ", "a "), # drops scripts - ("bold 包", "bold 包"), # unicode, and tags pass through - ) - for case in test_cases: - self.xblock.score_comment = case[0] - assert case[1] == self.xblock.get_context()['comment'] - - def test_lti20_rest_bad_contenttype(self): - """ - Input with bad content type - """ - with self.assertRaisesRegex(self.LTIError, "Content-Type must be"): # noqa: PT027 - request = Mock(headers={'Content-Type': 'Non-existent'}) - self.xblock.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.xblock.verify_oauth_body_sign = Mock(side_effect=self.LTIError(err_msg)) - with self.assertRaisesRegex(self.LTIError, err_msg): # noqa: PT027 - request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) - self.xblock.verify_lti_2_0_result_rest_headers(request) - - def test_lti20_rest_good_headers(self): - """ - Input with good oauth body hash verification - """ - self.xblock.verify_oauth_body_sign = Mock(return_value=True) - - request = Mock(headers={'Content-Type': 'application/vnd.ims.lis.v2.result+json'}) - self.xblock.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 - assert self.xblock.verify_oauth_body_sign.called - - BAD_DISPATCH_INPUTS = [ - None, - "", - "abcd" - "notuser/abcd" - "user/" - "user//" - "user/gbere/" - "user/gbere/xsdf" - "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.assertRaisesRegex(self.LTIError, "No valid user id found in endpoint URL"): # noqa: PT027 - self.xblock.parse_lti_2_0_handler_suffix(einput) - - GOOD_DISPATCH_INPUTS = [ - ("user/abcd3", "abcd3"), - ("user/Äbcdè2", "Ä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: - assert self.xblock.parse_lti_2_0_handler_suffix(ginput) == expected - - BAD_JSON_INPUTS = [ - # (bad inputs, error message expected) - ([ - "kk", # ValueError - "{{}", # ValueError - "{}}", # ValueError - 3, # TypeError - {}, # TypeError - ], "Supplied JSON string in request body could not be decoded"), - ([ - "3", # valid json, not array or object - "[]", # valid json, array too small - "[3, {}]", # valid json, 1st element not an object - ], "Supplied JSON string is a list that does not contain an object as the first element"), - ([ - '{"@type": "NOTResult"}', # @type key must have value 'Result' - ], "JSON object does not contain correct @type attribute"), - ([ - # @context missing - '{"@type": "Result", "resultScore": 0.1}', - ], "JSON object does not contain required key"), - ([ - ''' - {"@type": "Result", - "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", - "resultScore": 100}''' # score out of range - ], "score value outside the permitted range of 0-1."), - ([ - ''' - {"@type": "Result", - "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", - "resultScore": "1b"}''', # score ValueError - ''' - {"@type": "Result", - "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", - "resultScore": {}}''', # score TypeError - ], "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.assertRaisesRegex(self.LTIError, error_message): # noqa: PT027 - self.xblock.parse_lti_2_0_result_json(einput) - - GOOD_JSON_INPUTS = [ - (''' - {"@type": "Result", - "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", - "resultScore": 0.1}''', ""), # no comment means we expect "" - (''' - [{"@type": "Result", - "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", - "@id": "anon_id:abcdef0123456789", - "resultScore": 0.1}]''', ""), # OK to have array of objects -- just take the first. @id is okay too - (''' - {"@type": "Result", - "@context": "http://purl.imsglobal.org/ctx/lis/v2/Result", - "resultScore": 0.1, - "comment": "ಠ益ಠ"}''', "ಠ益ಠ"), # 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.xblock.parse_lti_2_0_result_json(json_str) - assert score == 0.1 - assert comment == expected_comment - - GOOD_JSON_PUT = textwrap.dedent(""" - {"@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(""" - {"@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='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': ( - 'OAuth oauth_nonce="135685044251684026041377608307", ' - 'oauth_timestamp="1234567890", oauth_version="1.0", ' - 'oauth_signature_method="HMAC-SHA1", ' - 'oauth_consumer_key="test_client_key", ' - 'oauth_signature="my_signature%3D", ' - 'oauth_body_hash="gz+PeJZuF2//n9hNUnDj2v5kN70="' - ) - } - mock_request.url = 'http://testurl' - mock_request.http_method = method - mock_request.method = method - mock_request.body = body - return mock_request - - def setup_system_xblock_mocks_for_lti20_request_test(self): - """ - Helper fn to set up mocking for lti 2.0 request test - """ - self.xblock.max_score = Mock(return_value=1.0) - self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) - self.xblock.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_xblock_mocks_for_lti20_request_test() - SCORE = 0.55 # pylint: disable=invalid-name - COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name - self.xblock.module_score = SCORE - self.xblock.score_comment = COMMENT - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT_LIKE_DELETE) - # Now call the handler - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - # Now assert there's no score - assert response.status_code == 200 - assert self.xblock.module_score is None - assert self.xblock.score_comment == '' - (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence - assert called_grade_obj ==\ - {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} - assert evt_type == 'grade' - - def test_lti20_delete_success(self): - """ - The happy path for LTI 2.0 DELETE - """ - self.setup_system_xblock_mocks_for_lti20_request_test() - SCORE = 0.55 # pylint: disable=invalid-name - COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name - self.xblock.module_score = SCORE - self.xblock.score_comment = COMMENT - mock_request = self.get_signed_lti20_mock_request(b"", method='DELETE') - # Now call the handler - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - # Now assert there's no score - assert response.status_code == 200 - assert self.xblock.module_score is None - assert self.xblock.score_comment == '' - (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence - assert called_grade_obj ==\ - {'user_id': self.USER_STANDIN.id, 'value': None, 'max_value': None, 'score_deleted': True} - assert 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_xblock_mocks_for_lti20_request_test() - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) - # Now call the handler - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - # Now assert - assert response.status_code == 200 - assert self.xblock.module_score == 0.1 - assert self.xblock.score_comment == 'ಠ益ಠ' - (_, evt_type, called_grade_obj), _ = self.runtime.publish.call_args # pylint: disable=unpacking-non-sequence - assert evt_type == 'grade' - assert called_grade_obj ==\ - {'user_id': self.USER_STANDIN.id, 'value': 0.1, 'max_value': 1.0, 'score_deleted': False} - - def test_lti20_get_no_score_success(self): - """ - The happy path for LTI 2.0 GET when there's no score - """ - self.setup_system_xblock_mocks_for_lti20_request_test() - mock_request = self.get_signed_lti20_mock_request(b"", method='GET') - # Now call the handler - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - # Now assert - assert response.status_code == 200 - assert 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_xblock_mocks_for_lti20_request_test() - SCORE = 0.55 # pylint: disable=invalid-name - COMMENT = "ಠ益ಠ" # pylint: disable=invalid-name - self.xblock.module_score = SCORE - self.xblock.score_comment = COMMENT - mock_request = self.get_signed_lti20_mock_request(b"", method='GET') - # Now call the handler - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - # Now assert - assert response.status_code == 200 - assert 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_xblock_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.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - assert 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_xblock_mocks_for_lti20_request_test() - self.xblock.verify_lti_2_0_result_rest_headers = Mock(side_effect=self.LTIError()) - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - assert 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_xblock_mocks_for_lti20_request_test() - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) - response = self.xblock.lti_2_0_result_rest_handler(mock_request, None) - assert 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_xblock_mocks_for_lti20_request_test() - self.xblock.parse_lti_2_0_result_json = Mock(side_effect=self.LTIError()) - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - assert 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_xblock_mocks_for_lti20_request_test() - self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - assert response.status_code == 404 - - def test_lti20_request_handler_grade_past_due(self): - """ - Test that we get a 404 when accept_grades_past_due is False and it is past due - """ - self.setup_system_xblock_mocks_for_lti20_request_test() - self.xblock.due = datetime.datetime.now(ZoneInfo("UTC")) - self.xblock.accept_grades_past_due = False - mock_request = self.get_signed_lti20_mock_request(self.GOOD_JSON_PUT) - response = self.xblock.lti_2_0_result_rest_handler(mock_request, "user/abcd") - assert response.status_code == 404 - - -@override_settings(USE_EXTRACTED_LTI_BLOCK=True) -class TestLTI20RESTResultServiceWithExtracted(_LTI20RESTResultServiceTestBase): - __test__ = True - - -@override_settings(USE_EXTRACTED_LTI_BLOCK=False) -class TestLTI20RESTResultServiceWithBuiltIn(_LTI20RESTResultServiceTestBase): - __test__ = True diff --git a/xmodule/tests/test_lti_unit.py b/xmodule/tests/test_lti_unit.py deleted file mode 100644 index 0840ffc7d5c1..000000000000 --- a/xmodule/tests/test_lti_unit.py +++ /dev/null @@ -1,563 +0,0 @@ -"""Test for LTI Xmodule functional logic.""" - - -import datetime -import textwrap -from copy import copy -from unittest.mock import Mock, PropertyMock, patch -from urllib import parse -from zoneinfo import ZoneInfo - -import pytest -from django.conf import settings -from django.test import TestCase, override_settings -from lxml import etree -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import BlockUsageLocator -from webob.request import Request -from xblock.field_data import DictFieldData -from xblock.fields import ScopeIds, Timedelta -from xblocks_contrib.lti.lti_2_util import LTIError as ExtractedLTIError - -from common.djangoapps.xblock_django.constants import ATTR_KEY_ANONYMOUS_USER_ID -from xmodule import lti_block -from xmodule.lti_2_util import LTIError as BuiltInLTIError -from xmodule.tests.helpers import StubUserService - -from . import get_test_system - - -@override_settings(LMS_BASE="edx.org") -class _TestLTIBase(TestCase): - """Logic tests for LTI block.""" - - __test__ = False - - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.lti_class = lti_block.reset_class() - if settings.USE_EXTRACTED_LTI_BLOCK: - cls.LTIError = ExtractedLTIError - else: - cls.LTIError = BuiltInLTIError - - def setUp(self): - super().setUp() - self.environ = {'wsgi.url_scheme': 'http', 'REQUEST_METHOD': 'POST'} - self.request_body_xml_template = textwrap.dedent(""" - - - - - V1.0 - {messageIdentifier} - - - - <{action}> - - - {sourcedId} - - - - en-us - {grade} - - - - - - - """) - self.course_id = CourseKey.from_string('org/course/run') - self.runtime = get_test_system(self.course_id) - self.runtime.publish = Mock() - self.runtime._services['rebind_user'] = Mock() # pylint: disable=protected-access - - self.xblock = self.lti_class( - self.runtime, - DictFieldData({}), - ScopeIds(None, None, None, BlockUsageLocator(self.course_id, 'lti', 'name')) - ) - current_user = self.runtime.service(self.xblock, 'user').get_current_user() - self.user_id = current_user.opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) - self.lti_id = self.xblock.lti_id - - self.unquoted_resource_link_id = '{}-i4x-2-3-lti-31de800015cf4afb973356dbe81496df'.format( # noqa: UP032 - settings.LMS_BASE - ) - - sourced_id = ':'.join(parse.quote(i) for i in (self.lti_id, self.unquoted_resource_link_id, self.user_id)) # pylint: disable=line-too-long - - self.defaults = { - 'namespace': "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0", - 'sourcedId': sourced_id, - 'action': 'replaceResultRequest', - 'grade': 0.5, - 'messageIdentifier': '528243ba5241b', - } - - self.xblock.due = None - self.xblock.graceperiod = None - - def get_request_body(self, params=None): - """Fetches the body of a request specified by params""" - if params is None: - params = {} - data = copy(self.defaults) - - data.update(params) - return self.request_body_xml_template.format(**data).encode('utf-8') - - def get_response_values(self, response): - """Gets the values from the given response""" - parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8') - root = etree.fromstring(response.body.strip(), parser=parser) - lti_spec_namespace = "http://www.imsglobal.org/services/ltiv1p1/xsd/imsoms_v1p0" - namespaces = {'def': lti_spec_namespace} - - code_major = root.xpath("//def:imsx_codeMajor", namespaces=namespaces)[0].text - description = root.xpath("//def:imsx_description", namespaces=namespaces)[0].text - message_identifier = root.xpath("//def:imsx_messageIdentifier", namespaces=namespaces)[0].text - imsx_pox_body = root.xpath("//def:imsx_POXBody", namespaces=namespaces)[0] - - try: - action = imsx_pox_body.getchildren()[0].tag.replace('{' + lti_spec_namespace + '}', '') - except Exception: # pylint: disable=broad-except - action = None - - return { - 'code_major': code_major, - 'description': description, - 'messageIdentifier': message_identifier, - 'action': action - } - - @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', - return_value=('test_client_key', 'test_client_secret') - ) - def test_authorization_header_not_present(self, _get_key_secret): # noqa: PT019 - """ - Request has no Authorization header. - - This is an unknown service request, i.e., it is not a part of the original service specification. - """ - request = Request(self.environ) - request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') - real_response = self.get_response_values(response) - expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'OAuth verification error: Malformed authorization header', - 'messageIdentifier': self.defaults['messageIdentifier'], - } - - assert response.status_code == 200 - self.assertDictEqual(expected_response, real_response) # noqa: PT009 - - @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', - return_value=('test_client_key', 'test_client_secret') - ) - def test_authorization_header_empty(self, _get_key_secret): # noqa: PT019 - """ - Request Authorization header has no value. - - This is an unknown service request, i.e., it is not a part of the original service specification. - """ - request = Request(self.environ) - request.authorization = "bad authorization header" - request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') - real_response = self.get_response_values(response) - expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'OAuth verification error: Malformed authorization header', - 'messageIdentifier': self.defaults['messageIdentifier'], - } - assert response.status_code == 200 - self.assertDictEqual(expected_response, real_response) # noqa: PT009 - - def test_real_user_is_none(self): - """ - If we have no real user, we should send back failure response. - """ - self.runtime._services['user'] = StubUserService(user=None) # pylint: disable=protected-access - self.xblock.verify_oauth_body_sign = Mock() - self.xblock.has_score = True - request = Request(self.environ) - request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') - real_response = self.get_response_values(response) - expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'User not found.', - 'messageIdentifier': self.defaults['messageIdentifier'], - } - assert response.status_code == 200 - self.assertDictEqual(expected_response, real_response) # noqa: PT009 - - def test_grade_past_due(self): - """ - Should fail if we do not accept past due grades, and it is past due. - """ - self.xblock.accept_grades_past_due = False - self.xblock.due = datetime.datetime.now(ZoneInfo("UTC")) - self.xblock.graceperiod = Timedelta().from_json("0 seconds") - request = Request(self.environ) - request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') - real_response = self.get_response_values(response) - expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'Grade is past due', - 'messageIdentifier': 'unknown', - } - assert response.status_code == 200 - assert expected_response == real_response - - def test_grade_not_in_range(self): - """ - Grade returned from Tool Provider is outside the range 0.0-1.0. - """ - self.xblock.verify_oauth_body_sign = Mock() - request = Request(self.environ) - request.body = self.get_request_body(params={'grade': '10'}) - response = self.xblock.grade_handler(request, '') - real_response = self.get_response_values(response) - expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': 'Request body XML parsing error: score value outside the permitted range of 0-1.', - 'messageIdentifier': 'unknown', - } - assert response.status_code == 200 - self.assertDictEqual(expected_response, real_response) # noqa: PT009 - - def test_bad_grade_decimal(self): - """ - Grade returned from Tool Provider doesn't use a period as the decimal point. - """ - self.xblock.verify_oauth_body_sign = Mock() - request = Request(self.environ) - request.body = self.get_request_body(params={'grade': '0,5'}) - response = self.xblock.grade_handler(request, '') - real_response = self.get_response_values(response) - msg = "could not convert string to float: '0,5'" - expected_response = { - 'action': None, - 'code_major': 'failure', - 'description': f'Request body XML parsing error: {msg}', - 'messageIdentifier': 'unknown', - } - assert response.status_code == 200 - self.assertDictEqual(expected_response, real_response) # noqa: PT009 - - def test_unsupported_action(self): - """ - Action returned from Tool Provider isn't supported. - `replaceResultRequest` is supported only. - """ - self.xblock.verify_oauth_body_sign = Mock() - request = Request(self.environ) - request.body = self.get_request_body({'action': 'wrongAction'}) - response = self.xblock.grade_handler(request, '') - real_response = self.get_response_values(response) - expected_response = { - 'action': None, - 'code_major': 'unsupported', - 'description': 'Target does not support the requested operation.', - 'messageIdentifier': self.defaults['messageIdentifier'], - } - assert response.status_code == 200 - self.assertDictEqual(expected_response, real_response) # noqa: PT009 - - def test_good_request(self): - """ - Response from Tool Provider is correct. - """ - self.xblock.verify_oauth_body_sign = Mock() - self.xblock.has_score = True - request = Request(self.environ) - request.body = self.get_request_body() - response = self.xblock.grade_handler(request, '') - description_expected = 'Score for {sourcedId} is now {score}'.format( - sourcedId=self.defaults['sourcedId'], - score=self.defaults['grade'], - ) - real_response = self.get_response_values(response) - expected_response = { - 'action': 'replaceResultResponse', - 'code_major': 'success', - 'description': description_expected, - 'messageIdentifier': self.defaults['messageIdentifier'], - } - - assert response.status_code == 200 - self.assertDictEqual(expected_response, real_response) # noqa: PT009 - assert self.xblock.module_score == float(self.defaults['grade']) - - def test_user_id(self): - expected_user_id = str(parse.quote(self.xblock.runtime.anonymous_student_id)) - real_user_id = self.xblock.get_user_id() - assert real_user_id == expected_user_id - - def test_outcome_service_url(self): - 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.xblock.runtime.handler_url = Mock(side_effect=mock_handler_url) - real_outcome_service_url = self.xblock.get_outcome_service_url(service_name=test_service_name) - assert real_outcome_service_url == (mock_url_prefix + test_service_name) - - def test_resource_link_id(self): - with patch('xmodule.lti_block.LTIBlock.usage_key', new_callable=PropertyMock): - self.xblock.usage_key.html_id = lambda: 'i4x-2-3-lti-31de800015cf4afb973356dbe81496df' - expected_resource_link_id = str(parse.quote(self.unquoted_resource_link_id)) - real_resource_link_id = self.xblock.get_resource_link_id() - assert real_resource_link_id == expected_resource_link_id - - def test_lis_result_sourcedid(self): - expected_sourced_id = ':'.join(parse.quote(i) for i in ( - str(self.course_id), - self.xblock.get_resource_link_id(), - self.user_id - )) - real_lis_result_sourcedid = self.xblock.get_lis_result_sourcedid() - assert real_lis_result_sourcedid == expected_sourced_id - - def test_client_key_secret(self): - """ - LTI block gets client key and secret provided. - """ - #this adds lti passports to system - mocked_course = Mock(lti_passports=['lti_id:test_client:test_secret']) - modulestore = Mock() - modulestore.get_course.return_value = mocked_course - runtime = Mock(modulestore=modulestore) - self.xblock.runtime = runtime - self.xblock.lti_id = "lti_id" - key, secret = self.xblock.get_client_key_secret() - expected = ('test_client', 'test_secret') - assert expected == (key, secret) - - def test_client_key_secret_not_provided(self): - """ - LTI block attempts to get client key and secret provided in cms. - - There are key and secret but not for specific LTI. - """ - - # this adds lti passports to system - mocked_course = Mock(lti_passports=['test_id:test_client:test_secret']) - modulestore = Mock() - modulestore.get_course.return_value = mocked_course - runtime = Mock(modulestore=modulestore) - self.xblock.runtime = runtime - # set another lti_id - self.xblock.lti_id = "another_lti_id" - key_secret = self.xblock.get_client_key_secret() - expected = ('', '') - assert expected == key_secret - - def test_bad_client_key_secret(self): - """ - LTI block attempts to get client key and secret provided in cms. - - There are key and secret provided in wrong format. - """ - # this adds lti passports to system - mocked_course = Mock(lti_passports=['test_id_test_client_test_secret']) - modulestore = Mock() - modulestore.get_course.return_value = mocked_course - runtime = Mock(modulestore=modulestore) - self.xblock.runtime = runtime - self.xblock.lti_id = 'lti_id' - with pytest.raises(self.LTIError): - self.xblock.get_client_key_secret() - - @patch('xmodule.lti_block.signature.verify_hmac_sha1', Mock(return_value=True)) - @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', - Mock(return_value=('test_client_key', 'test_client_secret')) - ) - def test_successful_verify_oauth_body_sign(self): - """ - Test if OAuth signing was successful. - """ - self.xblock.verify_oauth_body_sign(self.get_signed_grade_mock_request()) - - @patch('xmodule.lti_block.LTIBlock.get_outcome_service_url', Mock(return_value='https://testurl/')) - @patch('xmodule.lti_block.LTIBlock.get_client_key_secret', - Mock(return_value=('__consumer_key__', '__lti_secret__'))) - def test_failed_verify_oauth_body_sign_proxy_mangle_url(self): - """ - Oauth signing verify fail. - """ - request = self.get_signed_grade_mock_request_with_correct_signature() - self.xblock.verify_oauth_body_sign(request) - # we should verify against get_outcome_service_url not - # request url proxy and load balancer along the way may - # change url presented to the method - request.url = 'http://testurl/' - self.xblock.verify_oauth_body_sign(request) - - def get_signed_grade_mock_request_with_correct_signature(self): - """ - Generate a proper LTI request object - """ - mock_request = Mock() - mock_request.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': ( - 'OAuth realm="https://testurl/", oauth_body_hash="wwzA3s8gScKD1VpJ7jMt9b%2BMj9Q%3D",' - 'oauth_nonce="18821463", oauth_timestamp="1409321145", ' - 'oauth_consumer_key="__consumer_key__", oauth_signature_method="HMAC-SHA1", ' - 'oauth_version="1.0", oauth_signature="fHsE1hhIz76/msUoMR3Lyb7Aou4%3D"' - ) - } - mock_request.url = 'https://testurl' - mock_request.http_method = 'POST' - mock_request.method = mock_request.http_method - - mock_request.body = ( - b'\n' - b'' - b'V1.0' - b'edX_fix' - b'' - b'MITxLTI/MITxLTI/201x:localhost%3A8000-i4x-MITxLTI-MITxLTI-lti-3751833a214a4f66a0d18f63234207f2' - b':363979ef768ca171b50f9d1bfb322131' - b'en0.32' - b'' - ) - - return mock_request - - def test_wrong_xml_namespace(self): - """ - Test wrong XML Namespace. - - Tests that tool provider returned grade back with wrong XML Namespace. - """ - with pytest.raises(IndexError): # noqa: PT012 - mocked_request = self.get_signed_grade_mock_request(namespace_lti_v1p1=False) - self.xblock.parse_grade_xml_body(mocked_request.body) - - def test_parse_grade_xml_body(self): - """ - Test XML request body parsing. - - Tests that xml body was parsed successfully. - """ - mocked_request = self.get_signed_grade_mock_request() - message_identifier, sourced_id, grade, action = self.xblock.parse_grade_xml_body(mocked_request.body) - assert self.defaults['messageIdentifier'] == message_identifier - assert self.defaults['sourcedId'] == sourced_id - assert self.defaults['grade'] == grade - assert self.defaults['action'] == action - - @patch('xmodule.lti_block.signature.verify_hmac_sha1', Mock(return_value=False)) - @patch( - 'xmodule.lti_block.LTIBlock.get_client_key_secret', - Mock(return_value=('test_client_key', 'test_client_secret')) - ) - def test_failed_verify_oauth_body_sign(self): - """ - Oauth signing verify fail. - """ - with pytest.raises(self.LTIError): # noqa: PT012 - req = self.get_signed_grade_mock_request() - self.xblock.verify_oauth_body_sign(req) - - def get_signed_grade_mock_request(self, namespace_lti_v1p1=True): - """ - Example of signed request from LTI Provider. - - When `namespace_v1p0` is set to True then the default namespase from - LTI 1.1 will be used. Otherwise fake namespace will be added to XML. - """ - mock_request = Mock() - mock_request.headers = { - 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Authorization': 'OAuth oauth_nonce="135685044251684026041377608307", \ - oauth_timestamp="1234567890", oauth_version="1.0", \ - oauth_signature_method="HMAC-SHA1", \ - oauth_consumer_key="test_client_key", \ - oauth_signature="my_signature%3D", \ - oauth_body_hash="JEpIArlNCeV4ceXxric8gJQCnBw="' - } - mock_request.url = 'http://testurl' - mock_request.http_method = 'POST' - - params = {} - if not namespace_lti_v1p1: - params = { - 'namespace': "http://www.fakenamespace.com/fake" - } - mock_request.body = self.get_request_body(params) - - return mock_request - - def test_good_custom_params(self): - """ - Custom parameters are presented in right format. - """ - self.xblock.custom_parameters = ['test_custom_params=test_custom_param_value'] - self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) - self.xblock.oauth_params = Mock() - self.xblock.get_input_fields() - self.xblock.oauth_params.assert_called_with( - {'custom_test_custom_params': 'test_custom_param_value'}, - 'test_client_key', 'test_client_secret' - ) - - def test_bad_custom_params(self): - """ - Custom parameters are presented in wrong format. - """ - bad_custom_params = ['test_custom_params: test_custom_param_value'] - self.xblock.custom_parameters = bad_custom_params - self.xblock.get_client_key_secret = Mock(return_value=('test_client_key', 'test_client_secret')) - self.xblock.oauth_params = Mock() - with pytest.raises(self.LTIError): - self.xblock.get_input_fields() - - def test_max_score(self): - self.xblock.weight = 100.0 - - assert not self.xblock.has_score - assert self.xblock.max_score() is None - - self.xblock.has_score = True - - assert self.xblock.max_score() == 100.0 - - def test_context_id(self): - """ - Tests that LTI parameter context_id is equal to course_id. - """ - assert str(self.course_id) == self.xblock.context_id - - -@override_settings(USE_EXTRACTED_LTI_BLOCK=True) -class TestLTIExtracted(_TestLTIBase): - __test__ = True - - -@override_settings(USE_EXTRACTED_LTI_BLOCK=False) -class TestLTIBuiltIn(_TestLTIBase): - __test__ = True