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 _
-%>
-
-
-
-% 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}
-
-
-
- {action}>
-
-
- """)
- 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