diff --git a/lti_consumer/admin.py b/lti_consumer/admin.py index d39e2062..a942a889 100644 --- a/lti_consumer/admin.py +++ b/lti_consumer/admin.py @@ -2,7 +2,7 @@ Admin views for LTI related models. """ from django.contrib import admin -from lti_consumer.models import LtiConfiguration +from lti_consumer.models import LtiAgsLineItem, LtiConfiguration class LtiConfigurationAdmin(admin.ModelAdmin): @@ -15,3 +15,4 @@ class LtiConfigurationAdmin(admin.ModelAdmin): admin.site.register(LtiConfiguration, LtiConfigurationAdmin) +admin.site.register(LtiAgsLineItem) diff --git a/lti_consumer/lti_1p3/ags.py b/lti_consumer/lti_1p3/ags.py new file mode 100644 index 00000000..5fbb459b --- /dev/null +++ b/lti_consumer/lti_1p3/ags.py @@ -0,0 +1,70 @@ +""" +LTI Advantage Assignments and Grades service implementation +""" + + +class LtiAgs: + """ + LTI Advantage Consumer + + Implements LTI Advantage Services and ties them in + with the LTI Consumer. This only handles the LTI + message claim inclusion and token handling. + + Available services: + * Assignments and Grades services (partial support) + + Reference: https://www.imsglobal.org/lti-advantage-overview + """ + def __init__( + self, + lineitems_url, + allow_creating_lineitems=True, + results_service_enabled=True, + scores_service_enabled=True + ): + """ + Instance class with LTI AGS Global settings. + """ + # If the platform allows creating lineitems, set this + # to True. + self.allow_creating_lineitems = allow_creating_lineitems + + # Result and scores services + self.results_service_enabled = results_service_enabled + self.scores_service_enabled = scores_service_enabled + + # Lineitems urls + self.lineitems_url = lineitems_url + + def get_available_scopes(self): + """ + Retrieves list of available token scopes in this instance. + """ + scopes = [] + + if self.allow_creating_lineitems: + scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem') + else: + scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly') + + if self.results_service_enabled: + scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly') + + if self.scores_service_enabled: + scopes.append('https://purl.imsglobal.org/spec/lti-ags/scope/score') + + return scopes + + def get_lti_ags_launch_claim(self): + """ + Returns LTI AGS Claim to be injected in the LTI launch message. + """ + ags_claim = { + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { + "scope": self.get_available_scopes(), + "lineitems": self.lineitems_url, + } + } + + return ags_claim diff --git a/lti_consumer/lti_1p3/constants.py b/lti_consumer/lti_1p3/constants.py index de29ef7e..344978d6 100644 --- a/lti_consumer/lti_1p3/constants.py +++ b/lti_consumer/lti_1p3/constants.py @@ -42,7 +42,12 @@ "scope", ]) -LTI_1P3_ACCESS_TOKEN_SCOPES = [] + +LTI_1P3_ACCESS_TOKEN_SCOPES = [ + # LTI-AGS Scopes + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', +] class LTI_1P3_CONTEXT_TYPE(Enum): # pylint: disable=invalid-name diff --git a/lti_consumer/lti_1p3/consumer.py b/lti_consumer/lti_1p3/consumer.py index c5904bd5..6cd2c8ab 100644 --- a/lti_consumer/lti_1p3/consumer.py +++ b/lti_consumer/lti_1p3/consumer.py @@ -12,6 +12,7 @@ LTI_1P3_CONTEXT_TYPE, ) from .key_handlers import ToolKeyHandler, PlatformKeyHandler +from .ags import LtiAgs class LtiConsumer1p3: @@ -436,3 +437,57 @@ def set_extra_claim(self, claim): if not isinstance(claim, dict): raise ValueError('Invalid extra claim: is not a dict.') self.extra_claims.update(claim) + + +class LtiAdvantageConsumer(LtiConsumer1p3): + """ + LTI Advantage Consumer Implementation. + + Builds on top of the LTI 1.3 consumer and adds support for + the following LTI Advantage Services: + + * Assignments and Grades Service (LTI-AGS): Allows tools to + retrieve and send back grades into the platform. + Note: this is a partial implementation with read-only LineItems. + Reference spec: https://www.imsglobal.org/spec/lti-ags/v2p0 + """ + def __init__(self, *args, **kwargs): + """ + Override parent class and set up required LTI Advantage variables. + """ + super(LtiAdvantageConsumer, self).__init__(*args, **kwargs) + + # LTI AGS Variables + self.ags = None + + @property + def lti_ags(self): + """ + Returns LTI AGS class or throw exception if not set up. + """ + if not self.ags: + raise exceptions.LtiAdvantageServiceNotSetUp( + "The LTI AGS service was not set up for this consumer." + ) + + return self.ags + + def enable_ags( + self, + lineitems_url, + ): + """ + Enable LTI Advantage Assignments and Grades Service. + + This will include the LTI AGS Claim in the LTI message + and set up the required class. + """ + self.ags = LtiAgs( + lineitems_url=lineitems_url, + allow_creating_lineitems=True, + results_service_enabled=True, + scores_service_enabled=True + ) + + # Include LTI AGS claim inside the LTI Launch message + self.set_extra_claim(self.ags.get_lti_ags_launch_claim()) diff --git a/lti_consumer/lti_1p3/exceptions.py b/lti_consumer/lti_1p3/exceptions.py index b75a3011..e895567d 100644 --- a/lti_consumer/lti_1p3/exceptions.py +++ b/lti_consumer/lti_1p3/exceptions.py @@ -51,3 +51,7 @@ class RsaKeyNotSet(Lti1p3Exception): class PreflightRequestValidationFailure(Lti1p3Exception): pass + + +class LtiAdvantageServiceNotSetUp(Lti1p3Exception): + pass diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/parsers.py b/lti_consumer/lti_1p3/extensions/rest_framework/parsers.py new file mode 100644 index 00000000..836f3622 --- /dev/null +++ b/lti_consumer/lti_1p3/extensions/rest_framework/parsers.py @@ -0,0 +1,16 @@ +""" +LTI 1.3 Django extensions - Content parsers + +Used by DRF views to render content in LTI APIs. +""" + +from rest_framework import parsers + + +class LineItemParser(parsers.JSONParser): + """ + Line Item Parser. + + It's the same as JSON parser, but uses a custom media_type. + """ + media_type = 'application/vnd.ims.lis.v2.lineitem+json' diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/permissions.py b/lti_consumer/lti_1p3/extensions/rest_framework/permissions.py new file mode 100644 index 00000000..c24bda8a --- /dev/null +++ b/lti_consumer/lti_1p3/extensions/rest_framework/permissions.py @@ -0,0 +1,47 @@ +""" +Django REST Framework extensions for LTI 1.3 & LTI Advantage implementation. + +Implements a custom authorization classes to be used by any of the +LTI Advantage extensions. +""" +from rest_framework import permissions + + +class LtiAgsPermissions(permissions.BasePermission): + """ + LTI AGS Permissions. + + This checks if the token included in the request + has the allowed scopes to read/write LTI AGS items + (LineItems, Results, Score). + + LineItem scopes: https://www.imsglobal.org/spec/lti-ags/v2p0#scope-and-allowed-http-methods + Results: Not implemented yet. + Score: Not implemented yet. + """ + def has_permission(self, request, view): + """ + Check if LTI AGS permissions are set in auth token. + """ + has_perm = False + + # Retrieves token from request, which was already checked by + # the Authentication class, so we assume it's a sane value. + auth_token = request.headers['Authorization'].split()[1] + + if view.action in ['list', 'retrieve']: + # We don't need to wrap this around a try-catch because + # the token was already tested by the Authentication class. + has_perm = request.lti_consumer.check_token( + auth_token, + [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + ], + ) + elif view.action in ['create', 'update', 'partial_update', 'delete']: + has_perm = request.lti_consumer.check_token( + auth_token, + ['https://purl.imsglobal.org/spec/lti-ags/scope/lineitem'] + ) + return has_perm diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/renderers.py b/lti_consumer/lti_1p3/extensions/rest_framework/renderers.py new file mode 100644 index 00000000..db71c631 --- /dev/null +++ b/lti_consumer/lti_1p3/extensions/rest_framework/renderers.py @@ -0,0 +1,28 @@ +""" +LTI 1.3 Django extensions - Content renderers + +Used by DRF views to render content in LTI APIs. +""" +from rest_framework import renderers + + +class LineItemsRenderer(renderers.JSONRenderer): + """ + Line Items Renderer. + + It's a JSON renderer, but uses a custom media_type. + Reference: https://www.imsglobal.org/spec/lti-ags/v2p0#media-types-and-schemas + """ + media_type = 'application/vnd.ims.lis.v2.lineitemcontainer+json' + format = 'json' + + +class LineItemRenderer(renderers.JSONRenderer): + """ + Line Item Renderer. + + It's a JSON renderer, but uses a custom media_type. + Reference: https://www.imsglobal.org/spec/lti-ags/v2p0#media-types-and-schemas + """ + media_type = 'application/vnd.ims.lis.v2.lineitem+json' + format = 'json' diff --git a/lti_consumer/lti_1p3/extensions/rest_framework/serializers.py b/lti_consumer/lti_1p3/extensions/rest_framework/serializers.py new file mode 100644 index 00000000..d1736611 --- /dev/null +++ b/lti_consumer/lti_1p3/extensions/rest_framework/serializers.py @@ -0,0 +1,91 @@ +""" +Serializers for LTI-related endpoints +""" +from rest_framework import serializers +from rest_framework.reverse import reverse +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey + +from lti_consumer.models import LtiAgsLineItem + + +class UsageKeyField(serializers.Field): + """ + Serializer field for a model UsageKey field. + + Recreated here since we cannot import directly from + from the platform like so: + `from openedx.core.lib.api.serializers import UsageKeyField` + """ + # pylint: disable=arguments-differ + def to_representation(self, data): + """ + Convert a usage key to unicode. + """ + return str(data) + + def to_internal_value(self, data): + """ + Convert unicode to a usage key. + """ + try: + return UsageKey.from_string(data) + except InvalidKeyError: + raise serializers.ValidationError("Invalid usage key: {}".format(data)) + + +class LtiAgsLineItemSerializer(serializers.ModelSerializer): + """ + LTI AGS LineItem Serializer. + + This maps out the internally stored LineItemParameters to + the LTI-AGS API Specification, as shown in the example + response below: + + { + "id" : "https://lms.example.com/context/2923/lineitems/1", + "scoreMaximum" : 60, + "label" : "Chapter 5 Test", + "resourceId" : "a-9334df-33", + "tag" : "grade", + "resourceLinkId" : "1g3k4dlk49fk", + "startDateTime": "2018-03-06T20:05:02Z", + "endDateTime": "2018-04-06T22:05:03Z", + } + + Reference: + https://www.imsglobal.org/spec/lti-ags/v2p0#example-application-vnd-ims-lis-v2-lineitem-json-representation + """ + # Id needs to be overriden and be a URL to the LineItem endpoint + id = serializers.SerializerMethodField() + + # Mapping from snake_case to camelCase + resourceId = serializers.CharField(source='resource_id') + scoreMaximum = serializers.IntegerField(source='score_maximum') + resourceLinkId = UsageKeyField(required=False, source='resource_link_id') + startDateTime = serializers.DateTimeField(required=False, source='start_date_time') + endDateTime = serializers.DateTimeField(required=False, source='end_date_time') + + def get_id(self, obj): + request = self.context.get('request') + return reverse( + 'lti_consumer:lti-ags-view-detail', + kwargs={ + 'lti_config_id': obj.lti_configuration.id, + 'pk': obj.pk + }, + request=request, + ) + + class Meta: + model = LtiAgsLineItem + fields = ( + 'id', + 'resourceId', + 'scoreMaximum', + 'label', + 'tag', + 'resourceLinkId', + 'startDateTime', + 'endDateTime', + ) diff --git a/lti_consumer/lti_1p3/tests/extensions/rest_framework/test_permissions.py b/lti_consumer/lti_1p3/tests/extensions/rest_framework/test_permissions.py new file mode 100644 index 00000000..fbe7a5ce --- /dev/null +++ b/lti_consumer/lti_1p3/tests/extensions/rest_framework/test_permissions.py @@ -0,0 +1,184 @@ +""" +Unit tests for LTI 1.3 consumer implementation +""" +from __future__ import absolute_import, unicode_literals + +import ddt +from mock import MagicMock + +from Cryptodome.PublicKey import RSA +from django.test.testcases import TestCase + +from lti_consumer.models import LtiConfiguration +from lti_consumer.lti_1p3.consumer import LtiConsumer1p3 +from lti_consumer.lti_1p3.extensions.rest_framework.permissions import LtiAgsPermissions + + +# Variables required for testing and verification +ISS = "http://test-platform.example/" +OIDC_URL = "http://test-platform/oidc" +LAUNCH_URL = "http://test-platform/launch" +CLIENT_ID = "1" +DEPLOYMENT_ID = "1" +NONCE = "1234" +STATE = "ABCD" +# Consider storing a fixed key +RSA_KEY_ID = "1" +RSA_KEY = RSA.generate(2048).export_key('PEM') + + +@ddt.ddt +class TestLtiAuthentication(TestCase): + """ + Unit tests for Lti1p3ApiAuthentication class + """ + def setUp(self): + super(TestLtiAuthentication, self).setUp() + + # Set up consumer + self.lti_consumer = LtiConsumer1p3( + iss=ISS, + lti_oidc_url=OIDC_URL, + lti_launch_url=LAUNCH_URL, + client_id=CLIENT_ID, + deployment_id=DEPLOYMENT_ID, + rsa_key=RSA_KEY, + rsa_key_id=RSA_KEY_ID, + # Use the same key for testing purposes + tool_key=RSA_KEY + ) + + # Create LTI Configuration + self.lti_configuration = LtiConfiguration.objects.create( + version=LtiConfiguration.LTI_1P3, + ) + + # Create mock request + self.mock_request = MagicMock() + self.mock_request.lti_consumer = self.lti_consumer + + def _make_token(self, scopes): + """ + Return a valid token with the required scopes. + """ + # Generate a valid access token + return self.lti_consumer.key_handler.encode_and_sign( + { + "sub": self.lti_consumer.client_id, + "iss": self.lti_consumer.iss, + "scopes": " ".join(scopes), + }, + expiration=3600 + ) + + @ddt.data( + ["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"], + ["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"], + [ + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + ] + ) + def test_read_only_lineitem_list(self, token_scopes): + """ + Test if LineItem is readable when any of the allowed scopes is + included in the token. + """ + perm_class = LtiAgsPermissions() + mock_view = MagicMock() + + # Make token and include it in the mock request + token = self._make_token(token_scopes) + self.mock_request.headers = { + "Authorization": "Bearer {}".format(token) + } + + # Test list view + mock_view.action = 'list' + self.assertTrue( + perm_class.has_permission(self.mock_request, mock_view), + ) + + # Test retrieve view + mock_view.action = 'retrieve' + self.assertTrue( + perm_class.has_permission(self.mock_request, mock_view), + ) + + def test_lineitem_no_permissions(self): + """ + Test if LineItem is readable when any of the allowed scopes is + included in the token. + """ + perm_class = LtiAgsPermissions() + mock_view = MagicMock() + + # Make token and include it in the mock request + token = self._make_token([]) + self.mock_request.headers = { + "Authorization": "Bearer {}".format(token) + } + + # Test list view + mock_view.action = 'list' + self.assertFalse( + perm_class.has_permission(self.mock_request, mock_view), + ) + + # Test retrieve view + mock_view.action = 'retrieve' + self.assertFalse( + perm_class.has_permission(self.mock_request, mock_view), + ) + + @ddt.data( + (["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly"], False), + (["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"], True), + ( + [ + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly", + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem", + ], + True + ) + ) + @ddt.unpack + def test_lineitem_write_permissions(self, token_scopes, is_allowed): + """ + Test if write operations on LineItem are allowed with the correct token. + """ + perm_class = LtiAgsPermissions() + mock_view = MagicMock() + + # Make token and include it in the mock request + token = self._make_token(token_scopes) + self.mock_request.headers = { + "Authorization": "Bearer {}".format(token) + } + + for action in ['create', 'update', 'partial_update', 'delete']: + # Test list view + mock_view.action = action + self.assertEqual( + perm_class.has_permission(self.mock_request, mock_view), + is_allowed + ) + + def test_unregistered_action_not_allowed(self): + """ + Test unauthorized when trying to post to unregistered action. + """ + perm_class = LtiAgsPermissions() + mock_view = MagicMock() + + # Make token and include it in the mock request + token = self._make_token([]) + self.mock_request.headers = { + "Authorization": "Bearer {}".format(token) + } + + # Test list view + mock_view.action = 'invalid-action' + self.assertFalse( + perm_class.has_permission(self.mock_request, mock_view), + ) diff --git a/lti_consumer/lti_1p3/tests/test_ags.py b/lti_consumer/lti_1p3/tests/test_ags.py new file mode 100644 index 00000000..94f4cd30 --- /dev/null +++ b/lti_consumer/lti_1p3/tests/test_ags.py @@ -0,0 +1,72 @@ +""" +Unit tests for LTI 1.3 consumer implementation +""" +from __future__ import absolute_import, unicode_literals + +from django.test.testcases import TestCase + +from lti_consumer.lti_1p3.ags import LtiAgs + + +class TestLtiAgs(TestCase): + """ + Unit tests for LtiAgs class + """ + def test_instance_ags_no_permissions(self): + """ + Test enabling LTI AGS with no permissions. + """ + ags = LtiAgs( + "http://example.com/lineitem", + allow_creating_lineitems=False, + results_service_enabled=False, + scores_service_enabled=False + ) + scopes = ags.get_available_scopes() + + # Disabling all permissions will only allow the tool to + # list and retrieve LineItems + self.assertEqual( + scopes, + ['https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly'], + ) + + def test_instance_ags_all_permissions(self): + """ + Test enabling LTI AGS with all permissions. + """ + ags = LtiAgs( + "http://example.com/lineitem", + allow_creating_lineitems=True, + results_service_enabled=True, + scores_service_enabled=True + ) + scopes = ags.get_available_scopes() + + # Check available scopes + self.assertIn('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', scopes) + self.assertIn('https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', scopes) + self.assertIn('https://purl.imsglobal.org/spec/lti-ags/scope/score', scopes) + + def test_get_lti_ags_launch_claim(self): + """ + Test if the launch claim is properly formed + """ + ags = LtiAgs( + "http://example.com/lineitem", + allow_creating_lineitems=False, + results_service_enabled=False, + scores_service_enabled=False + ) + + self.assertEqual( + ags.get_lti_ags_launch_claim(), + { + "https://purl.imsglobal.org/spec/lti-ags/claim/endpoint": { + "scope": [ + "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly" + ], + "lineitems": "http://example.com/lineitem", + } + } + ) diff --git a/lti_consumer/lti_1p3/tests/test_consumer.py b/lti_consumer/lti_1p3/tests/test_consumer.py index 9ffc157b..dfae8e16 100644 --- a/lti_consumer/lti_1p3/tests/test_consumer.py +++ b/lti_consumer/lti_1p3/tests/test_consumer.py @@ -15,7 +15,8 @@ from jwkest.jws import JWS from lti_consumer.lti_1p3.constants import LTI_1P3_CONTEXT_TYPE -from lti_consumer.lti_1p3.consumer import LtiConsumer1p3 +from lti_consumer.lti_1p3.consumer import LtiConsumer1p3, LtiAdvantageConsumer +from lti_consumer.lti_1p3.ags import LtiAgs from lti_consumer.lti_1p3 import exceptions @@ -548,3 +549,109 @@ def test_extra_claim_invalid(self, test_value): """ with self.assertRaises(ValueError): self.lti_consumer.set_extra_claim(test_value) + + +@ddt.ddt +class TestLtiAdvantageConsumer(TestCase): + """ + Unit tests for LtiAdvantageConsumer + """ + def setUp(self): + super(TestLtiAdvantageConsumer, self).setUp() + + # Set up consumer + self.lti_consumer = LtiAdvantageConsumer( + iss=ISS, + lti_oidc_url=OIDC_URL, + lti_launch_url=LAUNCH_URL, + client_id=CLIENT_ID, + deployment_id=DEPLOYMENT_ID, + rsa_key=RSA_KEY, + rsa_key_id=RSA_KEY_ID, + # Use the same key for testing purposes + tool_key=RSA_KEY + ) + + def _setup_lti_user(self): + """ + Set up a minimal LTI message with only required parameters. + + Currently, the only required parameters are the user data, + but using a helper function to keep the usage consistent accross + all tests. + """ + self.lti_consumer.set_user_data( + user_id="1", + role="student", + ) + + def _get_lti_message( + self, + preflight_response=None, + resource_link="link" + ): + """ + Retrieves a base LTI message with fixed test parameters. + + This function has valid default values, so it can be used to test custom + parameters, but allows overriding them. + """ + if preflight_response is None: + preflight_response = { + "client_id": CLIENT_ID, + "redirect_uri": LAUNCH_URL, + "nonce": NONCE, + "state": STATE + } + + return self.lti_consumer.generate_launch_request( + preflight_response, + resource_link + ) + + def _decode_token(self, token): + """ + Checks for a valid signarute and decodes JWT signed LTI message + + This also tests the public keyset function. + """ + public_keyset = self.lti_consumer.get_public_keyset() + key_set = load_jwks(json.dumps(public_keyset)) + + return JWS().verify_compact(token, keys=key_set) + + def test_no_ags_returns_failure(self): + """ + Test that when LTI-AGS isn't configured, the class yields an error. + """ + with self.assertRaises(exceptions.LtiAdvantageServiceNotSetUp): + self.lti_consumer.lti_ags # pylint: disable=pointless-statement + + def test_enable_ags(self): + """ + Test enabling LTI AGS and checking that required parameters are set. + """ + self.lti_consumer.enable_ags("http://example.com/lineitems") + + # Check that the AGS class was properly instanced and set + self.assertEqual(type(self.lti_consumer.ags), LtiAgs) + + # Check retrieving class works + lti_ags_class = self.lti_consumer.lti_ags + self.assertEqual(self.lti_consumer.ags, lti_ags_class) + + # Check that enabling the AGS adds the LTI AGS claim + # in the launch message + self.assertEqual( + self.lti_consumer.extra_claims, + { + 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': { + 'scope': [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score' + ], + 'lineitems': 'http://example.com/lineitems' + } + } + ) diff --git a/lti_consumer/migrations/0002_ltiagslineitem.py b/lti_consumer/migrations/0002_ltiagslineitem.py new file mode 100644 index 00000000..42391398 --- /dev/null +++ b/lti_consumer/migrations/0002_ltiagslineitem.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2020-09-29 21:48 + +from django.db import migrations, models +import django.db.models.deletion +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lti_consumer', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='LtiAgsLineItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('resource_id', models.CharField(blank=True, max_length=100)), + ('resource_link_id', opaque_keys.edx.django.models.UsageKeyField(blank=True, db_index=True, max_length=255, null=True)), + ('label', models.CharField(max_length=100)), + ('score_maximum', models.IntegerField()), + ('tag', models.CharField(blank=True, max_length=50)), + ('start_date_time', models.DateTimeField(blank=True, null=True)), + ('end_date_time', models.DateTimeField(blank=True, null=True)), + ('lti_configuration', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='lti_consumer.LtiConfiguration')), + ], + ), + ] diff --git a/lti_consumer/models.py b/lti_consumer/models.py index 85a72a91..882b7239 100644 --- a/lti_consumer/models.py +++ b/lti_consumer/models.py @@ -8,8 +8,8 @@ # 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 +from lti_consumer.lti_1p3.consumer import LtiAdvantageConsumer +from lti_consumer.utils import get_lms_base, get_lti_ags_lineitems_url class LtiConfiguration(models.Model): @@ -112,7 +112,7 @@ def _get_lti_1p3_consumer(self): """ # If LTI configuration is stored in the XBlock. if self.config_store == self.CONFIG_ON_XBLOCK: - consumer = LtiConsumer1p3( + consumer = LtiAdvantageConsumer( iss=get_lms_base(), lti_oidc_url=self.block.lti_1p3_oidc_url, lti_launch_url=self.block.lti_1p3_launch_url, @@ -128,6 +128,12 @@ def _get_lti_1p3_consumer(self): tool_keyset_url=None, ) + # Check if enabled and setup LTI-AGS + if self.block.has_score: + consumer.enable_ags( + lineitems_url=get_lti_ags_lineitems_url(self.id) + ) + return consumer # There's no configuration stored locally, so throw @@ -145,3 +151,56 @@ def get_lti_consumer(self): def __str__(self): return "[{}] {} - {}".format(self.config_store, self.version, self.location) + + +class LtiAgsLineItem(models.Model): + """ + Model to store LineItem data for LTI Assignments and Grades service. + + LTI-AGS Specification: https://www.imsglobal.org/spec/lti-ags/v2p0 + The platform MUST NOT modify the 'resourceId', 'resourceLinkId' and 'tag' values. + + Note: When implementing multi-tenancy support, this needs to be changed + and be tied to a deployment ID, because each deployment should isolate + it's resources. + + .. no_pii: + """ + # LTI Configuration link + # This ties the LineItem to each tool configuration + # and allows easily retrieving LTI credentials for + # API authentication. + lti_configuration = models.ForeignKey( + LtiConfiguration, + on_delete=models.CASCADE, + null=True, + blank=True + ) + + # Tool resource identifier, not used by the LMS. + resource_id = models.CharField(max_length=100, blank=True) + + # LMS Resource link + # Must be the same as the one sent in the tool's LTI launch. + # Each LineItem created by a tool should be specific to the + # context from which it was created. + # Currently it maps to a block using a usagekey + resource_link_id = UsageKeyField( + max_length=255, + db_index=True, + null=True, + blank=True, + ) + + # Other LineItem attributes + label = models.CharField(max_length=100) + score_maximum = models.IntegerField() + tag = models.CharField(max_length=50, blank=True) + start_date_time = models.DateTimeField(blank=True, null=True) + end_date_time = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return "{} - {}".format( + self.resource_link_id, + self.label, + ) diff --git a/lti_consumer/plugin/urls.py b/lti_consumer/plugin/urls.py index 30ea5973..1cf1d7f5 100644 --- a/lti_consumer/plugin/urls.py +++ b/lti_consumer/plugin/urls.py @@ -12,14 +12,18 @@ from lti_consumer.plugin.views import ( public_keyset_endpoint, launch_gate_endpoint, - access_token_endpoint + access_token_endpoint, + # LTI Advantage URLs + LtiAgsLineItemViewset, ) # LTI 1.3 APIs router router = routers.SimpleRouter(trailing_slash=False) +router.register(r'lti-ags', LtiAgsLineItemViewset, basename='lti-ags-view') +app_name = 'lti_consumer' urlpatterns = [ url( 'lti_consumer/v1/public_keysets/{}$'.format(settings.USAGE_ID_PATTERN), diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py index 78174a50..a90febb1 100644 --- a/lti_consumer/plugin/views.py +++ b/lti_consumer/plugin/views.py @@ -1,12 +1,19 @@ """ LTI consumer plugin passthrough views """ - from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods - +from django_filters.rest_framework import DjangoFilterBackend from opaque_keys.edx.keys import UsageKey +from rest_framework import viewsets + +from lti_consumer.models import LtiAgsLineItem +from lti_consumer.lti_1p3.extensions.rest_framework.serializers import LtiAgsLineItemSerializer +from lti_consumer.lti_1p3.extensions.rest_framework.permissions import LtiAgsPermissions +from lti_consumer.lti_1p3.extensions.rest_framework.authentication import Lti1p3ApiAuthentication +from lti_consumer.lti_1p3.extensions.rest_framework.renderers import LineItemsRenderer, LineItemRenderer +from lti_consumer.lti_1p3.extensions.rest_framework.parsers import LineItemParser from lti_consumer.plugin.compat import ( run_xblock_handler, run_xblock_handler_noauth, @@ -77,3 +84,49 @@ def access_token_endpoint(request, usage_id=None): ) except Exception: # pylint: disable=broad-except return HttpResponse(status=404) + + +class LtiAgsLineItemViewset(viewsets.ModelViewSet): + """ + LineItem endpoint implementation from LTI Advantage. + + See full documentation at: + https://www.imsglobal.org/spec/lti-ags/v2p0#line-item-service + """ + serializer_class = LtiAgsLineItemSerializer + pagination_class = None + + # Custom permission classes for LTI APIs + authentication_classes = [Lti1p3ApiAuthentication] + permission_classes = [LtiAgsPermissions] + + # Renderer/parser classes to accept LTI AGS content types + renderer_classes = [ + LineItemsRenderer, + LineItemRenderer, + ] + parser_classes = [LineItemParser] + + # Filters + filter_backends = [DjangoFilterBackend] + filterset_fields = [ + 'resource_link_id', + 'resource_id', + 'tag' + ] + + def get_queryset(self): + lti_configuration = self.request.lti_configuration + + # Return all LineItems related to the LTI configuration. + # TODO: + # Note that each configuration currently maps 1:1 + # to each resource link (block), and this filter needs + # improved once we start reusing LTI configurations. + return LtiAgsLineItem.objects.filter( + lti_configuration=lti_configuration + ) + + def perform_create(self, serializer): + lti_configuration = self.request.lti_configuration + serializer.save(lti_configuration=lti_configuration) diff --git a/lti_consumer/tests/unit/plugin/test_views_lti_ags.py b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py new file mode 100644 index 00000000..20bbc4ac --- /dev/null +++ b/lti_consumer/tests/unit/plugin/test_views_lti_ags.py @@ -0,0 +1,276 @@ +""" +Tests for LTI Advantage Assignments and Grades Service views. +""" +import json +from mock import patch, PropertyMock + +from Cryptodome.PublicKey import RSA +import ddt +from django.urls import reverse +from jwkest.jwk import RSAKey +from rest_framework.test import APITransactionTestCase + + +from lti_consumer.lti_xblock import LtiConsumerXBlock +from lti_consumer.models import LtiConfiguration, LtiAgsLineItem +from lti_consumer.tests.unit.test_utils import make_xblock + + +@ddt.ddt +class TestLtiAgsLineItemViewSet(APITransactionTestCase): + """ + Test `LtiAgsLineItemViewset` method. + """ + def setUp(self): + super(TestLtiAgsLineItemViewSet, self).setUp() + + # Create custom LTI Block + self.rsa_key_id = "1" + 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'), + # Intentionally using the same key for tool key to + # allow using signing methods and 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' + + # Create configuration + self.lti_config = LtiConfiguration.objects.create( + location=str(self.xblock.location), + version=LtiConfiguration.LTI_1P3 + ) + # Preload XBlock to avoid calls to modulestore + self.lti_config.block = self.xblock + + # Patch internal method to avoid calls to modulestore + patcher = patch( + 'lti_consumer.models.LtiConfiguration.block', + new_callable=PropertyMock, + return_value=self.xblock + ) + self.addCleanup(patcher.stop) + self._lti_block_patch = patcher.start() + + # LineItem endpoint + self.lineitem_endpoint = reverse( + 'lti_consumer:lti-ags-view-list', + kwargs={ + "lti_config_id": self.lti_config.id + } + ) + + def _set_lti_token(self, scopes=None): + """ + Generates and sets a LTI Auth token in the request client. + """ + if not scopes: + scopes = '' + + consumer = self.lti_config.get_lti_consumer() + token = consumer.key_handler.encode_and_sign({ + "iss": "https://example.com", + "scopes": scopes, + }) + # pylint: disable=no-member + self.client.credentials( + HTTP_AUTHORIZATION="Bearer {}".format(token) + ) + + def test_lti_ags_view_no_token(self): + """ + Test the LTI AGS list view when there's no token. + """ + response = self.client.get(self.lineitem_endpoint) + self.assertEqual(response.status_code, 403) + + @ddt.data("Bearer invalid-token", "test", "Token with more items") + def test_lti_ags_view_invalid_token(self, authorization): + """ + Test the LTI AGS list view when there's an invalid token. + """ + self.client.credentials(HTTP_AUTHORIZATION=authorization) # pylint: disable=no-member + response = self.client.get(self.lineitem_endpoint) + + self.assertEqual(response.status_code, 403) + + def test_lti_ags_token_missing_scopes(self): + """ + Test the LTI AGS list view when there's a valid token without valid scopes. + """ + self._set_lti_token() + response = self.client.get(self.lineitem_endpoint) + self.assertEqual(response.status_code, 403) + + @ddt.data( + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem' + ) + def test_lti_ags_list_permissions(self, scopes): + """ + Test the LTI AGS list view when there's token valid scopes. + """ + self._set_lti_token(scopes) + # Test with no LineItems + response = self.client.get(self.lineitem_endpoint) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, []) + + def test_lti_ags_list(self): + """ + Test the LTI AGS list. + """ + self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly') + + # Create LineItem + line_item = LtiAgsLineItem.objects.create( + lti_configuration=self.lti_config, + resource_id="test", + resource_link_id=self.xblock.location, + label="test label", + score_maximum=100 + ) + + # Retrieve & check + response = self.client.get(self.lineitem_endpoint) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['content-type'], 'application/vnd.ims.lis.v2.lineitemcontainer+json') + self.assertEqual( + response.data, + [ + { + 'id': 'http://testserver/lti_consumer/v1/lti/{}/lti-ags/{}'.format( + self.lti_config.id, + line_item.id + ), + 'resourceId': 'test', + 'scoreMaximum': 100, + 'label': 'test label', + 'tag': '', + 'resourceLinkId': self.xblock.location, + 'startDateTime': None, + 'endDateTime': None, + } + ] + ) + + def test_lti_ags_retrieve(self): + """ + Test the LTI AGS retrieve endpoint. + """ + self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly') + + # Create LineItem + line_item = LtiAgsLineItem.objects.create( + lti_configuration=self.lti_config, + resource_id="test", + resource_link_id=self.xblock.location, + label="test label", + score_maximum=100 + ) + + # Retrieve & check + lineitem_detail_url = reverse( + 'lti_consumer:lti-ags-view-detail', + kwargs={ + "lti_config_id": self.lti_config.id, + "pk": line_item.id + } + ) + response = self.client.get(lineitem_detail_url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + { + 'id': 'http://testserver/lti_consumer/v1/lti/{}/lti-ags/{}'.format( + self.lti_config.id, + line_item.id + ), + 'resourceId': 'test', + 'scoreMaximum': 100, + 'label': 'test label', + 'tag': '', + 'resourceLinkId': self.xblock.location, + 'startDateTime': None, + 'endDateTime': None, + } + ) + + def test_create_lineitem(self): + """ + Test the LTI AGS LineItem Creation. + """ + self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem') + + # Create LineItem + response = self.client.post( + self.lineitem_endpoint, + data=json.dumps({ + 'resourceId': 'test', + 'scoreMaximum': 100, + 'label': 'test', + 'tag': 'score', + 'resourceLinkId': self.xblock.location, + }), + content_type="application/vnd.ims.lis.v2.lineitem+json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + response.data, + { + 'id': 'http://testserver/lti_consumer/v1/lti/1/lti-ags/1', + 'resourceId': 'test', + 'scoreMaximum': 100, + 'label': 'test', + 'tag': 'score', + 'resourceLinkId': self.xblock.location, + 'startDateTime': None, + 'endDateTime': None, + } + ) + self.assertEqual(LtiAgsLineItem.objects.all().count(), 1) + line_item = LtiAgsLineItem.objects.get() + self.assertEqual(line_item.resource_id, 'test') + self.assertEqual(line_item.score_maximum, 100) + self.assertEqual(line_item.label, 'test') + self.assertEqual(line_item.tag, 'score') + self.assertEqual(str(line_item.resource_link_id), self.xblock.location) + + def test_create_lineitem_invalid_resource_link_id(self): + """ + Test the LTI AGS Lineitem creation when passing invalid resource link id. + """ + self._set_lti_token('https://purl.imsglobal.org/spec/lti-ags/scope/lineitem') + + # Create LineItem + response = self.client.post( + self.lineitem_endpoint, + data=json.dumps({ + 'resourceId': 'test', + 'scoreMaximum': 100, + 'label': 'test', + 'tag': 'score', + 'resourceLinkId': 'invalid-resource-link', + }), + content_type="application/vnd.ims.lis.v2.lineitem+json", + ) + + self.assertEqual(response.status_code, 400) diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py index 69d9ce7b..812ed7c2 100644 --- a/lti_consumer/tests/unit/test_lti_xblock.py +++ b/lti_consumer/tests/unit/test_lti_xblock.py @@ -1193,14 +1193,6 @@ def setUp(self): # 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_launch_request(self): """ Test LTI 1.3 launch request @@ -1376,14 +1368,6 @@ def setUp(self): # 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): """ Test that the LTI 1.3 access token endpoind is unavailable when using 1.1. diff --git a/lti_consumer/tests/unit/test_models.py b/lti_consumer/tests/unit/test_models.py index fa74094e..e18076b6 100644 --- a/lti_consumer/tests/unit/test_models.py +++ b/lti_consumer/tests/unit/test_models.py @@ -8,7 +8,7 @@ from mock import patch from lti_consumer.lti_xblock import LtiConsumerXBlock -from lti_consumer.models import LtiConfiguration +from lti_consumer.models import LtiAgsLineItem, LtiConfiguration from lti_consumer.tests.unit.test_utils import make_xblock @@ -39,19 +39,12 @@ def setUp(self): '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, + 'has_score': True, } 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), @@ -89,3 +82,54 @@ def test_repr(self): str(lti_config), "[CONFIG_ON_XBLOCK] lti_1p3 - {}".format(dummy_location) ) + + def test_lti_consumer_ags_enabled(self): + """ + Check if LTI AGS is properly included when block is graded. + """ + self.lti_1p3_config.block = self.xblock + + # Get LTI 1.3 consumer + consumer = self.lti_1p3_config.get_lti_consumer() + + # Check that LTI claim was included in extra claims + self.assertEqual( + consumer.extra_claims, + { + 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': + { + 'scope': [ + 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', + 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + ], + 'lineitems': 'https://example.com/api/lti_consumer/v1/lti/2/lti-ags' + } + } + ) + + +class TestLtiAgsLineItemModel(TestCase): + """ + Unit tests for LtiAgsLineItem model methods. + """ + def setUp(self): + super(TestLtiAgsLineItemModel, self).setUp() + + self.dummy_location = 'block-v1:course+test+2020+type@problem+block@test' + self.lti_ags_model = LtiAgsLineItem.objects.create( + lti_configuration=None, + resource_id="test-id", + label="this-is-a-test", + resource_link_id=self.dummy_location, + score_maximum=100, + ) + + def test_repr(self): + """ + Test String representation of model. + """ + self.assertEqual( + str(self.lti_ags_model), + "block-v1:course+test+2020+type@problem+block@test - this-is-a-test" + ) diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py index b1b7153f..b70ee93f 100644 --- a/lti_consumer/utils.py +++ b/lti_consumer/utils.py @@ -37,7 +37,7 @@ def get_lms_lti_keyset_link(location): :param location: the location of the block """ - return u"{lms_base}/api/lti_consumer/v1/public_keysets/{location}".format( + return "{lms_base}/api/lti_consumer/v1/public_keysets/{location}".format( lms_base=get_lms_base(), location=str(location), ) @@ -49,7 +49,7 @@ def get_lms_lti_launch_link(): :param location: the location of the block """ - return u"{lms_base}/api/lti_consumer/v1/launch/".format( + return "{lms_base}/api/lti_consumer/v1/launch/".format( lms_base=get_lms_base(), ) @@ -60,7 +60,19 @@ def get_lms_lti_access_token_link(location): :param location: the location of the block """ - return u"{lms_base}/api/lti_consumer/v1/token/{location}".format( + return "{lms_base}/api/lti_consumer/v1/token/{location}".format( lms_base=get_lms_base(), location=str(location), ) + + +def get_lti_ags_lineitems_url(lti_config_id): + """ + Return the LTI AGS endpoint + + :param lti_config_id: LTI configuration id + """ + return "{lms_base}/api/lti_consumer/v1/lti/{lti_config_id}/lti-ags".format( + lms_base=get_lms_base(), + lti_config_id=str(lti_config_id), + ) diff --git a/requirements/base.in b/requirements/base.in index f317ec2a..7e08e14b 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -11,4 +11,5 @@ XBlock xblock-utils pycryptodomex pyjwkest -edx-opaque-keys[django] \ No newline at end of file +edx-opaque-keys[django] +django-filter \ No newline at end of file diff --git a/requirements/base.txt b/requirements/base.txt index 9d455985..a90db986 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -5,10 +5,11 @@ # make upgrade # appdirs==1.4.4 # via fs -bleach==3.1.5 # via -r requirements/base.in +bleach==3.2.1 # via -r requirements/base.in certifi==2020.6.20 # via requests chardet==3.0.4 # via requests -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.in, edx-opaque-keys +django-filter==2.4.0 # via -r requirements/base.in +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.in, django-filter, 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 @@ -32,7 +33,6 @@ simplejson==3.17.2 # via xblock-utils six==1.15.0 # via bleach, edx-opaque-keys, fs, packaging, pyjwkest, python-dateutil, stevedore, xblock sqlparse==0.3.1 # via django stevedore==1.32.0 # via -c requirements/constraints.txt, edx-opaque-keys -typing==3.7.4.3 # via fs urllib3==1.25.10 # via requests web-fragments==0.3.2 # via xblock, xblock-utils webencodings==0.5.1 # via bleach diff --git a/requirements/django.txt b/requirements/django.txt index 95341a8d..2d9bf95f 100644 --- a/requirements/django.txt +++ b/requirements/django.txt @@ -1 +1 @@ -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.txt, django-pyfs, edx-opaque-keys, xblock-sdk +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/base.txt, django-filter, django-pyfs, edx-opaque-keys, xblock-sdk diff --git a/requirements/test.txt b/requirements/test.txt index 25a140cc..5d5deafe 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -6,9 +6,9 @@ # 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.62 # via fs-s3fs -botocore==1.17.62 # via boto3, s3transfer +bleach==3.2.1 # via -r requirements/base.txt +boto3==1.15.7 # via fs-s3fs +botocore==1.18.7 # 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 @@ -16,10 +16,10 @@ click==7.1.2 # via click-log, edx-lint coverage==5.3 # via coveralls coveralls==2.1.2 # via -r requirements/test.in ddt==1.4.1 # via -r requirements/test.in +django-filter==2.4.0 # via -r requirements/base.txt django-pyfs==2.2 # via -r requirements/test.in djangorestframework==3.9.4 # via -c requirements/constraints.txt, -r requirements/test.in docopt==0.6.2 # via coveralls -docutils==0.15.2 # via botocore 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 diff --git a/requirements/travis.txt b/requirements/travis.txt index f8806d21..e064147d 100644 --- a/requirements/travis.txt +++ b/requirements/travis.txt @@ -6,9 +6,9 @@ # 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.62 # via -r requirements/test.txt, fs-s3fs -botocore==1.17.62 # via -r requirements/test.txt, boto3, s3transfer +bleach==3.2.1 # via -r requirements/test.txt +boto3==1.15.7 # via -r requirements/test.txt, fs-s3fs +botocore==1.18.7 # 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 @@ -17,11 +17,11 @@ coverage==5.3 # 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-filter==2.4.0 # via -r requirements/test.txt django-pyfs==2.2 # via -r requirements/test.txt -django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, django-pyfs, edx-opaque-keys, xblock-sdk +django==2.2.16 # via -c requirements/constraints.txt, -r requirements/test.txt, django-filter, django-pyfs, edx-opaque-keys, xblock-sdk djangorestframework==3.9.4 # via -c requirements/constraints.txt, -r requirements/test.txt docopt==0.6.2 # via -r requirements/test.txt, coveralls -docutils==0.15.2 # via -r requirements/test.txt, botocore 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 diff --git a/test_settings.py b/test_settings.py index e6e2f7fa..47f7852e 100644 --- a/test_settings.py +++ b/test_settings.py @@ -9,3 +9,6 @@ # Keep settings, use different ROOT_URLCONF ROOT_URLCONF = 'test_urls' + +# LMS Urls - for LTI 1.3 testing +LMS_ROOT_URL = "https://example.com" \ No newline at end of file diff --git a/test_urls.py b/test_urls.py index 66f5f749..b250558f 100644 --- a/test_urls.py +++ b/test_urls.py @@ -5,5 +5,5 @@ urlpatterns = [ re_path(r'^', include('workbench.urls')), - re_path(r'^', include('lti_consumer.plugin.urls')), + re_path(r'^', include('lti_consumer.plugin.urls', namespace='lti_consumer')), ]