diff --git a/README.rst b/README.rst index cc9a2d3396da09ade14de1b25a7d076891053cbe..99248138f03d156047921e27cbc8d417907e9655 100644 --- a/README.rst +++ b/README.rst @@ -367,6 +367,11 @@ Changelog Please See the [releases tab](https://github.com/edx/xblock-lti-consumer/releases) for the complete changelog. +3.3.0 - 2022-01-20 +------------------- + +* Added support for specifying LTI 1.3 JWK URLs. + 3.2.0 - 2022-01-18 ------------------- diff --git a/lti_consumer/__init__.py b/lti_consumer/__init__.py index 0ddc23dac6db7e92c29851b3cf718e24dfd14ef5..06a949dcd7b850dab33052b9fd18d7bcfb814701 100644 --- a/lti_consumer/__init__.py +++ b/lti_consumer/__init__.py @@ -4,4 +4,4 @@ Runtime will load the XBlock class from here. from .lti_xblock import LtiConsumerXBlock from .apps import LTIConsumerApp -__version__ = '3.2.0' +__version__ = '3.3.0' diff --git a/lti_consumer/lti_1p3/key_handlers.py b/lti_consumer/lti_1p3/key_handlers.py index e49193aedfa22244ee5053dfc5c4cbbab206d5b5..6fc689dd0d48d29b3b3ceba6abe044ab230d6493 100644 --- a/lti_consumer/lti_1p3/key_handlers.py +++ b/lti_consumer/lti_1p3/key_handlers.py @@ -68,8 +68,15 @@ class ToolKeyHandler: keyset = [] if self.keyset_url: - # TODO: Improve support for keyset handling, handle errors. - keyset.extend(load_jwks_from_url(self.keyset_url)) + try: + keys = load_jwks_from_url(self.keyset_url) + except Exception as err: + # Broad Exception is required here because jwkest raises + # an Exception object explicitly. + # Beware that many different scenarios are being handled + # as an invalid key when the JWK loading fails. + raise exceptions.NoSuitableKeys() from err + keyset.extend(keys) if self.public_key and kid: # Fill in key id of stored key. diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py index 4ca545f949eb4022db038b450ac27e65898a03d3..8970c2c44ca63baa8be83166061cebb816a34095 100644 --- a/lti_consumer/lti_xblock.py +++ b/lti_consumer/lti_xblock.py @@ -283,6 +283,33 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): "prior to doing the launch request." ), ) + + lti_1p3_tool_key_mode = String( + display_name=_("Tool Public Key Mode"), + scope=Scope.settings, + values=[ + {"display_name": "Public Key", "value": "public_key"}, + {"display_name": "Keyset URL", "value": "keyset_url"}, + ], + default="public_key", + help=_( + "Select how the tool's public key information will be specified." + ), + ) + lti_1p3_tool_keyset_url = String( + display_name=_("Tool Keyset URL"), + default='', + scope=Scope.settings, + help=_( + "Enter the LTI 1.3 Tool's JWK keysets URL." + "<br />This link should retrieve a JSON file containing" + " public keys and signature algorithm information, so" + " that the LMS can check if the messages and launch" + " requests received have the signature from the tool." + "<br /><b>This is not required when doing LTI 1.3 Launches" + " without LTI Advantage nor Basic Outcomes requests.</b>" + ), + ) lti_1p3_tool_public_key = String( display_name=_("Tool Public Key"), multiline_editor=True, @@ -520,7 +547,9 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): editable_field_names = ( 'display_name', 'description', # LTI 1.3 variables - 'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', 'lti_1p3_tool_public_key', 'lti_1p3_enable_nrps', + 'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', + 'lti_1p3_tool_key_mode', 'lti_1p3_tool_keyset_url', 'lti_1p3_tool_public_key', + 'lti_1p3_enable_nrps', # LTI Advantage variables 'lti_advantage_deep_linking_enabled', 'lti_advantage_deep_linking_launch_url', 'lti_advantage_ags_mode', @@ -574,6 +603,12 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): _("Custom Parameters must be a list") ))) + # keyset URL and public key are mutually exclusive + if data.lti_1p3_tool_key_mode == 'keyset_url': + data.lti_1p3_tool_public_key = '' + elif data.lti_1p3_tool_key_mode == 'public_key': + data.lti_1p3_tool_keyset_url = '' + def get_settings(self): """ Get the XBlock settings bucket via the SettingsService. diff --git a/lti_consumer/models.py b/lti_consumer/models.py index a659c929e6bd5cfd5b04583126bd7fc316e89f39..33576a84849b3ca642144c93acbf514e84997624 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -266,7 +266,7 @@ class LtiConfiguration(models.Model): rsa_key_id=self.lti_1p3_private_key_id, # LTI 1.3 Tool key/keyset url tool_key=self.block.lti_1p3_tool_public_key, - tool_keyset_url=None, + tool_keyset_url=self.block.lti_1p3_tool_keyset_url, ) # Check if enabled and setup LTI-AGS diff --git a/lti_consumer/static/js/xblock_studio_view.js b/lti_consumer/static/js/xblock_studio_view.js index 0d1792a8aefd146ad7bde5988493f8184bd2055b..aa78c4c54cf8755393118d55c737d8322bbec122 100644 --- a/lti_consumer/static/js/xblock_studio_view.js +++ b/lti_consumer/static/js/xblock_studio_view.js @@ -14,6 +14,8 @@ function LtiConsumerXBlockInitStudio(runtime, element) { const lti1P3FieldList = [ "lti_1p3_launch_url", "lti_1p3_oidc_url", + "lti_1p3_tool_key_mode", + "lti_1p3_tool_keyset_url", "lti_1p3_tool_public_key", "lti_advantage_ags_mode", "lti_advantage_deep_linking_enabled", @@ -72,12 +74,37 @@ function LtiConsumerXBlockInitStudio(runtime, element) { }); } + /** + * Only display the field appropriate for the selected key mode. + */ + function toggleLtiToolKeyMode() { + const ltiKeyModeField = $(element).find('#xb-field-edit-lti_1p3_tool_key_mode'); + + // find the field containers + const ltiKeysetUrlField = $(element).find('[data-field-name=lti_1p3_tool_keyset_url]'); + const ltiPublicKeyField = $(element).find('[data-field-name=lti_1p3_tool_public_key]'); + + const selectedKeyMode = ltiKeyModeField.children("option:selected").val(); + if (selectedKeyMode === 'public_key') { + ltiKeysetUrlField.hide(); + ltiPublicKeyField.show(); + } else if (selectedKeyMode === 'keyset_url') { + ltiPublicKeyField.hide(); + ltiKeysetUrlField.show(); + } + } + // Call once component is instanced to hide fields toggleLtiFields(); + toggleLtiToolKeyMode(); // Bind to onChange method of lti_version selector $(element).find('#xb-field-edit-lti_version').bind('change', function() { toggleLtiFields(); - }); + }); + // Bind to onChange method of lti_1p3_tool_key_mode selector + $(element).find('#xb-field-edit-lti_1p3_tool_key_mode').bind('change', function() { + toggleLtiToolKeyMode(); + }); } diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py index d9bc4bf93e5b1f08c073e0388832586707fa4d90..1e1a7a758b8c062dbbd6a18d1375151778616845 100644 --- a/lti_consumer/tests/unit/test_lti_xblock.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -4,7 +4,6 @@ Unit tests for LtiConsumerXBlock import json import logging -import urllib.parse from datetime import timedelta from unittest.mock import Mock, NonCallableMock, PropertyMock, patch @@ -13,14 +12,14 @@ from Cryptodome.PublicKey import RSA from django.conf import settings as dj_settings from django.test.testcases import TestCase from django.utils import timezone -from jwkest.jwk import RSAKey +from jwkest.jwk import RSAKey, KEYS from lti_consumer.api import get_lti_1p3_launch_info from lti_consumer.exceptions import LtiError from lti_consumer.lti_1p3.tests.utils import create_jwt from lti_consumer.lti_xblock import LtiConsumerXBlock, parse_handler_suffix from lti_consumer.tests.unit import test_utils -from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_request, make_xblock +from lti_consumer.tests.unit.test_utils import FAKE_USER_ID, make_jwt_request, make_request, make_xblock from lti_consumer.utils import resolve_custom_parameter_template HTML_PROBLEM_PROGRESS = '<div class="problem-progress">' @@ -452,6 +451,8 @@ class TestEditableFields(TestLtiConsumerXBlock): 'lti_version', 'lti_1p3_launch_url', 'lti_1p3_oidc_url', + 'lti_1p3_tool_key_mode', + 'lti_1p3_tool_keyset_url', 'lti_1p3_tool_public_key', 'lti_advantage_deep_linking_enabled', 'lti_advantage_deep_linking_launch_url', @@ -1484,17 +1485,7 @@ class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock): """ Test request with invalid JWT. """ - request = make_request( - urllib.parse.urlencode({ - "grant_type": "client_credentials", - "client_assertion_type": "something", - "client_assertion": "invalid-jwt", - "scope": "", - }), - 'POST' - ) - request.content_type = 'application/x-www-form-urlencoded' - + request = make_jwt_request("invalid-jwt") response = self.xblock.lti_1p3_access_token(request) self.assertEqual(response.status_code, 400) self.assertEqual(response.json_body, {'error': 'invalid_grant'}) @@ -1503,15 +1494,7 @@ class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock): """ Test request with invalid grant. """ - request = make_request( - urllib.parse.urlencode({ - "grant_type": "password", - "client_assertion_type": "something", - "client_assertion": "invalit-jwt", - "scope": "", - }), - 'POST' - ) + request = make_jwt_request("invalid-jwt", grant_type="password") request.content_type = 'application/x-www-form-urlencoded' response = self.xblock.lti_1p3_access_token(request) @@ -1526,17 +1509,7 @@ class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock): self.xblock.save() jwt = create_jwt(self.key, {}) - request = make_request( - urllib.parse.urlencode({ - "grant_type": "client_credentials", - "client_assertion_type": "something", - "client_assertion": jwt, - "scope": "", - }), - 'POST' - ) - request.content_type = 'application/x-www-form-urlencoded' - + request = make_jwt_request(jwt) response = self.xblock.lti_1p3_access_token(request) self.assertEqual(response.status_code, 400) self.assertEqual(response.json_body, {'error': 'invalid_client'}) @@ -1546,17 +1519,7 @@ class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock): Test request with valid JWT. """ jwt = create_jwt(self.key, {}) - request = make_request( - urllib.parse.urlencode({ - "grant_type": "client_credentials", - "client_assertion_type": "something", - "client_assertion": jwt, - "scope": "", - }), - 'POST' - ) - request.content_type = 'application/x-www-form-urlencoded' - + request = make_jwt_request(jwt) response = self.xblock.lti_1p3_access_token(request) self.assertEqual(response.status_code, 200) @@ -1633,3 +1596,87 @@ class TestDynamicCustomParametersResolver(TestLtiConsumerXBlock): self.assertEqual(resolved_value, custom_parameter_template_value) assert mock_log.error.called mock_import_module.asser_not_called() + + +class TestLti1p3AccessTokenJWK(TestCase): + """ + Unit tests for LtiConsumerXBlock Access Token endpoint when using a + LTI 1.3 setup with JWK authentication. + """ + def setUp(self): + super().setUp() + self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, { + 'lti_version': 'lti_1p3', + 'lti_1p3_launch_url': 'http://tool.example/launch', + 'lti_1p3_oidc_url': 'http://tool.example/oidc', + 'lti_1p3_tool_keyset_url': "http://tool.example/keyset", + }) + self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test' + self.xblock.save() + + self.key = RSAKey(key=RSA.generate(2048), kid="1") + + jwt = create_jwt(self.key, {}) + self.request = make_jwt_request(jwt) + + def make_keyset(self, keys): + """ + Builds a keyset object with the given keys. + """ + jwks = KEYS() + jwks._keys = keys # pylint: disable=protected-access + return jwks + + @patch("lti_consumer.lti_1p3.key_handlers.load_jwks_from_url") + def test_access_token_using_keyset_url(self, load_jwks_from_url): + """ + Test request using the provider's keyset URL instead of a public key. + """ + load_jwks_from_url.return_value = self.make_keyset([self.key]) + response = self.xblock.lti_1p3_access_token(self.request) + load_jwks_from_url.assert_called_once_with("http://tool.example/keyset") + self.assertEqual(response.status_code, 200) + + @patch("lti_consumer.lti_1p3.key_handlers.load_jwks_from_url") + def test_access_token_using_keyset_url_with_empty_keys(self, load_jwks_from_url): + """ + Test request where the provider's keyset URL returns an empty list of keys. + """ + load_jwks_from_url.return_value = self.make_keyset([]) + response = self.xblock.lti_1p3_access_token(self.request) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json_body, {"error": "invalid_client"}) + + @patch("lti_consumer.lti_1p3.key_handlers.load_jwks_from_url") + def test_access_token_using_keyset_url_with_wrong_keys(self, load_jwks_from_url): + """ + Test request where the provider's keyset URL returns wrong keys. + """ + key = RSAKey(key=RSA.generate(2048), kid="2") + load_jwks_from_url.return_value = self.make_keyset([key]) + response = self.xblock.lti_1p3_access_token(self.request) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json_body, {"error": "invalid_client"}) + + @patch("jwkest.jwk.request") + def test_access_token_using_keyset_url_that_fails(self, request): + """ + Test request where the provider's keyset URL request fails. + """ + request.side_effect = Exception("request fails") + response = self.xblock.lti_1p3_access_token(self.request) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json_body, {'error': 'invalid_client'}) + + @patch("jwkest.jwk.request") + def test_access_token_using_keyset_url_with_invalid_contents(self, request): + """ + Test request where the provider's keyset URL doesn't return valid JSON. + """ + response_mock = Mock() + response_mock.status_code = 200 + response_mock.text = b'this is not a valid json' + request.return_value = response_mock + response = self.xblock.lti_1p3_access_token(self.request) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json_body, {'error': 'invalid_client'}) diff --git a/lti_consumer/tests/unit/test_utils.py b/lti_consumer/tests/unit/test_utils.py index f45158759c3057a10aa637c5d68475e45078a384..214db164fe6133d2e1408dd370d105823354ed1c 100644 --- a/lti_consumer/tests/unit/test_utils.py +++ b/lti_consumer/tests/unit/test_utils.py @@ -3,6 +3,7 @@ Utility functions used within unit tests """ from unittest.mock import Mock +import urllib from webob import Request from workbench.runtime import WorkbenchRuntime from xblock.fields import ScopeIds @@ -53,6 +54,21 @@ def make_request(body, method='POST'): return request +def make_jwt_request(token, **overrides): + """ + Builds a Request with a JWT body. + """ + body = { + "grant_type": "client_credentials", + "client_assertion_type": "something", + "client_assertion": token, + "scope": "", + } + request = make_request(urllib.parse.urlencode({**body, **overrides}), 'POST') + request.content_type = 'application/x-www-form-urlencoded' + return request + + def dummy_processor(_xblock): """ A dummy LTI parameter processor. diff --git a/lti_consumer/translations/en/LC_MESSAGES/text.po b/lti_consumer/translations/en/LC_MESSAGES/text.po index 2df260212b47f0e5b7a9b5a87838f89bb27c84a6..a4b437a62a3b9e96d661dd20228e836e87a2dbd9 100644 --- a/lti_consumer/translations/en/LC_MESSAGES/text.po +++ b/lti_consumer/translations/en/LC_MESSAGES/text.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-06-07 15:54-0300\n" +"POT-Creation-Date: 2022-01-18 07:25-0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -38,71 +38,92 @@ msgstr "" msgid "Invalid token signature." msgstr "" -#: lti_xblock.py:131 +#: lti_xblock.py:126 msgid "No valid user id found in endpoint URL" msgstr "" -#: lti_xblock.py:237 +#: lti_xblock.py:232 msgid "Display Name" msgstr "" -#: lti_xblock.py:239 +#: lti_xblock.py:234 msgid "" "Enter the name that students see for this component. Analytics reports may " "also use the display name to identify this component." msgstr "" -#: lti_xblock.py:243 +#: lti_xblock.py:238 msgid "LTI Consumer" msgstr "" -#: lti_xblock.py:246 +#: lti_xblock.py:241 msgid "LTI Application Information" msgstr "" -#: lti_xblock.py:248 +#: lti_xblock.py:243 msgid "" "Enter a description of the third party application. If requesting username " "and/or email, use this text box to inform users why their username and/or " "email will be forwarded to a third party application." msgstr "" -#: lti_xblock.py:258 +#: lti_xblock.py:253 msgid "LTI Version" msgstr "" -#: lti_xblock.py:266 +#: lti_xblock.py:261 msgid "" "Select the LTI version that your tool supports.<br />The XBlock LTI Consumer " "fully supports LTI 1.1.1, LTI 1.3 and LTI Advantage features." msgstr "" -#: lti_xblock.py:272 +#: lti_xblock.py:267 msgid "Tool Launch URL" msgstr "" -#: lti_xblock.py:276 +#: lti_xblock.py:271 msgid "" "Enter the LTI 1.3 Tool Launch URL. <br />This is the URL the LMS will use to " "launch the LTI Tool." msgstr "" -#: lti_xblock.py:281 +#: lti_xblock.py:276 msgid "Tool Initiate Login URL" msgstr "" -#: lti_xblock.py:285 +#: lti_xblock.py:280 msgid "" "Enter the LTI 1.3 Tool OIDC Authorization url (can also be called login or " "login initiation URL).<br />This is the URL the LMS will use to start a LTI " "authorization prior to doing the launch request." msgstr "" -#: lti_xblock.py:291 +#: lti_xblock.py:287 +msgid "Tool Public Key Mode" +msgstr "" + +#: lti_xblock.py:295 +msgid "Select how the tool's public key information will be specified." +msgstr "" + +#: lti_xblock.py:299 +msgid "Tool Keyset URL" +msgstr "" + +#: lti_xblock.py:303 +msgid "" +"Enter the LTI 1.3 Tool's JWK keysets URL.<br />This link should retrieve a " +"JSON file containing public keys and signature algorithm information, so " +"that the LMS can check if the messages and launch requests received have the " +"signature from the tool.<br /><b>This is not required when doing LTI 1.3 " +"Launches without LTI Advantage nor Basic Outcomes requests.</b>" +msgstr "" + +#: lti_xblock.py:313 msgid "Tool Public Key" msgstr "" -#: lti_xblock.py:296 +#: lti_xblock.py:318 msgid "" "Enter the LTI 1.3 Tool's public key.<br />This is a string that starts with " "'-----BEGIN PUBLIC KEY-----' and is required so that the LMS can check if " @@ -111,61 +132,61 @@ msgid "" "Advantage nor Basic Outcomes requests.</b>" msgstr "" -#: lti_xblock.py:306 +#: lti_xblock.py:328 msgid "Enable LTI NRPS" msgstr "" -#: lti_xblock.py:307 +#: lti_xblock.py:329 msgid "Enable LTI Names and Role Provisioning Services." msgstr "" -#: lti_xblock.py:314 +#: lti_xblock.py:336 msgid "LTI 1.3 Block Client ID - DEPRECATED" msgstr "" -#: lti_xblock.py:317 +#: lti_xblock.py:339 msgid "DEPRECATED - This is now stored in the LtiConfiguration model." msgstr "" -#: lti_xblock.py:320 +#: lti_xblock.py:342 msgid "LTI 1.3 Block Key - DEPRECATED" msgstr "" -#: lti_xblock.py:327 +#: lti_xblock.py:349 msgid "Deep linking" msgstr "" -#: lti_xblock.py:328 +#: lti_xblock.py:350 msgid "Select True if you want to enable LTI Advantage Deep Linking." msgstr "" -#: lti_xblock.py:333 +#: lti_xblock.py:355 msgid "Deep Linking Launch URL" msgstr "" -#: lti_xblock.py:337 +#: lti_xblock.py:359 msgid "" "Enter the LTI Advantage Deep Linking Launch URL. If the tool does not " "specify one, use the same value as 'Tool Launch URL'." msgstr "" -#: lti_xblock.py:342 +#: lti_xblock.py:364 msgid "LTI Assignment and Grades Service" msgstr "" -#: lti_xblock.py:344 +#: lti_xblock.py:366 msgid "Disabled" msgstr "" -#: lti_xblock.py:345 +#: lti_xblock.py:367 msgid "Allow tools to submit grades only (declarative)" msgstr "" -#: lti_xblock.py:346 +#: lti_xblock.py:368 msgid "Allow tools to manage and submit grade (programmatic)" msgstr "" -#: lti_xblock.py:351 +#: lti_xblock.py:373 msgid "" "Enable the LTI-AGS service and select the functionality enabled for LTI " "tools. The 'declarative' mode (default) will provide a tool with a LineItem " @@ -173,11 +194,11 @@ msgid "" "tools to manage, create and link the grades." msgstr "" -#: lti_xblock.py:359 +#: lti_xblock.py:381 msgid "LTI ID" msgstr "" -#: lti_xblock.py:361 +#: lti_xblock.py:383 #, python-brace-format msgid "" "Enter the LTI ID for the external LTI provider. This value must be the same " @@ -186,11 +207,11 @@ msgid "" "documentation{anchor_close} for more details on this setting." msgstr "" -#: lti_xblock.py:373 +#: lti_xblock.py:395 msgid "LTI URL" msgstr "" -#: lti_xblock.py:375 +#: lti_xblock.py:397 #, python-brace-format msgid "" "Enter the URL of the external tool that this component launches. This " @@ -199,11 +220,11 @@ msgid "" "this setting." msgstr "" -#: lti_xblock.py:388 +#: lti_xblock.py:410 msgid "Custom Parameters" msgstr "" -#: lti_xblock.py:390 +#: lti_xblock.py:412 #, python-brace-format msgid "" "Add the key/value pair for any custom parameters, such as the page your e-" @@ -212,11 +233,11 @@ msgid "" "documentation{anchor_close} for more details on this setting." msgstr "" -#: lti_xblock.py:400 +#: lti_xblock.py:422 msgid "LTI Launch Target" msgstr "" -#: lti_xblock.py:402 +#: lti_xblock.py:424 msgid "" "Select Inline if you want the LTI content to open in an IFrame in the " "current page. Select Modal if you want the LTI content to open in a modal " @@ -225,146 +246,146 @@ msgid "" "Tool is set to False." msgstr "" -#: lti_xblock.py:416 +#: lti_xblock.py:438 msgid "Button Text" msgstr "" -#: lti_xblock.py:418 +#: lti_xblock.py:440 msgid "" "Enter the text on the button used to launch the third party application. " "This setting is only used when Hide External Tool is set to False and LTI " "Launch Target is set to Modal or New Window." msgstr "" -#: lti_xblock.py:426 +#: lti_xblock.py:448 msgid "Inline Height" msgstr "" -#: lti_xblock.py:428 +#: lti_xblock.py:450 msgid "" "Enter the desired pixel height of the iframe which will contain the LTI " "tool. This setting is only used when Hide External Tool is set to False and " "LTI Launch Target is set to Inline." msgstr "" -#: lti_xblock.py:436 +#: lti_xblock.py:458 msgid "Modal Height" msgstr "" -#: lti_xblock.py:438 +#: lti_xblock.py:460 msgid "" "Enter the desired viewport percentage height of the modal overlay which will " "contain the LTI tool. This setting is only used when Hide External Tool is " "set to False and LTI Launch Target is set to Modal." msgstr "" -#: lti_xblock.py:446 +#: lti_xblock.py:468 msgid "Modal Width" msgstr "" -#: lti_xblock.py:448 +#: lti_xblock.py:470 msgid "" "Enter the desired viewport percentage width of the modal overlay which will " "contain the LTI tool. This setting is only used when Hide External Tool is " "set to False and LTI Launch Target is set to Modal." msgstr "" -#: lti_xblock.py:456 +#: lti_xblock.py:478 msgid "Scored" msgstr "" -#: lti_xblock.py:457 +#: lti_xblock.py:479 msgid "" "Select True if this component will receive a numerical score from the " "external LTI system." msgstr "" -#: lti_xblock.py:464 +#: lti_xblock.py:486 msgid "" "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." msgstr "" -#: lti_xblock.py:473 +#: lti_xblock.py:495 msgid "" "The score kept in the xblock KVS -- duplicate of the published score in " "django DB" msgstr "" -#: lti_xblock.py:478 +#: lti_xblock.py:500 msgid "Comment as returned from grader, LTI2.0 spec" msgstr "" -#: lti_xblock.py:483 +#: lti_xblock.py:505 msgid "Hide External Tool" msgstr "" -#: lti_xblock.py:485 +#: lti_xblock.py:507 msgid "" "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." msgstr "" -#: lti_xblock.py:493 +#: lti_xblock.py:515 msgid "Accept grades past deadline" msgstr "" -#: lti_xblock.py:494 +#: lti_xblock.py:516 msgid "" "Select True to allow third party systems to post grades past the deadline." msgstr "" -#: lti_xblock.py:502 +#: lti_xblock.py:524 msgid "Request user's username" msgstr "" #. Translators: This is used to request the user's username for a third party service. -#: lti_xblock.py:504 +#: lti_xblock.py:526 msgid "Select True to request the user's username." msgstr "" -#: lti_xblock.py:509 +#: lti_xblock.py:531 msgid "Request user's email" msgstr "" #. Translators: This is used to request the user's email for a third party service. -#: lti_xblock.py:511 +#: lti_xblock.py:533 msgid "Select True to request the user's email address." msgstr "" -#: lti_xblock.py:516 +#: lti_xblock.py:538 msgid "Send extra parameters" msgstr "" -#: lti_xblock.py:517 +#: lti_xblock.py:539 msgid "" "Select True to send the extra parameters, which might contain Personally " "Identifiable Information. The processors are site-wide, please consult the " "site administrator if you have any questions." msgstr "" -#: lti_xblock.py:578 +#: lti_xblock.py:602 msgid "Custom Parameters must be a list" msgstr "" -#: lti_xblock.py:717 +#: lti_xblock.py:712 msgid "" "Could not parse LTI passport: {lti_passport!r}. Should be \"id:key:secret\" " "string." msgstr "" -#: lti_xblock.py:733 lti_xblock.py:749 +#: lti_xblock.py:728 lti_xblock.py:744 msgid "Could not get user id for current request" msgstr "" -#: lti_xblock.py:854 +#: lti_xblock.py:849 msgid "" "Could not parse custom parameter: {custom_parameter!r}. Should be \"x=y\" " "string." msgstr "" -#: lti_xblock.py:1299 +#: lti_xblock.py:1304 msgid "[LTI]: Real user not found against anon_id: {}" msgstr ""