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