From 4a70705d00087dedf51f97d743a22c876fbcea6e Mon Sep 17 00:00:00 2001 From: Giovanni Cimolin da Silva <giovannicimolin@gmail.com> Date: Thu, 20 Aug 2020 11:47:09 -0300 Subject: [PATCH] Move LTI configuration access to plugin model This change moves the LTI configuration retrieval to a Django model that will LTI configuration. The current model proxies back the to blocks to keep backwards compatibility. See reasoning for change at: https://github.com/edx/xblock-lti-consumer/blob/master/docs/decisions/0001-lti-extensions-plugin.rst --- .coveragerc | 2 +- lti_consumer/admin.py | 17 +++ lti_consumer/api.py | 54 +++++++ lti_consumer/lti_1p3/consumer.py | 15 ++ lti_consumer/lti_1p3/tests/test_consumer.py | 29 ++++ lti_consumer/lti_xblock.py | 54 +++---- lti_consumer/migrations/0001_initial.py | 24 ++++ lti_consumer/migrations/__init__.py | 0 lti_consumer/models.py | 147 ++++++++++++++++++++ lti_consumer/tests/unit/test_api.py | 110 +++++++++++++++ lti_consumer/tests/unit/test_lti_xblock.py | 79 ++++++----- lti_consumer/tests/unit/test_models.py | 91 ++++++++++++ lti_consumer/utils.py | 4 +- requirements/base.in | 1 + requirements/base.txt | 11 +- requirements/constraints.txt | 5 +- requirements/django.txt | 2 +- requirements/pip_tools.txt | 2 +- requirements/test.txt | 21 +-- requirements/tox.txt | 4 +- requirements/travis.txt | 27 ++-- setup.py | 9 +- 22 files changed, 601 insertions(+), 107 deletions(-) create mode 100644 lti_consumer/admin.py create mode 100644 lti_consumer/api.py create mode 100644 lti_consumer/migrations/0001_initial.py create mode 100644 lti_consumer/migrations/__init__.py create mode 100644 lti_consumer/models.py create mode 100644 lti_consumer/tests/unit/test_api.py create mode 100644 lti_consumer/tests/unit/test_models.py diff --git a/.coveragerc b/.coveragerc index 970aebf..942f73e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,4 +2,4 @@ [run] data_file = .coverage source = lti_consumer -omit = */urls.py +omit = */urls.py, *tests* diff --git a/lti_consumer/admin.py b/lti_consumer/admin.py new file mode 100644 index 0000000..d39e206 --- /dev/null +++ b/lti_consumer/admin.py @@ -0,0 +1,17 @@ +""" +Admin views for LTI related models. +""" +from django.contrib import admin +from lti_consumer.models import LtiConfiguration + + +class LtiConfigurationAdmin(admin.ModelAdmin): + """ + Admin view for LtiConfiguration models. + + Makes the location field read-only to avoid issues. + """ + readonly_fields = ('location', ) + + +admin.site.register(LtiConfiguration, LtiConfigurationAdmin) diff --git a/lti_consumer/api.py b/lti_consumer/api.py new file mode 100644 index 0000000..0675ff9 --- /dev/null +++ b/lti_consumer/api.py @@ -0,0 +1,54 @@ +""" +Python APIs used to handle LTI configuration and launches. + +Some methods are meant to be used inside the XBlock, so they +return plaintext to allow easy testing/mocking. +""" +from .models import LtiConfiguration + + +def _get_or_create_local_lti_config(lti_version, block_location): + """ + Retrieves the id of the LTI Configuration for the + block and location, or creates one if it doesn't exist. + + Doesn't take into account the LTI version of the cofiguration, + and updates it accordingly. + Internal method only since it handles + + Returns LTI configuration. + """ + lti_config, _ = LtiConfiguration.objects.get_or_create( + location=block_location, + config_store=LtiConfiguration.CONFIG_ON_XBLOCK, + ) + + if lti_config.version != lti_version: + lti_config.version = lti_version + lti_config.save() + + # Return configuration ID + return lti_config + + +def get_lti_consumer(config_id=None, block=None): + """ + Retrieves an LTI Consumer instance for a given configuration. + + Returns an instance of LtiConsumer1p1 or LtiConsumer1p3 depending + on the configuration. + """ + if config_id: + lti_config = LtiConfiguration.objects.get(pk=config_id) + elif block: + lti_config = _get_or_create_local_lti_config( + block.lti_version, + block.location, + ) + # Since the block was passed, preload it to avoid + # having to instance the modulestore and fetch it again. + lti_config.block = block + + # Return an instance of LTI 1.1 or 1.3 consumer, depending + # on the configuration stored in the model. + return lti_config.get_lti_consumer() diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py index 071cff2..2173bf7 100644 --- a/lti_consumer/lti_1p3/consumer.py +++ b/lti_consumer/lti_1p3/consumer.py @@ -54,6 +54,9 @@ class LtiConsumer1p3: self.lti_claim_context = None self.lti_claim_custom_parameters = None + # Extra claims - used by LTI Advantage + self.extra_claims = {} + @staticmethod def _get_user_roles(role): """ @@ -301,6 +304,10 @@ class LtiConsumer1p3: if self.lti_claim_custom_parameters: lti_message.update(self.lti_claim_custom_parameters) + # Extra claims - From LTI Advantage extensions + if self.extra_claims: + lti_message.update(self.extra_claims) + return { "state": preflight_response.get("state"), "id_token": self.key_handler.encode_and_sign( @@ -420,3 +427,11 @@ class LtiConsumer1p3: ) return True + + def set_extra_claim(self, claim): + """ + Adds an additional claim to the LTI Launch message + """ + if not isinstance(claim, dict): + raise ValueError('Invalid extra claim: is not a dict.') + self.extra_claims.update(claim) diff --git a/lti_consumer/lti_1p3/tests/test_consumer.py b/lti_consumer/lti_1p3/tests/test_consumer.py index 8387411..f7e3fb9 100644 --- a/lti_consumer/lti_1p3/tests/test_consumer.py +++ b/lti_consumer/lti_1p3/tests/test_consumer.py @@ -520,3 +520,32 @@ class TestLti1p3Consumer(TestCase): "scopes": "test" }) self.assertFalse(self.lti_consumer.check_token(token, ['123', ])) + + def test_extra_claim(self): + """ + Check if extra claims are correctly added to the LTI message + """ + self._setup_lti_user() + self.lti_consumer.set_extra_claim({"fake_claim": "test"}) + + # Retrieve launch message + launch_request = self._get_lti_message() + + # Decode and verify message + decoded = self._decode_token(launch_request['id_token']) + self.assertIn( + 'fake_claim', + decoded.keys() + ) + self.assertEqual( + decoded["fake_claim"], + "test" + ) + + @ddt.data("invalid", None, 0) + def test_extra_claim_invalid(self, test_value): + """ + Check if extra claims thrown when passed anything other than dicts. + """ + with self.assertRaises(ValueError): + self.lti_consumer.set_extra_claim(test_value) diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py index 5a7ac34..b2a8dfb 100644 --- a/lti_consumer/lti_xblock.py +++ b/lti_consumer/lti_xblock.py @@ -82,11 +82,9 @@ from .lti_1p3.exceptions import ( UnknownClientId, ) from .lti_1p3.constants import LTI_1P3_CONTEXT_TYPE -from .lti_1p3.consumer import LtiConsumer1p3 from .outcomes import OutcomeService from .utils import ( _, - get_lms_base, get_lms_lti_access_token_link, get_lms_lti_keyset_link, get_lms_lti_launch_link, @@ -649,7 +647,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): """ Return course by course id. """ - return self.runtime.descriptor_runtime.modulestore.get_course(self.course_id) # pylint: disable=no-member + return self.runtime.modulestore.get_course(self.runtime.course_id) # pylint: disable=no-member @property def lti_provider_key_secret(self): @@ -830,40 +828,28 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): close_date = due_date return close_date is not None and timezone.now() > close_date - def _get_lti1p1_consumer(self): + def _get_lti_consumer(self): """ - Returns a preconfigured LTI 1.1 consumer. + Returns a preconfigured LTI consumer depending on the value. If the block is configured to use LTI 1.1, set up a base LTI 1.1 consumer class. - This class does NOT store state between calls. - """ - key, secret = self.lti_provider_key_secret - return LtiConsumer1p1(self.launch_url, key, secret) - - def _get_lti1p3_consumer(self): - """ - Returns a preconfigured LTI 1.3 consumer. If the block is configured to use LTI 1.3, set up a base LTI 1.3 consumer class with all block related configuration services. + This uses the LTI API to fetch the configuration + from the models and instance the LTI client. + This class does NOT store state between calls. """ - return LtiConsumer1p3( - iss=get_lms_base(), - lti_oidc_url=self.lti_1p3_oidc_url, - lti_launch_url=self.lti_1p3_launch_url, - client_id=self.lti_1p3_client_id, - deployment_id="1", - # XBlock Private RSA Key - rsa_key=self.lti_1p3_block_key, - rsa_key_id=self.lti_1p3_client_id, - # LTI 1.3 Tool key/keyset url - tool_key=self.lti_1p3_tool_public_key, - tool_keyset_url=None, - ) + # Runtime import since this will only run in the + # Open edX LMS/Studio environments. + # pylint: disable=import-outside-toplevel + from lti_consumer.api import get_lti_consumer + + return get_lti_consumer(block=self) def extract_real_user_data(self): """ @@ -990,7 +976,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): """ real_user_data = self.extract_real_user_data() - lti_consumer = self._get_lti1p1_consumer() + lti_consumer = self._get_lti_consumer() username = None email = None @@ -1041,7 +1027,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): Returns: webob.response: HTML LTI launch form """ - lti_consumer = self._get_lti1p3_consumer() + lti_consumer = self._get_lti_consumer() context = lti_consumer.prepare_preflight_url( callback_url=get_lms_lti_launch_link(), hint=str(self.location), # pylint: disable=no-member @@ -1068,7 +1054,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): loader = ResourceLoader(__name__) context = {} - lti_consumer = self._get_lti1p3_consumer() + lti_consumer = self._get_lti_consumer() try: # Pass user data @@ -1101,7 +1087,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): context.update({ "preflight_response": dict(request.GET), "launch_request": lti_consumer.generate_launch_request( - resource_link=self.resource_link_id, + resource_link=str(self.location), # pylint: disable=no-member preflight_response=dict(request.GET) ) }) @@ -1120,7 +1106,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): """ if self.lti_version == "lti_1p3": return Response( - json_body=self._get_lti1p3_consumer().get_public_keyset(), + json_body=self._get_lti_consumer().get_public_keyset(), content_type='application/json', content_disposition='attachment; filename=keyset.json' ) @@ -1147,7 +1133,7 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): if request.method != "POST": return Response(status=405) - lti_consumer = self._get_lti1p3_consumer() + lti_consumer = self._get_lti_consumer() try: token = lti_consumer.access_token( dict(urllib.parse.parse_qsl( @@ -1236,14 +1222,14 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock): Returns: webob.response: response to this request. See above for details. """ - lti_consumer = self._get_lti1p1_consumer() + lti_consumer = self._get_lti_consumer() lti_consumer.set_outcome_service_url(self.outcome_service_url) if self.runtime.debug: lti_provider_key, lti_provider_secret = self.lti_provider_key_secret log_authorization_header(request, lti_provider_key, lti_provider_secret) - if not self.accept_grades_past_due and self.is_past_due(): + 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: diff --git a/lti_consumer/migrations/0001_initial.py b/lti_consumer/migrations/0001_initial.py new file mode 100644 index 0000000..21dc5fd --- /dev/null +++ b/lti_consumer/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.15 on 2020-08-26 18:15 + +from django.db import migrations, models +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='LtiConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.CharField(choices=[('lti_1p1', 'LTI 1.1'), ('lti_1p3', 'LTI 1.3 (with LTI Advantage Support)')], default='lti_1p1', max_length=10)), + ('config_store', models.CharField(choices=[('CONFIG_ON_XBLOCK', 'Configuration Stored on XBlock fields')], default='CONFIG_ON_XBLOCK', max_length=255)), + ('location', opaque_keys.edx.django.models.UsageKeyField(blank=True, db_index=True, max_length=255, null=True)), + ], + ), + ] diff --git a/lti_consumer/migrations/__init__.py b/lti_consumer/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lti_consumer/models.py b/lti_consumer/models.py new file mode 100644 index 0000000..85a72a9 --- /dev/null +++ b/lti_consumer/models.py @@ -0,0 +1,147 @@ +""" +LTI configuration and linking models. +""" +from django.db import models + +from opaque_keys.edx.django.models import UsageKeyField + +# LTI 1.1 +from lti_consumer.lti_1p1.consumer import LtiConsumer1p1 +# LTI 1.3 +from lti_consumer.lti_1p3.consumer import LtiConsumer1p3 +from lti_consumer.utils import get_lms_base + + +class LtiConfiguration(models.Model): + """ + Model to store LTI Configuration for LTI 1.1 and 1.3. + + This models stores references (Usage Keys) and returns LTI + configuration data fetching them from XBlock fields. + + With the implementation of + https://github.com/edx/xblock-lti-consumer/blob/master/docs/decisions/0001-lti-extensions-plugin.rst + this model will store all LTI configuration values as a formatted JSON. + + .. no_pii: + """ + # LTI Version + LTI_1P1 = 'lti_1p1' + LTI_1P3 = 'lti_1p3' + LTI_VERSION_CHOICES = [ + (LTI_1P1, 'LTI 1.1'), + (LTI_1P3, 'LTI 1.3 (with LTI Advantage Support)'), + ] + version = models.CharField( + max_length=10, + choices=LTI_VERSION_CHOICES, + default=LTI_1P1, + ) + + # Configuration storage + # Initally, this only supports the configuration + # stored on the block, but should be expanded to + # enable storing LTI configuration in this model. + CONFIG_ON_XBLOCK = 'CONFIG_ON_XBLOCK' + CONFIG_STORE_CHOICES = [ + (CONFIG_ON_XBLOCK, 'Configuration Stored on XBlock fields'), + ] + config_store = models.CharField( + max_length=255, + choices=CONFIG_STORE_CHOICES, + default=CONFIG_ON_XBLOCK, + ) + + # Block location where the configuration is stored. + # In the future, the LTI configuration will be + # stored in this model in a JSON field. + location = UsageKeyField( + max_length=255, + db_index=True, + null=True, + blank=True, + ) + + # Empty variable that'll hold the block once it's retrieved + # from the modulestore or preloaded + _block = None + + @property + def block(self): + """ + Return instance of block (either preloaded or directly from the modulestore). + """ + block = getattr(self, '_block', None) + if block is None: + if self.location is None: + raise ValueError("Block location not set, it's not possible to retrieve the block.") + + # Import on runtime only + # pylint: disable=import-outside-toplevel,import-error + from xmodule.modulestore.django import modulestore + block = self._block = modulestore().get_item(self.location) + return block + + @block.setter + def block(self, block): + """ + Allows preloading the block instead of fetching it from the modulestore. + """ + self._block = block + + def _get_lti_1p1_consumer(self): + """ + Return a class of LTI 1.1 consumer. + """ + # If LTI configuration is stored in the XBlock. + if self.config_store == self.CONFIG_ON_XBLOCK: + key, secret = self.block.lti_provider_key_secret + + return LtiConsumer1p1(self.block.launch_url, key, secret) + + # There's no configuration stored locally, so throw + # NotImplemented. + raise NotImplementedError + + def _get_lti_1p3_consumer(self): + """ + Return a class of LTI 1.3 consumer. + + Uses the `config_store` variable to determine where to + look for the configuration and instance the class. + """ + # If LTI configuration is stored in the XBlock. + if self.config_store == self.CONFIG_ON_XBLOCK: + consumer = LtiConsumer1p3( + iss=get_lms_base(), + lti_oidc_url=self.block.lti_1p3_oidc_url, + lti_launch_url=self.block.lti_1p3_launch_url, + client_id=self.block.lti_1p3_client_id, + # Deployment ID hardcoded to 1 since + # we're not using multi-tenancy. + deployment_id="1", + # XBlock Private RSA Key + rsa_key=self.block.lti_1p3_block_key, + rsa_key_id=self.block.lti_1p3_client_id, + # LTI 1.3 Tool key/keyset url + tool_key=self.block.lti_1p3_tool_public_key, + tool_keyset_url=None, + ) + + return consumer + + # There's no configuration stored locally, so throw + # NotImplemented. + raise NotImplementedError + + def get_lti_consumer(self): + """ + Returns an instanced class of LTI 1.1 or 1.3 consumer. + """ + if self.version == self.LTI_1P3: + return self._get_lti_1p3_consumer() + + return self._get_lti_1p1_consumer() + + def __str__(self): + return "[{}] {} - {}".format(self.config_store, self.version, self.location) diff --git a/lti_consumer/tests/unit/test_api.py b/lti_consumer/tests/unit/test_api.py new file mode 100644 index 0000000..0e23a68 --- /dev/null +++ b/lti_consumer/tests/unit/test_api.py @@ -0,0 +1,110 @@ +""" +Tests for LTI API. +""" +from django.test.testcases import TestCase +from mock import Mock, patch + +from lti_consumer.api import _get_or_create_local_lti_config, get_lti_consumer +from lti_consumer.models import LtiConfiguration + + +class TestGetOrCreateLocalLtiConfiguration(TestCase): + """ + Unit tests for _get_or_create_local_lti_config API method. + """ + def test_create_lti_config_if_inexistent(self): + """ + Check if the API creates a model if no object matching properties is found. + """ + location = 'block-v1:course+test+2020+type@problem+block@test' + lti_version = LtiConfiguration.LTI_1P3 + + # Check that there's nothing in the models + self.assertEqual(LtiConfiguration.objects.all().count(), 0) + + # Call API + lti_config = _get_or_create_local_lti_config( + lti_version=lti_version, + block_location=location + ) + + # Check if the object was created + self.assertEqual(lti_config.version, lti_version) + self.assertEqual(str(lti_config.location), location) + self.assertEqual(lti_config.config_store, LtiConfiguration.CONFIG_ON_XBLOCK) + + def test_retrieve_existing(self): + """ + Check if the API retrieves a model if the configuration matches. + """ + location = 'block-v1:course+test+2020+type@problem+block@test' + lti_version = LtiConfiguration.LTI_1P1 + + lti_config = LtiConfiguration.objects.create( + location=location + ) + + # Call API + lti_config_retrieved = _get_or_create_local_lti_config( + lti_version=lti_version, + block_location=location + ) + + # Check if the object was created + self.assertEqual(LtiConfiguration.objects.all().count(), 1) + self.assertEqual(lti_config_retrieved, lti_config) + + def test_update_lti_version(self): + """ + Check if the API retrieves the config and updates the API version. + """ + location = 'block-v1:course+test+2020+type@problem+block@test' + + lti_config = LtiConfiguration.objects.create( + location=location, + version=LtiConfiguration.LTI_1P1 + ) + + # Call API + _get_or_create_local_lti_config( + lti_version=LtiConfiguration.LTI_1P3, + block_location=location + ) + + # Check if the object was created + lti_config.refresh_from_db() + self.assertEqual(lti_config.version, LtiConfiguration.LTI_1P3) + + +class TestGetLtiConsumer(TestCase): + """ + Unit tests for get_lti_consumer API method. + """ + def test_retrieve_with_block(self): + """ + Check if the API creates a model if no object matching properties is found. + """ + block = Mock() + block.location = 'block-v1:course+test+2020+type@problem+block@test' + block.lti_version = LtiConfiguration.LTI_1P3 + LtiConfiguration.objects.create(location=block.location) + + # Call API + with patch("lti_consumer.models.LtiConfiguration.get_lti_consumer") as mock_get_lti_consumer: + get_lti_consumer(block=block) + mock_get_lti_consumer.assert_called_once() + + # Check that there's just a single LTI Config in the models + self.assertEqual(LtiConfiguration.objects.all().count(), 1) + + def test_retrieve_with_id(self): + """ + Check if the API retrieves a model if the configuration matches. + """ + location = 'block-v1:course+test+2020+type@problem+block@test' + lti_config = LtiConfiguration.objects.create(location=location) + + # Call API + with patch("lti_consumer.models.LtiConfiguration.get_lti_consumer") as mock_get_lti_consumer: + get_lti_consumer(config_id=lti_config.id) + mock_get_lti_consumer.assert_called_once() diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py index 3a6b393..7a7faeb 100644 --- a/lti_consumer/tests/unit/test_lti_xblock.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -148,7 +148,7 @@ class TestProperties(TestLtiConsumerXBlock): """ Test `course` calls modulestore.get_course """ - mock_get_course = self.xblock.runtime.descriptor_runtime.modulestore.get_course + mock_get_course = self.xblock.runtime.modulestore.get_course mock_get_course.return_value = None course = self.xblock.course @@ -428,10 +428,10 @@ class TestEditableFields(TestLtiConsumerXBlock): class TestGetLti1p1Consumer(TestLtiConsumerXBlock): """ - Unit tests for LtiConsumerXBlock._get_lti1p1_consumer() + Unit tests for LtiConsumerXBlock._get_lti_consumer() """ @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.course') - @patch('lti_consumer.lti_xblock.LtiConsumer1p1') + @patch('lti_consumer.models.LtiConsumer1p1') def test_lti_1p1_consumer_created(self, mock_lti_consumer, mock_course): """ Test LtiConsumer1p1 is created with the launch_url, oauth_key, and oauth_secret @@ -440,9 +440,11 @@ class TestGetLti1p1Consumer(TestLtiConsumerXBlock): key = 'test' secret = 'secret' self.xblock.lti_id = provider + self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test' type(mock_course).lti_passports = PropertyMock(return_value=["{}:{}:{}".format(provider, key, secret)]) - self.xblock._get_lti1p1_consumer() # pylint: disable=protected-access + with patch('lti_consumer.models.LtiConfiguration.block', return_value=self.xblock): + self.xblock._get_lti_consumer() # pylint: disable=protected-access mock_lti_consumer.assert_called_with(self.xblock.launch_url, key, secret) @@ -589,7 +591,7 @@ class TestLtiLaunchHandler(TestLtiConsumerXBlock): def setUp(self): super(TestLtiLaunchHandler, self).setUp() self.mock_lti_consumer = Mock(generate_launch_request=Mock(return_value={})) - self.xblock._get_lti1p1_consumer = Mock(return_value=self.mock_lti_consumer) # pylint: disable=protected-access + self.xblock._get_lti_consumer = Mock(return_value=self.mock_lti_consumer) # pylint: disable=protected-access self.xblock.due = timezone.now() self.xblock.graceperiod = timedelta(days=1) self.xblock.runtime.get_real_user = Mock(return_value=None) @@ -644,7 +646,7 @@ class TestResultServiceHandler(TestLtiConsumerXBlock): self.xblock.runtime.get_real_user = Mock() self.xblock.accept_grades_past_due = True self.mock_lti_consumer = Mock() - self.xblock._get_lti1p1_consumer = Mock(return_value=self.mock_lti_consumer) # pylint: disable=protected-access + self.xblock._get_lti_consumer = Mock(return_value=self.mock_lti_consumer) # pylint: disable=protected-access @patch('lti_consumer.lti_xblock.log_authorization_header') @patch('lti_consumer.lti_xblock.LtiConsumerXBlock.lti_provider_key_secret') @@ -1173,11 +1175,18 @@ class TestLtiConsumer1p3XBlock(TestCase): 'lti_1p3_block_key': RSA.generate(2048).export_key('PEM'), } self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes) + # Set dummy location so that UsageKey lookup is valid + self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test' - # pylint: disable=unused-argument - @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") - @patch('lti_consumer.lti_xblock.get_lms_base', return_value="https://example.com") - def test_launch_request(self, mock_url, mock_url_2): + # Patch settings calls to modulestore + self._settings_mock = patch( + 'lti_consumer.utils.settings', + LMS_ROOT_URL="https://example.com" + ) + self.addCleanup(self._settings_mock.stop) + self._settings_mock.start() + + def test_launch_request(self): """ Test LTI 1.3 launch request """ @@ -1190,10 +1199,7 @@ class TestLtiConsumer1p3XBlock(TestCase): response.body.decode('utf-8') ) - # pylint: disable=unused-argument - @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") - @patch('lti_consumer.lti_xblock.get_lms_base', return_value="https://example.com") - def test_launch_callback_endpoint(self, mock_url, mock_url_2): + def test_launch_callback_endpoint(self): """ Test the LTI 1.3 callback endpoint. """ @@ -1224,10 +1230,7 @@ class TestLtiConsumer1p3XBlock(TestCase): self.assertIn("state", response_body) self.assertIn("state_test_123", response_body) - # pylint: disable=unused-argument - @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") - @patch('lti_consumer.lti_xblock.get_lms_base', return_value="https://example.com") - def test_launch_callback_endpoint_fails(self, mock_url, mock_url_2): + def test_launch_callback_endpoint_fails(self): """ Test that the LTI 1.3 callback endpoint correctly display an error message. """ @@ -1258,10 +1261,7 @@ class TestLtiConsumer1p3XBlock(TestCase): response = self.xblock.lti_1p3_launch_callback(make_request('', 'GET')) self.assertEqual(response.status_code, 404) - # pylint: disable=unused-argument - @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") - @patch('lti_consumer.lti_xblock.get_lms_base', return_value="https://example.com") - def test_keyset_endpoint(self, mock_url, mock_url_2): + def test_keyset_endpoint(self): """ Test that the LTI 1.3 keyset endpoind. """ @@ -1280,6 +1280,7 @@ class TestLtiConsumer1p3XBlock(TestCase): response = self.xblock.public_keyset_endpoint(make_request('', 'GET')) self.assertEqual(response.status_code, 404) + # pylint: disable=unused-argument @patch('lti_consumer.lti_xblock.lti_1p3_enabled', return_value=True) def test_studio_view(self, mock_lti_1p3_flag): """ @@ -1319,10 +1320,7 @@ class TestLtiConsumer1p3XBlock(TestCase): } ) - # pylint: disable=unused-argument - @patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") - @patch('lti_consumer.lti_xblock.get_lms_base', return_value="https://example.com") - def test_author_view(self, mock_url, mock_url_2): + def test_author_view(self): """ Test that the studio view loads LTI 1.3 view. """ @@ -1331,10 +1329,7 @@ class TestLtiConsumer1p3XBlock(TestCase): self.assertIn("https://example.com", response.content) -# pylint: disable=unused-argument -@patch('lti_consumer.utils.get_lms_base', return_value="https://example.com") -@patch('lti_consumer.lti_xblock.get_lms_base', return_value="https://example.com") -class TestLti1p3AccessTokenEndpoint(TestCase): +class TestLti1p3AccessTokenEndpoint(TestLtiConsumerXBlock): """ Unit tests for LtiConsumerXBlock Access Token endpoint when using an LTI 1.3. """ @@ -1363,8 +1358,18 @@ class TestLti1p3AccessTokenEndpoint(TestCase): 'lti_1p3_tool_public_key': self.public_key, } self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes) + # Set dummy location so that UsageKey lookup is valid + self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test' + + # Patch settings calls to modulestore + self._settings_mock = patch( + 'lti_consumer.utils.settings', + LMS_ROOT_URL="https://example.com" + ) + self.addCleanup(self._settings_mock.stop) + self._settings_mock.start() - def test_access_token_endpoint_when_using_lti_1p1(self, *args, **kwargs): + def test_access_token_endpoint_when_using_lti_1p1(self): """ Test that the LTI 1.3 access token endpoind is unavailable when using 1.1. """ @@ -1377,7 +1382,7 @@ class TestLti1p3AccessTokenEndpoint(TestCase): response = self.xblock.lti_1p3_access_token(request) self.assertEqual(response.status_code, 404) - def test_access_token_endpoint_no_post(self, *args, **kwargs): + def test_access_token_endpoint_no_post(self): """ Test that the LTI 1.3 access token endpoind is unavailable when using 1.1. """ @@ -1386,7 +1391,7 @@ class TestLti1p3AccessTokenEndpoint(TestCase): response = self.xblock.lti_1p3_access_token(request) self.assertEqual(response.status_code, 405) - def test_access_token_missing_claims(self, *args, **kwargs): + def test_access_token_missing_claims(self): """ Test request with missing parameters. """ @@ -1397,7 +1402,7 @@ class TestLti1p3AccessTokenEndpoint(TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json_body, {'error': 'invalid_request'}) - def test_access_token_malformed(self, *args, **kwargs): + def test_access_token_malformed(self): """ Test request with invalid JWT. """ @@ -1416,7 +1421,7 @@ class TestLti1p3AccessTokenEndpoint(TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json_body, {'error': 'invalid_grant'}) - def test_access_token_invalid_grant(self, *args, **kwargs): + def test_access_token_invalid_grant(self): """ Test request with invalid grant. """ @@ -1435,7 +1440,7 @@ class TestLti1p3AccessTokenEndpoint(TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json_body, {'error': 'unsupported_grant_type'}) - def test_access_token_invalid_client(self, *args, **kwargs): + def test_access_token_invalid_client(self): """ Test request with valid JWT but no matching key to check signature. """ @@ -1458,7 +1463,7 @@ class TestLti1p3AccessTokenEndpoint(TestCase): self.assertEqual(response.status_code, 400) self.assertEqual(response.json_body, {'error': 'invalid_client'}) - def test_access_token(self, *args, **kwargs): + def test_access_token(self): """ Test request with valid JWT. """ diff --git a/lti_consumer/tests/unit/test_models.py b/lti_consumer/tests/unit/test_models.py new file mode 100644 index 0000000..57e1f26 --- /dev/null +++ b/lti_consumer/tests/unit/test_models.py @@ -0,0 +1,91 @@ +""" +Unit tests for LTI models. +""" +from Cryptodome.PublicKey import RSA +from django.test.testcases import TestCase + +from jwkest.jwk import RSAKey +from mock import patch + +from lti_consumer.lti_xblock import LtiConsumerXBlock +from lti_consumer.models import LtiConfiguration +from lti_consumer.tests.unit.test_utils import make_xblock + + +class TestLtiCofigurationModel(TestCase): + """ + Unit tests for LtiConfiguration model methods. + """ + def setUp(self): + super(TestLtiCofigurationModel, self).setUp() + + self.rsa_key_id = "1" + # Generate RSA and save exports + rsa_key = RSA.generate(2048) + self.key = RSAKey( + key=rsa_key, + kid=self.rsa_key_id + ) + self.public_key = rsa_key.publickey().export_key() + + self.xblock_attributes = { + 'lti_version': 'lti_1p3', + 'lti_1p3_launch_url': 'http://tool.example/launch', + 'lti_1p3_oidc_url': 'http://tool.example/oidc', + # We need to set the values below because they are not automatically + # generated until the user selects `lti_version == 'lti_1p3'` on the + # Studio configuration view. + 'lti_1p3_client_id': self.rsa_key_id, + 'lti_1p3_block_key': rsa_key.export_key('PEM'), + # Use same key for tool key to make testing easier + 'lti_1p3_tool_public_key': self.public_key, + } + self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, self.xblock_attributes) + # Set dummy location so that UsageKey lookup is valid + self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test' + + # Patch settings calls to modulestore + self._settings_mock = patch( + 'lti_consumer.utils.settings', + LMS_ROOT_URL="https://example.com" + ) + self.addCleanup(self._settings_mock.stop) + self._settings_mock.start() + + # Creates an LTI configuration objects for testing + self.lti_1p1_config = LtiConfiguration.objects.create( + location=str(self.xblock.location), # pylint: disable=no-member + version=LtiConfiguration.LTI_1P1 + ) + + self.lti_1p3_config = LtiConfiguration.objects.create( + location=str(self.xblock.location), # pylint: disable=no-member + version=LtiConfiguration.LTI_1P3 + ) + + @patch("lti_consumer.models.LtiConfiguration._get_lti_1p3_consumer") + @patch("lti_consumer.models.LtiConfiguration._get_lti_1p1_consumer") + def test_get_lti_consumer(self, lti_1p1_mock, lti_1p3_mock): + """ + Check if the correct LTI consumer is returned. + """ + self.lti_1p1_config.get_lti_consumer() + lti_1p1_mock.assert_called() + + self.lti_1p3_config.get_lti_consumer() + lti_1p3_mock.assert_called() + + def test_repr(self): + """ + Test String representation of model. + """ + dummy_location = 'block-v1:course+test+2020+type@problem+block@test' + lti_config = LtiConfiguration.objects.create( + location=dummy_location, + version=LtiConfiguration.LTI_1P3 + ) + + self.assertEqual( + str(lti_config), + "[CONFIG_ON_XBLOCK] lti_1p3 - {}".format(dummy_location) + ) diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index 0da58e6..320b584 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -16,7 +16,7 @@ def lti_1p3_enabled(): """ Returns `true` if LTI 1.3 integration is enabled for instance. """ - return settings.FEATURES.get('LTI_1P3_ENABLED', False) is True + return settings.FEATURES.get('LTI_1P3_ENABLED', False) is True # pragma: no cover def get_lms_base(): @@ -28,7 +28,7 @@ def get_lms_base(): One possible improvement is to use `contentstore.get_lms_link_for_item` and strip the base domain name. """ - return settings.LMS_ROOT_URL + return settings.LMS_ROOT_URL # pragma: no cover def get_lms_lti_keyset_link(location): diff --git a/requirements/base.in b/requirements/base.in index 4245277..f317ec2 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -11,3 +11,4 @@ XBlock xblock-utils pycryptodomex pyjwkest +edx-opaque-keys[django] \ No newline at end of file diff --git a/requirements/base.txt b/requirements/base.txt index 88ec6d7..f95db9e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,7 +8,8 @@ appdirs==1.4.4 # via fs bleach==3.1.5 # via -r requirements/base.in certifi==2020.6.20 # via requests chardet==3.0.4 # via requests -django==2.2.14 # via -c requirements/constraints.txt, -r requirements/base.in +django==2.2.15 # via -c requirements/constraints.txt, -r requirements/base.in, edx-opaque-keys +edx-opaque-keys[django]==2.1.1 # via -r requirements/base.in fs==2.4.11 # via xblock future==0.18.2 # via pyjwkest idna==2.10 # via requests @@ -18,23 +19,25 @@ mako==1.1.3 # via -r requirements/base.in, xblock-utils markupsafe==1.1.1 # via mako, xblock oauthlib==3.1.0 # via -r requirements/base.in packaging==20.4 # via bleach +pbr==5.4.5 # via stevedore pycryptodomex==3.9.8 # via -r requirements/base.in, pyjwkest pyjwkest==1.4.2 # via -r requirements/base.in +pymongo==3.11.0 # via edx-opaque-keys pyparsing==2.4.7 # via packaging python-dateutil==2.8.1 # via xblock pytz==2020.1 # via django, fs, xblock pyyaml==5.3.1 # via xblock requests==2.24.0 # via pyjwkest simplejson==3.17.2 # via xblock-utils -six==1.15.0 # via bleach, fs, packaging, pyjwkest, python-dateutil, xblock +six==1.15.0 # via bleach, edx-opaque-keys, fs, packaging, pyjwkest, python-dateutil, stevedore, xblock sqlparse==0.3.1 # via django -typing==3.7.4.3 # via fs +stevedore==1.32.0 # via -c requirements/constraints.txt, edx-opaque-keys urllib3==1.25.10 # via requests web-fragments==0.3.2 # via xblock, xblock-utils webencodings==0.5.1 # via bleach webob==1.8.6 # via xblock xblock-utils==2.1.1 # via -r requirements/base.in -xblock==1.3.1 # via -r requirements/base.in, xblock-utils +xblock==1.4.0 # via -r requirements/base.in, xblock-utils # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/constraints.txt b/requirements/constraints.txt index a99a315..95794dd 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -17,4 +17,7 @@ Django<2.3.0 mock<4.0.0 # Zip > 1.2.0 drops support for python 3.5 -zipp<1.2.0 \ No newline at end of file +zipp<1.2.0 + +# Newer versions not available in python 3.5 +stevedore<=1.32.0 diff --git a/requirements/django.txt b/requirements/django.txt index 2082b37..94f4fd9 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==2.2.14 # via -c requirements/constraints.txt, -r requirements/base.txt, django-pyfs, xblock-sdk +django==2.2.15 # via -c requirements/constraints.txt, -r requirements/base.txt, django-pyfs, edx-opaque-keys, xblock-sdk diff --git a/requirements/pip_tools.txt b/requirements/pip_tools.txt index 279019f..0be0c16 100644 --- a/requirements/pip_tools.txt +++ b/requirements/pip_tools.txt @@ -5,7 +5,7 @@ # make upgrade # click==7.1.2 # via pip-tools -pip-tools==5.2.1 # via -r requirements/pip_tools.in +pip-tools==5.3.1 # via -r requirements/pip_tools.in six==1.15.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/test.txt b/requirements/test.txt index e048db8..bb00830 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -7,19 +7,20 @@ appdirs==1.4.4 # via -r requirements/base.txt, fs astroid==2.3.3 # via pylint, pylint-celery bleach==3.1.5 # via -r requirements/base.txt -boto3==1.14.27 # via fs-s3fs -botocore==1.17.27 # via boto3, s3transfer +boto3==1.14.48 # via fs-s3fs +botocore==1.17.48 # via boto3, s3transfer certifi==2020.6.20 # via -r requirements/base.txt, requests chardet==3.0.4 # via -r requirements/base.txt, requests click-log==0.3.2 # via edx-lint click==7.1.2 # via click-log, edx-lint -coverage==5.2 # via coveralls -coveralls==2.1.1 # via -r requirements/test.in +coverage==5.2.1 # via coveralls +coveralls==2.1.2 # via -r requirements/test.in ddt==1.4.1 # via -r requirements/test.in django-pyfs==2.2 # via -r requirements/test.in docopt==0.6.2 # via coveralls docutils==0.15.2 # via botocore -edx-lint==1.5.0 # via -r requirements/test.in +edx-lint==1.5.2 # via -r requirements/test.in +edx-opaque-keys[django]==2.1.1 # via -r requirements/base.txt fs-s3fs==1.1.1 # via django-pyfs fs==2.4.11 # via -r requirements/base.txt, django-pyfs, fs-s3fs, xblock future==0.18.2 # via -r requirements/base.txt, pyjwkest @@ -35,6 +36,7 @@ mccabe==0.6.1 # via pylint mock==3.0.5 # via -c requirements/constraints.txt, -r requirements/test.in oauthlib==3.1.0 # via -r requirements/base.txt packaging==20.4 # via -r requirements/base.txt, bleach +pbr==5.4.5 # via -r requirements/base.txt, stevedore pep8==1.7.1 # via -r requirements/test.in pycryptodomex==3.9.8 # via -r requirements/base.txt, pyjwkest pyjwkest==1.4.2 # via -r requirements/base.txt @@ -42,6 +44,7 @@ pylint-celery==0.3 # via edx-lint pylint-django==2.0.11 # via edx-lint pylint-plugin-utils==0.6 # via pylint-celery, pylint-django pylint==2.4.4 # via edx-lint, pylint-celery, pylint-django, pylint-plugin-utils +pymongo==3.11.0 # via -r requirements/base.txt, edx-opaque-keys pyparsing==2.4.7 # via -r requirements/base.txt, packaging python-dateutil==2.8.1 # via -r requirements/base.txt, botocore, xblock pytz==2020.1 # via -r requirements/base.txt, django, fs, xblock @@ -49,18 +52,18 @@ pyyaml==5.3.1 # via -r requirements/base.txt, xblock requests==2.24.0 # via -r requirements/base.txt, coveralls, pyjwkest s3transfer==0.3.3 # via boto3 simplejson==3.17.2 # via -r requirements/base.txt, xblock-utils -six==1.15.0 # via -r requirements/base.txt, astroid, bleach, edx-lint, fs, fs-s3fs, mock, packaging, pyjwkest, python-dateutil, xblock +six==1.15.0 # via -r requirements/base.txt, astroid, bleach, edx-lint, edx-opaque-keys, fs, fs-s3fs, mock, packaging, pyjwkest, python-dateutil, stevedore, xblock sqlparse==0.3.1 # via -r requirements/base.txt, django +stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/base.txt, edx-opaque-keys typed-ast==1.4.1 # via astroid -typing==3.7.4.3 # via -r requirements/base.txt, fs urllib3==1.25.10 # via -r requirements/base.txt, botocore, requests web-fragments==0.3.2 # via -r requirements/base.txt, xblock, xblock-utils webencodings==0.5.1 # via -r requirements/base.txt, bleach webob==1.8.6 # via -r requirements/base.txt, xblock wrapt==1.11.2 # via astroid -xblock-sdk==0.2.0 # via -r requirements/test.in +xblock-sdk==0.2.2 # via -r requirements/test.in xblock-utils==2.1.1 # via -r requirements/base.txt -xblock==1.3.1 # via -r requirements/base.txt, xblock-utils +xblock==1.4.0 # via -r requirements/base.txt, xblock-utils # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/requirements/tox.txt b/requirements/tox.txt index 3a3993b..90208c4 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -15,6 +15,6 @@ py==1.9.0 # via tox pyparsing==2.4.7 # via packaging six==1.15.0 # via packaging, tox, virtualenv toml==0.10.1 # via tox -tox==3.18.0 # via -r requirements/tox.in -virtualenv==20.0.28 # via tox +tox==3.19.0 # via -r requirements/tox.in +virtualenv==20.0.31 # via tox zipp==1.1.1 # via -c requirements/constraints.txt, importlib-metadata, importlib-resources diff --git a/requirements/travis.txt b/requirements/travis.txt index f8c97e8..faf2752 100644 --- a/requirements/travis.txt +++ b/requirements/travis.txt @@ -7,21 +7,22 @@ appdirs==1.4.4 # via -r requirements/test.txt, -r requirements/tox.txt, fs, virtualenv astroid==2.3.3 # via -r requirements/test.txt, pylint, pylint-celery bleach==3.1.5 # via -r requirements/test.txt -boto3==1.14.27 # via -r requirements/test.txt, fs-s3fs -botocore==1.17.27 # via -r requirements/test.txt, boto3, s3transfer +boto3==1.14.48 # via -r requirements/test.txt, fs-s3fs +botocore==1.17.48 # via -r requirements/test.txt, boto3, s3transfer certifi==2020.6.20 # via -r requirements/test.txt, requests chardet==3.0.4 # via -r requirements/test.txt, requests click-log==0.3.2 # via -r requirements/test.txt, edx-lint click==7.1.2 # via -r requirements/test.txt, click-log, edx-lint -coverage==5.2 # via -r requirements/test.txt, coveralls -coveralls==2.1.1 # via -r requirements/test.txt +coverage==5.2.1 # via -r requirements/test.txt, coveralls +coveralls==2.1.2 # via -r requirements/test.txt ddt==1.4.1 # via -r requirements/test.txt distlib==0.3.1 # via -r requirements/tox.txt, virtualenv django-pyfs==2.2 # via -r requirements/test.txt -django==2.2.14 # via -c requirements/constraints.txt, -r requirements/test.txt, django-pyfs, xblock-sdk +django==2.2.15 # via -c requirements/constraints.txt, -r requirements/test.txt, django-pyfs, edx-opaque-keys, xblock-sdk docopt==0.6.2 # via -r requirements/test.txt, coveralls docutils==0.15.2 # via -r requirements/test.txt, botocore -edx-lint==1.5.0 # via -r requirements/test.txt +edx-lint==1.5.2 # via -r requirements/test.txt +edx-opaque-keys[django]==2.1.1 # via -r requirements/test.txt filelock==3.0.12 # via -r requirements/tox.txt, tox, virtualenv fs-s3fs==1.1.1 # via -r requirements/test.txt, django-pyfs fs==2.4.11 # via -r requirements/test.txt, django-pyfs, fs-s3fs, xblock @@ -40,6 +41,7 @@ mccabe==0.6.1 # via -r requirements/test.txt, pylint mock==3.0.5 # via -c requirements/constraints.txt, -r requirements/test.txt oauthlib==3.1.0 # via -r requirements/test.txt packaging==20.4 # via -r requirements/test.txt, -r requirements/tox.txt, bleach, tox +pbr==5.4.5 # via -r requirements/test.txt, stevedore pep8==1.7.1 # via -r requirements/test.txt pluggy==0.13.1 # via -r requirements/tox.txt, tox py==1.9.0 # via -r requirements/tox.txt, tox @@ -49,6 +51,7 @@ pylint-celery==0.3 # via -r requirements/test.txt, edx-lint pylint-django==2.0.11 # via -r requirements/test.txt, edx-lint pylint-plugin-utils==0.6 # via -r requirements/test.txt, pylint-celery, pylint-django pylint==2.4.4 # via -r requirements/test.txt, edx-lint, pylint-celery, pylint-django, pylint-plugin-utils +pymongo==3.11.0 # via -r requirements/test.txt, edx-opaque-keys pyparsing==2.4.7 # via -r requirements/test.txt, -r requirements/tox.txt, packaging python-dateutil==2.8.1 # via -r requirements/test.txt, botocore, xblock pytz==2020.1 # via -r requirements/test.txt, django, fs, xblock @@ -56,21 +59,21 @@ pyyaml==5.3.1 # via -r requirements/test.txt, xblock requests==2.24.0 # via -r requirements/test.txt, coveralls, pyjwkest s3transfer==0.3.3 # via -r requirements/test.txt, boto3 simplejson==3.17.2 # via -r requirements/test.txt, xblock-utils -six==1.15.0 # via -r requirements/test.txt, -r requirements/tox.txt, astroid, bleach, edx-lint, fs, fs-s3fs, mock, packaging, pyjwkest, python-dateutil, tox, virtualenv, xblock +six==1.15.0 # via -r requirements/test.txt, -r requirements/tox.txt, astroid, bleach, edx-lint, edx-opaque-keys, fs, fs-s3fs, mock, packaging, pyjwkest, python-dateutil, stevedore, tox, virtualenv, xblock sqlparse==0.3.1 # via -r requirements/test.txt, django +stevedore==1.32.0 # via -c requirements/constraints.txt, -r requirements/test.txt, edx-opaque-keys toml==0.10.1 # via -r requirements/tox.txt, tox -tox==3.18.0 # via -r requirements/tox.txt +tox==3.19.0 # via -r requirements/tox.txt typed-ast==1.4.1 # via -r requirements/test.txt, astroid -typing==3.7.4.3 # via -r requirements/test.txt, fs urllib3==1.25.10 # via -r requirements/test.txt, botocore, requests -virtualenv==20.0.28 # via -r requirements/tox.txt, tox +virtualenv==20.0.31 # via -r requirements/tox.txt, tox web-fragments==0.3.2 # via -r requirements/test.txt, xblock, xblock-utils webencodings==0.5.1 # via -r requirements/test.txt, bleach webob==1.8.6 # via -r requirements/test.txt, xblock wrapt==1.11.2 # via -r requirements/test.txt, astroid -xblock-sdk==0.2.0 # via -r requirements/test.txt +xblock-sdk==0.2.2 # via -r requirements/test.txt xblock-utils==2.1.1 # via -r requirements/test.txt -xblock==1.3.1 # via -r requirements/test.txt, xblock-utils +xblock==1.4.0 # via -r requirements/test.txt, xblock-utils zipp==1.1.1 # via -c requirements/constraints.txt, -r requirements/tox.txt, importlib-metadata, importlib-resources # The following packages are considered to be unsafe in a requirements file: diff --git a/setup.py b/setup.py index 9821da5..5d66efb 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ with open('README.rst') as _f: setup( name='lti-consumer-xblock', - version='2.2', + version='2.3', description='This XBlock implements the consumer side of the LTI specification.', long_description=long_description, long_description_content_type='text/markdown', @@ -60,10 +60,13 @@ setup( ], entry_points={ 'xblock.v1': [ - 'lti_consumer = lti_consumer:LtiConsumerXBlock', + 'lti_consumer = lti_consumer.lti_xblock:LtiConsumerXBlock', ], 'lms.djangoapp': [ - "lti_consumer = lti_consumer:LTIConsumerApp", + "lti_consumer = lti_consumer.apps:LTIConsumerApp", + ], + 'cms.djangoapp': [ + "lti_consumer = lti_consumer.apps:LTIConsumerApp", ] }, package_data=package_data("lti_consumer", ["static", "templates", "public", "translations"]), -- GitLab