diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
index 5a85555cdb69..2acbf63da9ce 100644
--- a/cms/djangoapps/contentstore/views/library.py
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -151,16 +151,27 @@ def _list_libraries(request):
"""
org = request.GET.get('org', '')
text_search = request.GET.get('text_search', '').lower()
+ paginate = request.GET.get('pagination', 'false').lower() == 'true'
if org:
libraries = modulestore().get_libraries(org=org)
else:
libraries = modulestore().get_libraries()
+ library_count = len(libraries)
+
+ if paginate:
+ page = int(request.GET.get('page', 1))
+ page_size = int(request.GET.get('page_size', 50))
+
+ offset = (page - 1) * page_size
+ limit = page * page_size
+ libraries = libraries[offset:limit]
+
lib_info = [
{
- "display_name": lib.display_name,
- "library_key": str(lib.location.library_key),
+ 'display_name': lib.display_name,
+ 'library_key': text_type(lib.location.library_key),
}
for lib in libraries
if (
@@ -172,6 +183,16 @@ def _list_libraries(request):
has_studio_read_access(request.user, lib.location.library_key)
)
]
+
+ if paginate:
+ # This format is used by rest-framework based paginated API endpoints
+ # so we can use the same response processing on the client side as we
+ # do with other API endpoints
+ return JsonResponse({
+ 'results': lib_info,
+ 'count': library_count,
+ })
+
return JsonResponse(lib_info)
diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py
index e62c56509592..442fe8374585 100644
--- a/cms/djangoapps/contentstore/views/tests/test_library.py
+++ b/cms/djangoapps/contentstore/views/tests/test_library.py
@@ -141,6 +141,99 @@ def test_list_libraries(self):
self.assertEqual(entry["display_name"], lib_dict[key].display_name)
del lib_dict[key] # To ensure no duplicates are matched
+ def test_list_paginated_libraries(self):
+ """
+ Test that we can GET /library/ to list all libraries visible to the current user
+ with a default 50 items per page using pagination.
+ """
+ # Create some more libraries
+ libraries = [LibraryFactory.create() for _ in range(3)]
+ lib_dict = {lib.location.library_key: lib for lib in libraries}
+
+ response = self.client.get_json(LIBRARY_REST_URL, {
+ "pagination": "true",
+ })
+
+ self.assertEqual(response.status_code, 200)
+ response_data = parse_json(response)
+ lib_list = response_data["results"]
+
+ self.assertEqual(response_data["count"], len(lib_list))
+ self.assertEqual(len(lib_list), len(libraries))
+ for entry in lib_list:
+ self.assertIn("library_key", entry)
+ self.assertIn("display_name", entry)
+ key = CourseKey.from_string(entry["library_key"])
+ self.assertIn(key, lib_dict)
+ self.assertEqual(entry["display_name"], lib_dict[key].display_name)
+ del lib_dict[key] # To ensure no duplicates are matched
+
+ def test_list_paginated_libraries_with_page_size_set(self):
+ """
+ Test that we can GET /library/ to list all libraries visible to the current user
+ with a 5 items per page using pagination.
+ """
+ # Create some more libraries
+ page_size = 5
+ libraries = [LibraryFactory.create() for _ in range(10)]
+
+ response = self.client.get_json(LIBRARY_REST_URL, {
+ "pagination": "true",
+ "page_size": page_size
+ })
+
+ self.assertEqual(response.status_code, 200)
+ response_data = parse_json(response)
+ lib_list = response_data["results"]
+
+ self.assertEqual(response_data["count"], len(libraries))
+ self.assertEqual(len(lib_list), page_size)
+
+ stored_libraries = self.store.get_libraries()
+ selected_libs = [
+ {
+ 'display_name': lib.display_name,
+ 'library_key': text_type(lib.location.library_key),
+ } for lib in stored_libraries[0:page_size]
+ ]
+
+ self.assertListEqual(selected_libs, lib_list)
+
+ def test_list_paginated_libraries_with_a_specific_page_selected(self):
+ """
+ Test that we can GET /library/ to list all libraries visible to the current user
+ with a 5 items per page on a specific page using pagination.
+ """
+ # Create some more libraries
+ page = 2
+ page_size = 5
+ libraries = [LibraryFactory.create() for _ in range(15)]
+
+ response = self.client.get_json(LIBRARY_REST_URL, {
+ "pagination": "true",
+ "page_size": page_size,
+ "page": page
+ })
+
+ self.assertEqual(response.status_code, 200)
+ response_data = parse_json(response)
+ lib_list = response_data["results"]
+
+ self.assertEqual(response_data["count"], len(libraries))
+ self.assertEqual(len(lib_list), page_size)
+
+ offset = (page - 1) * page_size
+ limit = page * page_size
+ stored_libraries = self.store.get_libraries()
+ selected_libs = [
+ {
+ 'display_name': lib.display_name,
+ 'library_key': text_type(lib.location.library_key),
+ } for lib in stored_libraries[offset:limit]
+ ]
+
+ self.assertListEqual(selected_libs, lib_list)
+
@ddt.data("delete", "put")
def test_bad_http_verb(self, verb):
"""
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 6ec831517b7d..21c234edc1ff 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -610,6 +610,7 @@
# use the ratelimit backend to prevent brute force attacks
AUTHENTICATION_BACKENDS = [
'rules.permissions.ObjectPermissionBackend',
+ 'openedx.core.djangoapps.content_libraries.auth.LtiAuthenticationBackend',
'openedx.core.djangoapps.oauth_dispatch.dot_overrides.backends.EdxRateLimitedAllowAllUsersModelBackend',
'bridgekeeper.backends.RulePermissionBackend',
]
@@ -1587,6 +1588,9 @@
# Database-backed Organizations App (http://github.com/edx/edx-organizations)
'organizations',
+
+ # Content Library LTI 1.3 Support.
+ 'pylti1p3.contrib.django.lti1p3_tool_config'
]
diff --git a/cms/templates/content_libraries/xblock_iframe.html b/cms/templates/content_libraries/xblock_iframe.html
new file mode 100644
index 000000000000..4789a9bfd6db
--- /dev/null
+++ b/cms/templates/content_libraries/xblock_iframe.html
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ fragment.head_html | safe }}
+
+
+
+ {{ fragment.body_html | safe }}
+
+ {{ fragment.foot_html | safe }}
+
+
+
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 161554948f9a..14ce147dc353 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -3090,6 +3090,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
# in the LMS process at the moment, so anything that has Django admin access
# permissions needs to be listed as an LMS app or the script will fail.
'user_tasks',
+
+ # Content Library LTI 1.3 Support.
+ 'pylti1p3.contrib.django.lti1p3_tool_config',
]
######################### CSRF #########################################
diff --git a/openedx/core/djangoapps/content_libraries/admin.py b/openedx/core/djangoapps/content_libraries/admin.py
index 165a88a3308a..e7e843ba88f0 100644
--- a/openedx/core/djangoapps/content_libraries/admin.py
+++ b/openedx/core/djangoapps/content_libraries/admin.py
@@ -19,7 +19,16 @@ class ContentLibraryAdmin(admin.ModelAdmin):
"""
Definition of django admin UI for Content Libraries
"""
- fields = ("library_key", "org", "slug", "bundle_uuid", "allow_public_learning", "allow_public_read")
+
+ fields = (
+ "library_key",
+ "org",
+ "slug",
+ "bundle_uuid",
+ "allow_public_learning",
+ "allow_public_read",
+ "lti_tool",
+ )
list_display = ("slug", "org", "bundle_uuid")
inlines = (ContentLibraryPermissionInline, )
diff --git a/openedx/core/djangoapps/content_libraries/apps.py b/openedx/core/djangoapps/content_libraries/apps.py
index bece3dc641dc..685e9259b6e2 100644
--- a/openedx/core/djangoapps/content_libraries/apps.py
+++ b/openedx/core/djangoapps/content_libraries/apps.py
@@ -31,3 +31,9 @@ class ContentLibrariesConfig(AppConfig):
},
},
}
+
+ def ready(self):
+ """
+ Import signal handler's module to ensure they are registered.
+ """
+ from . import signal_handlers # pylint: disable=unused-import
diff --git a/openedx/core/djangoapps/content_libraries/auth.py b/openedx/core/djangoapps/content_libraries/auth.py
new file mode 100644
index 000000000000..1b35350cb396
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/auth.py
@@ -0,0 +1,43 @@
+"""
+Content Library LTI authentication.
+
+This module offers an authentication backend to support LTI launches within
+content libraries.
+"""
+
+
+import logging
+
+from django.contrib.auth.backends import ModelBackend
+
+from .models import LtiProfile
+
+
+log = logging.getLogger(__name__)
+
+
+class LtiAuthenticationBackend(ModelBackend):
+ """
+ Authenticate based on content library LTI profile.
+
+ The backend assumes the profile was previously created and its presence is
+ enough to assume the launch claims are valid.
+ """
+
+ # pylint: disable=arguments-differ
+ def authenticate(self, request, iss=None, aud=None, sub=None, **kwargs):
+ """
+ Authenticate if the user in the request has an LTI profile.
+ """
+ log.info('LTI 1.3 authentication: iss=%s, sub=%s', iss, sub)
+ try:
+ lti_profile = LtiProfile.objects.get_from_claims(
+ iss=iss, aud=aud, sub=sub)
+ except LtiProfile.DoesNotExist:
+ return None
+ user = lti_profile.user
+ log.info('LTI 1.3 authentication profile: profile=%s user=%s',
+ lti_profile, user)
+ if user and self.user_can_authenticate(user):
+ return user
+ return None
diff --git a/openedx/core/djangoapps/content_libraries/management/commands/content_libraries_import.py b/openedx/core/djangoapps/content_libraries/management/commands/content_libraries_import.py
new file mode 100644
index 000000000000..8c5849c6500a
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/management/commands/content_libraries_import.py
@@ -0,0 +1,376 @@
+"""
+Command to import modulestore content into Content Libraries.
+"""
+
+import argparse
+import collections
+import logging
+
+import requests
+
+from django.conf import settings
+from django.core.management import BaseCommand
+from django.core.management import CommandError
+
+from edx_rest_api_client.client import OAuthAPIClient
+from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.keys import UsageKey
+from opaque_keys.edx.locator import LibraryLocatorV2
+from opaque_keys.edx.locator import LibraryUsageLocatorV2
+from openedx.core.djangoapps.content_libraries import api as contentlib_api
+from openedx.core.djangoapps.olx_rest_api.block_serializer import XBlockSerializer
+from xmodule.modulestore.django import modulestore
+
+
+log = logging.getLogger(__name__)
+
+
+class BaseEdxImportClient:
+ """
+ Base class for all import clients used by this command.
+
+ Import clients are wrappers tailored to implement the steps used in the
+ import command and can leverage different backends. It is not aimed towards
+ being a generic API client for Open edX.
+ """
+
+ EXPORTABLE_BLOCK_TYPES = {"drag-and-drop-v2",
+ "problem",
+ "html",
+ "video"}
+
+ def get_block_data(self, block_key):
+ """
+ Get the block's OLX and static files, if any.
+ """
+ raise NotImplementedError()
+
+ def get_export_keys(self, course_key):
+ """
+ Get all exportable block keys of a given course.
+ """
+ raise NotImplementedError()
+
+ def get_block_static_data(self, asset_file):
+ """
+ Get the contents of an asset_file specified in the block_olx
+ """
+ raise NotImplementedError()
+
+
+class EdxModulestoreClient(BaseEdxImportClient):
+ """
+ An import client based on the local instance of modulestore.
+ """
+
+ def __init__(self, modulestore_instance=None):
+ """
+ Initialize the client with a modulestore instance.
+ """
+ self.modulestore = modulestore_instance or modulestore()
+
+ def get_block_data(self, block_key):
+ """
+ Get block OLX by serializing it from modulestore directly.
+ """
+ block = self.modulestore.get_item(block_key)
+ data = XBlockSerializer(block)
+ return {'olx': data.olx_str,
+ 'static_file': {s.name: s for s in data.static_files}}
+
+ def get_export_keys(self, course_key):
+ """
+ Retrieve the course from modulestore and traverse its content tree.
+ """
+ course = self.modulestore.get_course(course_key)
+ export_keys = set()
+ blocks_q = collections.deque(course.get_children())
+ while blocks_q:
+ block = blocks_q.popleft()
+ usage_id = block.scope_ids.usage_id
+ if usage_id in export_keys:
+ continue
+ if usage_id.block_type in self.EXPORTABLE_BLOCK_TYPES:
+ export_keys.add(usage_id)
+ if block.has_children:
+ blocks_q.extend(block.get_children())
+ return list(export_keys)
+
+ def get_block_static_data(self, asset_file):
+ """
+ Get static content from its URL if available, otherwise from its data.
+ """
+ if asset_file.data:
+ return asset_file.data
+ resp = requests.get(f"http://{settings.CMS_BASE}" + asset_file.url)
+ resp.raise_for_status()
+ return resp.content
+
+
+class EdxApiClient(BaseEdxImportClient):
+ """
+ An import client based on a remote Open Edx API interface.
+ """
+
+ URL_COURSES = "/api/courses/v1/courses/{course_key}"
+
+ URL_MODULESTORE_BLOCK_OLX = "/api/olx-export/v1/xblock/{block_key}/"
+
+ def __init__(self, lms_url, studio_url, oauth_key, oauth_secret):
+ """
+ Initialize the API client with URLs and OAuth keys.
+ """
+ self.lms_url = lms_url
+ self.studio_url = studio_url
+ self.oauth_client = OAuthAPIClient(
+ self.lms_url,
+ oauth_key,
+ oauth_secret,
+ )
+
+ def get_block_data(self, block_key):
+ """
+ See parent's docstring.
+ """
+ olx_path = self.URL_MODULESTORE_BLOCK_OLX.format(block_key=block_key)
+ resp = self._get(self.studio_url + olx_path)
+ return resp['blocks'][str(block_key)]
+
+ def get_export_keys(self, course_key):
+ """
+ See parent's docstring.
+ """
+ course_blocks_url = self._get_course(course_key)['blocks_url']
+ course_blocks = self._get(
+ course_blocks_url,
+ params={'all_blocks': True, 'depth': 'all'})['blocks']
+ export_keys = []
+ for block_info in course_blocks.values():
+ if block_info['type'] in self.EXPORTABLE_BLOCK_TYPES:
+ export_keys.append(UsageKey.from_string(block_info['id']))
+ return export_keys
+
+ def get_block_static_data(self, asset_file):
+ """
+ See parent's docstring.
+ """
+ if (asset_file["url"].startswith(self.studio_url)
+ and 'export-file' in asset_file['url']):
+ # We must call download this file with authentication. But
+ # we only want to pass the auth headers if this is the same
+ # studio instance, or else we could leak credentials to a
+ # third party.
+ path = asset_file['url'][len(self.studio_url):]
+ resp = self._get(path)
+ else:
+ resp = requests.get(asset_file['url'])
+ resp.raise_for_status()
+ return resp.content
+
+ def _get(self, *args, **kwds):
+ """
+ Perform a get request to the client.
+ """
+ return self._json_call('get', *args, **kwds)
+
+ def _get_course(self, course_key):
+ """
+ Request details for a course.
+ """
+ course_url = self.lms_url + self.URL_COURSES.format(course_key=course_key)
+ return self._get(course_url)
+
+ def _json_call(self, method, *args, **kwds):
+ """
+ Wrapper around request calls that ensures valid json responses.
+ """
+ response = getattr(self.oauth_client, method)(*args, **kwds)
+ response.raise_for_status()
+ return response.json()
+
+
+class Command(BaseCommand):
+ """
+ Import modulestore content, references by a course, into a Content Libraries
+ library.
+ """
+
+ def add_arguments(self, parser):
+ """
+ Add arguments to the argument parser.
+ """
+ parser.add_argument(
+ 'library-key',
+ type=LibraryLocatorV2.from_string,
+ help=('Usage key of the Content Library to import content into.'),
+ )
+ parser.add_argument(
+ 'course-key',
+ type=CourseKey.from_string,
+ help=('The Course Key string, used to identify the course to import '
+ 'content from.'),
+ )
+ subparser = parser.add_subparsers(
+ title='Courseware location and methods',
+ dest='method',
+ description=('Select the method and location to locate the course and '
+ 'its contents.')
+ )
+ api_parser = subparser.add_parser(
+ 'api',
+
+ help=('Query and retrieve course blocks from a remote instance using '
+ 'Open edX course and OLX export APIs. You need to enable API access '
+ 'on the instance.')
+ )
+ api_parser.add_argument(
+ '--lms-url',
+ default=settings.LMS_ROOT_URL,
+ help=("The LMS URL, used to retrieve course content (default: "
+ "'%(default)s')."),
+ )
+ api_parser.add_argument(
+ '--studio-url',
+ default=f"https://{settings.CMS_BASE}",
+ help=("The Studio URL, used to retrieve block OLX content (default: "
+ "'%(default)s')"),
+ )
+ oauth_group = api_parser.add_mutually_exclusive_group(required=False)
+ oauth_group.add_argument(
+ '--oauth-creds-file',
+ type=argparse.FileType('r'),
+ help=('The edX OAuth credentials in a filename. The first line is '
+ 'the OAuth key, second line is the OAuth secret. This is '
+ 'preferred compared to passing the credentials in the command '
+ 'line.'),
+ )
+ oauth_group.add_argument(
+ '--oauth-creds',
+ nargs=2,
+ help=('The edX OAuth credentials in the command line. The first '
+ 'argument is the OAuth secret, the second argument is the '
+ 'OAuth key. Notice that command line arguments are insecure, '
+ 'see `--oauth-creds-file`.'),
+ )
+ subparser.add_parser(
+ 'modulestore',
+ help=("Use a local modulestore intsance to retrieve blocks database on "
+ "the instance where the command is being run. You don't need "
+ "to enable API access.")
+ )
+
+ def write_error(self, *args, **kwds):
+ """
+ Write error messagses to stdout.
+ """
+ return self.stdout.write(self.style.ERROR(*args, **kwds))
+
+ def write_success(self, *args, **kwds):
+ """
+ Write success messages to stdout.
+ """
+ return self.stdout.write(self.style.SUCCESS(*args, **kwds))
+
+ def handle(self, *args, **options):
+ """
+ Collect all blocks from a course that are "importable" and write them to the
+ a blockstore library.
+ """
+
+ # Search for the library.
+
+ try:
+ library = contentlib_api.get_library(options['library-key'])
+ except contentlib_api.ContentLibraryNotFound as exc:
+ raise CommandError("The library specified does not exist: "
+ f"{options['library-key']}") from exc
+
+ # Validate the method and its arguments, instantiate the openedx client.
+
+ if options['method'] == 'api':
+ if options['oauth_creds_file']:
+ with options['oauth_creds_file'] as creds_f:
+ oauth_key, oauth_secret = [v.strip() for v in creds_f.readlines()]
+ elif options['oauth_creds']:
+ oauth_key, oauth_secret = options['oauth_creds']
+ else:
+ raise CommandError("Method 'remote' requires one of the "
+ "--oauth-* options, and none was specified.")
+ edx_client = EdxApiClient(
+ options['lms_url'],
+ options['studio_url'],
+ oauth_key,
+ oauth_secret)
+ elif options['method'] == 'modulestore':
+ edx_client = EdxModulestoreClient()
+ else:
+ assert False, f"Method not supported: {options['method']}"
+
+ # Query the course and rerieve all course blocks.
+
+ export_keys = edx_client.get_export_keys(options['course-key'])
+ if not export_keys:
+ raise CommandError("The courseware course specified does not have "
+ "any exportable content. No action take.")
+
+ # Import each block, skipping the ones that fail.
+
+ failed_blocks = []
+ for index, block_key in enumerate(export_keys):
+ try:
+ self.stdout.write(f"{index + 1}/{len(export_keys)}: {block_key}: ", ending='')
+ self.import_block(edx_client, library, block_key)
+ except Exception as exc: # pylint: disable=broad-except
+ self.write_error('❌')
+ self.stderr.write(f"Failed to import modulestore block: {exc}")
+ log.exception("Error importing modulestore block: %s", block_key)
+ failed_blocks.append(block_key)
+ continue
+ else:
+ self.write_success('✓')
+ if failed_blocks:
+ self.write_error(f"❌ {len(failed_blocks)} out of {len(export_keys)} failed:")
+ for key in failed_blocks:
+ self.write_error(str(key))
+
+ def import_block(self, edx_client, library, modulestore_key):
+ """
+ Import a single modulestore block.
+ """
+
+ # Get or create the block in the library.
+
+ block_data = edx_client.get_block_data(modulestore_key)
+
+ try:
+ library_block = contentlib_api.create_library_block(
+ library.key,
+ modulestore_key.block_type,
+ modulestore_key.block_id)
+ blockstore_key = library_block.usage_key
+ except contentlib_api.LibraryBlockAlreadyExists:
+ blockstore_key = LibraryUsageLocatorV2(
+ lib_key=library.key,
+ block_type=modulestore_key.block_type,
+ usage_id=modulestore_key.block_id,
+ )
+ contentlib_api.get_library_block(blockstore_key)
+
+ # Handle static files.
+
+ for filename, static_file in block_data.get('static_files', {}).items():
+ files = [
+ f.path for f in
+ contentlib_api.get_library_block_static_asset_files(blockstore_key)
+ ]
+ if filename in files:
+ # Files already added, move on.
+ continue
+ file_content = edx_client.get_block_static_data(static_file)
+ contentlib_api.add_library_block_static_asset_file(
+ blockstore_key, filename, file_content)
+
+ # Import OLX and publish.
+
+ contentlib_api.set_library_block_olx(blockstore_key, block_data['olx'])
+ contentlib_api.publish_changes(blockstore_key.lib_key)
diff --git a/openedx/core/djangoapps/content_libraries/migrations/0005_ltigradedresource_ltiprofile.py b/openedx/core/djangoapps/content_libraries/migrations/0005_ltigradedresource_ltiprofile.py
new file mode 100644
index 000000000000..71aa56683e5a
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/migrations/0005_ltigradedresource_ltiprofile.py
@@ -0,0 +1,44 @@
+# Generated by Django 2.2.20 on 2021-05-11 15:43
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('content_libraries', '0004_contentlibrary_license'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='LtiProfile',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('platform_id', models.CharField(help_text='The LTI platform identifier to which this profile belongs to.', max_length=255, verbose_name='platform identifier')),
+ ('client_id', models.CharField(help_text='The LTI client identifier generated by the platform.', max_length=255, verbose_name='client identifier')),
+ ('subject_id', models.CharField(help_text='Identifies the entity that initiated the deep linking request, commonly a user. If set to ``None`` the profile belongs to the Anonymous entity.', max_length=255, verbose_name='subject identifier')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('user', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='contentlibraries_lti_profile', to=settings.AUTH_USER_MODEL, verbose_name='open edx user')),
+ ],
+ options={
+ 'unique_together': {('platform_id', 'client_id', 'subject_id')},
+ },
+ ),
+ migrations.CreateModel(
+ name='LtiGradedResource',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('usage_key', models.CharField(help_text='The usage key string of the blockstore resource serving the content of this launch.', max_length=255)),
+ ('resource_id', models.CharField(help_text='The platform unique identifier of this resource in the platform, also known as "resource link id".', max_length=255)),
+ ('resource_title', models.CharField(help_text='The platform descriptive title for this resource placed in the platform.', max_length=255, null=True)),
+ ('ags_lineitem', models.CharField(help_text='If AGS was enabled during launch, this should hold the lineitem ID.', max_length=255)),
+ ('profile', models.ForeignKey(help_text='The authorized LTI profile that launched the resource.', on_delete=django.db.models.deletion.CASCADE, related_name='lti_resources', to='content_libraries.LtiProfile')),
+ ],
+ options={
+ 'unique_together': {('usage_key', 'profile')},
+ },
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content_libraries/migrations/0006_auto_20210615_1916.py b/openedx/core/djangoapps/content_libraries/migrations/0006_auto_20210615_1916.py
new file mode 100644
index 000000000000..672be651d82a
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/migrations/0006_auto_20210615_1916.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.2.20 on 2021-06-15 19:16
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('lti1p3_tool_config', '0001_initial'),
+ ('content_libraries', '0005_ltigradedresource_ltiprofile'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='contentlibrary',
+ name='lti_tool',
+ field=models.ForeignKey(default=None, help_text="Authorize the LTI tool selected to expose this library's content through LTI launches, leave unselected to disable LTI launches.", null=True, on_delete=django.db.models.deletion.SET_NULL, to='lti1p3_tool_config.LtiTool'),
+ ),
+ migrations.AlterField(
+ model_name='ltiprofile',
+ name='subject_id',
+ field=models.CharField(help_text='Identifies the entity that initiated the launch request, commonly a user.', max_length=255, verbose_name='subject identifier'),
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content_libraries/models.py b/openedx/core/djangoapps/content_libraries/models.py
index a8ffa653bba1..197a9b670101 100644
--- a/openedx/core/djangoapps/content_libraries/models.py
+++ b/openedx/core/djangoapps/content_libraries/models.py
@@ -1,18 +1,65 @@
"""
-Models for new Content Libraries
+========================
+Content Libraries Models
+========================
+
+This module contains the models for new Content Libraries.
+
+LTI 1.3 Models
+==============
+
+Content Libraries serves blockstore-based content through LTI 1.3 launches.
+The interface supports resource link launches and grading services. Two use
+cases justify the current data model to support LTI launches. They are:
+
+1. Authentication and authorization. This use case demands management of user
+ lifecycle to authorize access to content and grade submission, and it
+ introduces a model to own the authentication business logic related to LTI.
+
+2. Grade and assignments. When AGS is supported, content libraries store
+ additional information concerning the launched resource so that, once the
+ grading sub-system submits the score, it can retrieve them to propagate the
+ score update into the platform's grade book.
+
+Relationship with LMS's ``lti_provider``` models
+------------------------------------------------
+
+The data model above is similar to the one provided by the current LTI 1.1
+implementation for modulestore and courseware content. But, Content Libraries
+is orthogonal. Its use-case is to offer standalone, embedded content from a
+specific backend (blockstore). As such, it decouples from LTI 1.1. and the
+logic assume no relationship or impact across the two applications. The same
+reasoning applies to steps beyond the data model, such as at the XBlock
+runtime, authentication, and score handling, etc.
"""
+
+
+import logging
+import uuid
+
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.exceptions import ValidationError
from django.db import models
+from django.db import transaction
from django.utils.translation import ugettext_lazy as _
from opaque_keys.edx.locator import LibraryLocatorV2
+from pylti1p3.contrib.django import DjangoDbToolConf
+from pylti1p3.contrib.django import DjangoMessageLaunch
+from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool
+from pylti1p3.grade import Grade
+
from openedx.core.djangoapps.content_libraries.constants import (
LIBRARY_TYPES, COMPLEX, LICENSE_OPTIONS,
ALL_RIGHTS_RESERVED,
)
from organizations.models import Organization # lint-amnesty, pylint: disable=wrong-import-order
+from .apps import ContentLibrariesConfig
+
+
+log = logging.getLogger(__name__)
+
User = get_user_model()
@@ -35,7 +82,7 @@ class ContentLibrary(models.Model):
All actual content is stored in Blockstore, and any data that we'd want to
transfer to another instance if this library were exported and then
re-imported on another Open edX instance should be kept in Blockstore. This
- model in the LMS should only be used to track settings specific to this Open
+ model in Studio should only be used to track settings specific to this Open
edX instance, like who has permission to edit this content library.
"""
objects = ContentLibraryManager()
@@ -71,6 +118,15 @@ class ContentLibrary(models.Model):
"""),
)
+ lti_tool = models.ForeignKey(
+ LtiTool,
+ default=None,
+ null=True,
+ on_delete=models.SET_NULL,
+ help_text=("Authorize the LTI tool selected to expose this library's content "
+ "through LTI launches, leave unselected to disable LTI launches.")
+ )
+
class Meta:
verbose_name_plural = "Content Libraries"
unique_together = ("org", "slug")
@@ -82,6 +138,25 @@ def library_key(self):
"""
return LibraryLocatorV2(org=self.org.short_name, slug=self.slug)
+ @classmethod
+ def authorize_lti_launch(cls, library_key, *, issuer, client_id=None):
+ """
+ Check if the given Issuer and Client ID are authorized to launch content
+ from this library.
+ """
+ try:
+ self = ContentLibrary.objects.get_by_key(library_key)
+ except ContentLibrary.DoesNotExist:
+ return False
+ if self.lti_tool is None:
+ return False
+ authorized = (self.lti_tool.issuer == issuer)
+ if self.lti_tool.client_id:
+ authorized &= (self.lti_tool.client_id == client_id)
+ # Only tools that are active can authorize access.
+ authorized &= self.lti_tool.is_active
+ return authorized
+
def __str__(self):
return "ContentLibrary ({})".format(str(self.library_key))
@@ -127,3 +202,248 @@ def save(self, *args, **kwargs): # lint-amnesty, pylint: disable=arguments-diff
def __str__(self):
who = self.user.username if self.user else self.group.name
return f"ContentLibraryPermission ({self.access_level} for {who})"
+
+
+class LtiProfileManager(models.Manager):
+ """
+ Custom manager of LtiProfile mode.
+ """
+
+ def get_from_claims(self, *, iss, aud, sub):
+ """
+ Get the an instance from a LTI launch claims.
+ """
+ return self.get(platform_id=iss, client_id=aud, subject_id=sub)
+
+ def get_or_create_from_claims(self, *, iss, aud, sub):
+ """
+ Get or create an instance from a LTI launch claims.
+ """
+ try:
+ return self.get_from_claims(iss=iss, aud=aud, sub=sub)
+ except self.model.DoesNotExist:
+ # User will be created on ``save()``.
+ return self.create(platform_id=iss, client_id=aud, subject_id=sub)
+
+
+class LtiProfile(models.Model):
+ """
+ Content Librarie LTI's profile for Open edX users.
+
+ Unless Anonymous, this should be a unique representation of the LTI subject
+ (as per the client token ``sub`` identify claim) that initiated an LTI
+ launch through Content Libraries.
+ """
+
+ objects = LtiProfileManager()
+
+ user = models.OneToOneField(
+ get_user_model(),
+ null=True,
+ on_delete=models.CASCADE,
+ related_name='contentlibraries_lti_profile',
+ verbose_name=_('open edx user'),
+ )
+
+ platform_id = models.CharField(
+ max_length=255,
+ verbose_name=_('platform identifier'),
+ help_text=_("The LTI platform identifier to which this profile belongs "
+ "to.")
+ )
+
+ client_id = models.CharField(
+ max_length=255,
+ verbose_name=_('client identifier'),
+ help_text=_("The LTI client identifier generated by the platform.")
+ )
+
+ subject_id = models.CharField(
+ max_length=255,
+ verbose_name=_('subject identifier'),
+ help_text=_('Identifies the entity that initiated the launch request, '
+ 'commonly a user.')
+ )
+
+ created_at = models.DateTimeField(
+ auto_now_add=True
+ )
+
+ class Meta:
+ unique_together = ['platform_id', 'client_id', 'subject_id']
+
+ @property
+ def subject_url(self):
+ """
+ An local URL that is known to uniquely identify this profile.
+
+ We take advantage of the fact that platform id is required to be an URL
+ and append paths with the reamaining keys to it.
+ """
+ return '/'.join([
+ self.platform_id.rstrip('/'),
+ self.client_id,
+ self.subject_id
+ ])
+
+ def save(self, *args, **kwds):
+ """
+ Get or create an edx user on save.
+ """
+ if not self.user:
+ uid = uuid.uuid5(uuid.NAMESPACE_URL, self.subject_url)
+ username = f'urn:openedx:content_libraries:username:{uid}'
+ email = f'{uid}@{ContentLibrariesConfig.name}'
+ with transaction.atomic():
+ if self.user is None:
+ self.user, created = User.objects.get_or_create(
+ username=username,
+ defaults={'email': email})
+ if created:
+ # LTI users can only auth throught LTI launches.
+ self.user.set_unusable_password()
+ self.user.save()
+ super().save(*args, **kwds)
+
+ def __str__(self):
+ return self.subject_id
+
+
+class LtiGradedResourceManager(models.Manager):
+ """
+ A custom manager for the graded resources model.
+ """
+
+ def upsert_from_ags_launch(self, user, block, resource_endpoint, resource_link):
+ """
+ Update or create a graded resource at AGS launch.
+ """
+ resource_id = resource_link['id']
+ resource_title = resource_link.get('title') or None
+ lineitem = resource_endpoint['lineitem']
+ lti_profile = user.contentlibraries_lti_profile
+ try:
+ resource = self.get(
+ profile=lti_profile,
+ usage_key=block.scope_ids.usage_id
+ )
+ except self.model.DoesNotExist:
+ resource = self.model(
+ profile=lti_profile,
+ usage_key=block.scope_ids.usage_id,
+ )
+ resource.resource_title = resource_title
+ resource.resource_id = resource_id
+ resource.ags_lineitem = lineitem
+ resource.save()
+ return resource
+
+ def get_from_user_id(self, user_id, **kwds):
+ """
+ Retrieve a resource for a given user id holding an lti profile.
+ """
+ try:
+ user = get_user_model().objects.get(pk=user_id)
+ except get_user_model().DoesNotExist as exc:
+ raise self.model.DoesNotExist('User specified was not found.') from exc
+ profile = getattr(user, 'contentlibraries_lti_profile', None)
+ if not profile:
+ raise self.model.DoesNotExist('User does not have a LTI profile.')
+ kwds['profile'] = profile
+ return self.get(**kwds)
+
+
+class LtiGradedResource(models.Model):
+ """
+ A content libraries resource launched through LTI with AGS enabled.
+
+ Essentially, an instance of this model represents a successful LTI AGS
+ launch. This model links the profile that launched the resource with the
+ resource itself, allowing identifcation of the link through its usage key
+ string and user id.
+ """
+
+ objects = LtiGradedResourceManager()
+
+ profile = models.ForeignKey(
+ LtiProfile,
+ on_delete=models.CASCADE,
+ related_name='lti_resources',
+ help_text=_('The authorized LTI profile that launched the resource.'))
+
+ usage_key = models.CharField(
+ max_length=255,
+ help_text=_('The usage key string of the blockstore resource serving the '
+ 'content of this launch.'),
+ )
+
+ resource_id = models.CharField(
+ max_length=255,
+ help_text=_('The platform unique identifier of this resource in the '
+ 'platform, also known as "resource link id".'),
+ )
+
+ resource_title = models.CharField(
+ max_length=255,
+ null=True,
+ help_text=_('The platform descriptive title for this resource placed in '
+ 'the platform.'),
+ )
+
+ ags_lineitem = models.CharField(
+ max_length=255,
+ null=False,
+ help_text=_('If AGS was enabled during launch, this should hold the '
+ 'lineitem ID.'))
+
+ class Meta:
+ unique_together = (['usage_key', 'profile'])
+
+ def update_score(self, weighted_earned, weighted_possible, timestamp):
+ """
+ Use LTI's score service to update the platform's gradebook.
+
+ This method synchronously send a request to the platform to update the
+ assignment score, raising in case the
+ """
+
+ launch_data = {
+ 'iss': self.profile.platform_id,
+ 'aud': self.profile.client_id,
+ 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint': {
+ 'lineitem': self.ags_lineitem,
+ 'scope': {
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/score',
+ }
+ }
+ }
+
+ tool_config = DjangoDbToolConf()
+
+ ags = (
+ DjangoMessageLaunch(request=None, tool_config=tool_config)
+ .set_auto_validation(enable=False)
+ .set_jwt({'body': launch_data})
+ .set_restored()
+ .validate_registration()
+ .get_ags()
+ )
+
+ if weighted_possible == 0:
+ weighted_score = 0
+ else:
+ weighted_score = float(weighted_earned) / float(weighted_possible)
+
+ ags.put_grade(
+ Grade()
+ .set_score_given(weighted_score)
+ .set_score_maximum(1)
+ .set_timestamp(timestamp.isoformat())
+ .set_activity_progress('Submitted')
+ .set_grading_progress('FullyGraded')
+ .set_user_id(self.profile.subject_id)
+ )
+
+ def __str__(self):
+ return self.usage_key
diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py
new file mode 100644
index 000000000000..2c551dd5cd2e
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py
@@ -0,0 +1,43 @@
+"""
+Content library signal handlers.
+"""
+
+import logging
+
+from django.dispatch import receiver
+
+from lms.djangoapps.grades.api import signals as grades_signals
+
+from .models import LtiGradedResource
+
+
+log = logging.getLogger(__name__)
+
+
+@receiver(grades_signals.PROBLEM_WEIGHTED_SCORE_CHANGED)
+def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument
+ """
+ Match the score event to an LTI resource and update.
+ """
+
+ modified = kwargs.get('modified')
+ usage_id = kwargs.get('usage_id')
+ user_id = kwargs.get('user_id')
+ weighted_earned = kwargs.get('weighted_earned')
+ weighted_possible = kwargs.get('weighted_possible')
+
+ if None in (modified, usage_id, user_id, weighted_earned, weighted_possible):
+ log.error("LTI 1.3: Score Signal: Missing a required parameters, "
+ "ignoring: kwargs=%s", kwargs)
+ return
+ try:
+ resource = LtiGradedResource.objects.get_from_user_id(
+ user_id, usage_key=usage_id
+ )
+ except LtiGradedResource.DoesNotExist:
+ log.error("LTI 1.3: Score Signal: Unknown resource, ignoring: kwargs=%s",
+ kwargs)
+ else:
+ resource.update_score(weighted_earned, weighted_possible, modified)
+ log.info("LTI 1.3: Score Signal: Grade upgraded: resource; %s",
+ resource)
diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py
index 435985571ef2..56f439b926ce 100644
--- a/openedx/core/djangoapps/content_libraries/tests/base.py
+++ b/openedx/core/djangoapps/content_libraries/tests/base.py
@@ -37,6 +37,10 @@
URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock
URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file
+URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/'
+URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
+URL_LIB_LTI_LAUNCH = URL_LIB_LTI_PREFIX + 'launch/'
+
URL_BLOCK_RENDER_VIEW = '/api/xblock/v2/xblocks/{block_key}/view/{view_name}/'
URL_BLOCK_GET_HANDLER_URL = '/api/xblock/v2/xblocks/{block_key}/handler_url/{handler_name}/'
URL_BLOCK_METADATA_URL = '/api/xblock/v2/xblocks/{block_key}/'
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_auth.py b/openedx/core/djangoapps/content_libraries/tests/test_auth.py
new file mode 100644
index 000000000000..99d84b7fd7ea
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/tests/test_auth.py
@@ -0,0 +1,35 @@
+"""
+Unit tests for Content Libraries authentication module.
+"""
+
+
+from django.test import TestCase
+
+
+from ..models import LtiProfile
+from ..models import get_user_model
+from ..auth import LtiAuthenticationBackend
+
+
+class LtiAuthenticationBackendTest(TestCase):
+ """
+ AuthenticationBackend tests.
+ """
+
+ iss = 'http://foo.bar'
+ aud = 'a-random-test-aud'
+ sub = 'a-random-test-sub'
+
+ def test_without_profile(self):
+ get_user_model().objects.create(username='foobar')
+ backend = LtiAuthenticationBackend()
+ user = backend.authenticate(None, iss=self.iss, aud=self.aud, sub=self.sub)
+ self.assertIsNone(user)
+
+ def test_with_profile(self):
+ profile = LtiProfile.objects.create(
+ platform_id=self.iss, client_id=self.aud, subject_id=self.sub)
+ backend = LtiAuthenticationBackend()
+ user = backend.authenticate(None, iss=self.iss, aud=self.aud, sub=self.sub)
+ self.assertIsNotNone(user)
+ self.assertEqual(user.contentlibraries_lti_profile, profile)
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_command_content_libraries_import.py b/openedx/core/djangoapps/content_libraries/tests/test_command_content_libraries_import.py
new file mode 100644
index 000000000000..a9319b7ad8e9
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/tests/test_command_content_libraries_import.py
@@ -0,0 +1,106 @@
+"""
+Tests for managent command "importcourseware".
+"""
+
+
+from unittest import mock
+from io import StringIO
+
+import ddt
+
+from django.core.management import call_command
+from django.core.management.base import CommandError
+from django.test import TestCase
+
+from opaque_keys.edx.keys import UsageKey
+from opaque_keys.edx.locator import LibraryLocatorV2
+
+
+@ddt.ddt
+@mock.patch('openedx.core.djangoapps.content_libraries.management.commands.content_libraries_import.EdxApiClient')
+@mock.patch('openedx.core.djangoapps.content_libraries.management.commands.content_libraries_import.contentlib_api')
+class ImportCoursewareTest(TestCase):
+ """
+ Unit tests for importcourseware command.
+ """
+
+ library_key_str = 'lib:foo:bar'
+
+ course_key_str = 'course-v1:foo+bar+foobar'
+
+ def call_command(self, *args, **kwds):
+ """
+ Call command with default test paramters.
+ """
+ out = StringIO()
+ kwds['stdout'] = out
+ library_key = kwds.pop('library_key', self.library_key_str)
+ course_key = kwds.pop('course_key', self.course_key_str)
+ call_command('content_libraries_import', library_key, course_key,
+ 'api',
+ '--oauth-creds', 'fake-key', 'fake-secret',
+ *args, **kwds)
+ return out
+
+ # pylint: disable=unused-argument
+ def test_call_without_library(self, api_mock, edx_class_mock):
+ """
+ Given library does not exists
+ Then raises command error
+ """
+ from openedx.core.djangoapps.content_libraries.api import ContentLibraryNotFound
+ api_mock.ContentLibraryNotFound = ContentLibraryNotFound
+ api_mock.get_library.side_effect = ContentLibraryNotFound
+ with self.assertRaises(CommandError):
+ self.call_command()
+
+ # pylint: disable=unused-argument
+ def test_call_without_course(self, api_mock, edx_class_mock):
+ """
+ Given course does not exist
+ Then raises command error
+ """
+ edx_mock = edx_class_mock.return_value
+ edx_mock.get_export_keys.return_value = []
+ with self.assertRaises(CommandError):
+ self.call_command()
+
+ # pylint: disable=unused-argument
+ def test_call_without_content(self, api_mock, edx_class_mock):
+ """
+ Given course has not content
+ Then raises command error
+ """
+ edx_mock = edx_class_mock.return_value
+ edx_mock.get_export_keys.return_value = []
+ with self.assertRaises(CommandError):
+ self.call_command()
+
+ @ddt.data("drag-and-drop-v2", "problem", "html", "video")
+ def test_call_when_block_with_olx(self, block_type, api_mock, edx_class_mock):
+ """
+ Given a course with one block
+ Then extract OLX, write to library and publish.
+ """
+
+ usage_key_str = 'lb:foo:bar:foobar:1234'
+
+ edx_mock = edx_class_mock.return_value
+ edx_mock.get_export_keys.return_value = [UsageKey.from_string(usage_key_str)]
+ edx_mock.get_block_data.return_value = {'olx': 'fake-olx'}
+
+ library_mock = api_mock.get_library.return_value
+ library_mock.key = LibraryLocatorV2.from_string(self.library_key_str)
+
+ from openedx.core.djangoapps.content_libraries.api import LibraryBlockAlreadyExists
+
+ api_mock.LibraryBlockAlreadyExists = LibraryBlockAlreadyExists
+ api_mock.create_library_block.side_effect = LibraryBlockAlreadyExists
+
+ self.call_command()
+
+ api_mock.get_library_block.assert_called_once()
+ api_mock.get_library_block_static_asset_files.assert_not_called()
+ api_mock.set_library_block_olx.assert_called_once_with(
+ mock.ANY, 'fake-olx')
+ api_mock.publish_changes.assert_called_once()
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_models.py b/openedx/core/djangoapps/content_libraries/tests/test_models.py
new file mode 100644
index 000000000000..02094b2d0a3e
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/tests/test_models.py
@@ -0,0 +1,303 @@
+"""
+Unit tests for Content Libraries models.
+"""
+
+
+from unittest import mock
+import uuid
+
+from django.test import TestCase
+from django.test import RequestFactory
+from django.contrib.auth import get_user_model
+
+from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiToolKey
+
+from organizations.models import Organization
+from opaque_keys.edx.locator import LibraryLocatorV2
+
+from ..models import ALL_RIGHTS_RESERVED
+from ..models import COMPLEX
+from ..models import ContentLibrary
+from ..models import LtiGradedResource
+from ..models import LtiProfile
+from ..models import LtiTool
+
+
+class ContentLibraryTest(TestCase):
+ """
+ Tests for ContentLibrary model.
+ """
+
+ def _create_library(self, **kwds):
+ """
+ Create a library model, without a blockstore bundle attached to it.
+ """
+ org = Organization.objects.create(name='foo', short_name='foo')
+ return ContentLibrary.objects.create(
+ org=org,
+ slug='foobar',
+ type=COMPLEX,
+ bundle_uuid=uuid.uuid4(),
+ allow_public_learning=False,
+ allow_public_read=False,
+ license=ALL_RIGHTS_RESERVED,
+ **kwds,
+ )
+
+ def test_authorize_lti_launch_when_no_library(self):
+ """
+ Given no library
+ When authorize_lti_launch is called
+ Then return False
+ """
+ self.assertFalse(ContentLibrary.objects.exists())
+ authorized = ContentLibrary.authorize_lti_launch(
+ LibraryLocatorV2(org='foo', slug='foobar'),
+ issuer='http://a-fake-issuer',
+ client_id='a-fake-client-id')
+ self.assertFalse(authorized)
+
+ def test_authorize_lti_launch_when_null(self):
+ """
+ Given a library WITHOUT an lti tool set
+ When authorize_lti_launch is called
+ Then return False
+ """
+ library = self._create_library()
+ authorized = ContentLibrary.authorize_lti_launch(
+ library.library_key,
+ issuer='http://a-fake-issuer',
+ client_id='a-fake-client-id')
+ self.assertFalse(authorized)
+
+ def test_authorize_lti_launch_when_not_null(self):
+ """
+ Given a library WITH an lti tool set
+ When authorize_lti_launch is called with different issuers
+ Then return False
+ """
+ issuer = 'http://a-fake-issuer'
+ client_id = 'a-fake-client-id'
+ library = self._create_library(lti_tool=LtiTool.objects.create(
+ issuer=issuer,
+ client_id=client_id,
+ tool_key=LtiToolKey.objects.create()
+ ))
+ authorized = ContentLibrary.authorize_lti_launch(
+ library.library_key,
+ issuer='http://another-fake-issuer',
+ client_id='another-fake-client-id')
+ self.assertFalse(authorized)
+
+ def test_authorize_lti_launch_when_not_null_and_inactive(self):
+ """
+ Given a library WITH an lti tool set
+ When authorize_lti_launch is called with the same issuers
+ And lti tool is inactive
+ Then return False
+ """
+ issuer = 'http://a-fake-issuer'
+ client_id = 'a-fake-client-id'
+ library = self._create_library(lti_tool=LtiTool.objects.create(
+ issuer=issuer,
+ client_id=client_id,
+ is_active=False,
+ tool_key=LtiToolKey.objects.create()
+ ))
+ authorized = ContentLibrary.authorize_lti_launch(
+ library.library_key,
+ issuer='http://another-fake-issuer',
+ client_id='another-fake-client-id')
+ self.assertFalse(authorized)
+
+ def test_authorize_lti_launch_when_not_null_and_active(self):
+ """
+ Given a library WITH an lti tool set
+ When authorize_lti_launch is called with the same issuers
+ And lti tool is active
+ Then return True
+ """
+ issuer = 'http://a-fake-issuer'
+ client_id = 'a-fake-client-id'
+ library = self._create_library(lti_tool=LtiTool.objects.create(
+ issuer=issuer,
+ client_id=client_id,
+ is_active=True, # redudant since it defaults to True
+ tool_key=LtiToolKey.objects.create()
+ ))
+ authorized = ContentLibrary.authorize_lti_launch(
+ library.library_key,
+ issuer=issuer,
+ client_id=client_id)
+ self.assertTrue(authorized)
+
+
+class LtiProfileTest(TestCase):
+ """
+ LtiProfile model tests.
+ """
+
+ def test_get_from_claims_doesnotexists(self):
+ with self.assertRaises(LtiProfile.DoesNotExist):
+ LtiProfile.objects.get_from_claims(iss='iss', aud='aud', sub='sub')
+
+ def test_get_from_claims_exists(self):
+ """
+ Given a LtiProfile with iss and sub,
+ When get_from_claims()
+ Then return the same object.
+ """
+
+ iss = 'http://foo.example.com/'
+ sub = 'randomly-selected-sub-for-testing'
+ aud = 'randomly-selected-aud-for-testing'
+ profile = LtiProfile.objects.create(
+ platform_id=iss,
+ client_id=aud,
+ subject_id=sub)
+
+ queried_profile = LtiProfile.objects.get_from_claims(
+ iss=iss, aud=aud, sub=sub)
+
+ self.assertEqual(
+ queried_profile,
+ profile,
+ 'The queried profile is equal to the profile created.')
+
+ def test_subject_url(self):
+ """
+ Given a profile
+ Then has a valid subject_url.
+ """
+ iss = 'http://foo.example.com'
+ sub = 'randomly-selected-sub-for-testing'
+ aud = 'randomly-selected-aud-for-testing'
+ expected_url = 'http://foo.example.com/randomly-selected-aud-for-testing/randomly-selected-sub-for-testing'
+ profile = LtiProfile.objects.create(
+ platform_id=iss,
+ client_id=aud,
+ subject_id=sub)
+ self.assertEqual(expected_url, profile.subject_url)
+
+ def test_create_with_user(self):
+ """
+ Given a profile without a user
+ When save is called
+ Then a user is created.
+ """
+
+ iss = 'http://foo.example.com/'
+ sub = 'randomly-selected-sub-for-testing'
+ aud = 'randomly-selected-aud-for-testing'
+ profile = LtiProfile.objects.create(
+ platform_id=iss,
+ client_id=aud,
+ subject_id=sub)
+ self.assertIsNotNone(profile.user)
+ self.assertTrue(
+ profile.user.username.startswith('urn:openedx:content_libraries:username:'))
+
+ def test_get_or_create_from_claims(self):
+ """
+ Given a profile does not exist
+ When get or create
+ And get or create again
+ Then the same profile is returned.
+ """
+ iss = 'http://foo.example.com/'
+ sub = 'randomly-selected-sub-for-testing'
+ aud = 'randomly-selected-aud-for-testing'
+ self.assertFalse(LtiProfile.objects.exists())
+ profile = LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub)
+ self.assertIsNotNone(profile.user)
+ self.assertEqual(iss, profile.platform_id)
+ self.assertEqual(sub, profile.subject_id)
+
+ profile_two = LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub)
+ self.assertEqual(profile_two, profile)
+
+ def test_get_or_create_from_claims_twice(self):
+ """
+ Given a profile
+ When another profile is created
+ Then success
+ """
+ iss = 'http://foo.example.com/'
+ aud = 'randomly-selected-aud-for-testing'
+ sub_one = 'randomly-selected-sub-for-testing'
+ sub_two = 'another-randomly-sub-for-testing'
+ self.assertFalse(LtiProfile.objects.exists())
+ LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub_one)
+ LtiProfile.objects.get_or_create_from_claims(iss=iss, aud=aud, sub=sub_two)
+
+
+class LtiResourceTest(TestCase):
+ """
+ LtiGradedResource model tests.
+ """
+
+ iss = 'fake-iss-for-test'
+
+ sub = 'fake-sub-for-test'
+
+ aud = 'fake-aud-for-test'
+
+ def setUp(self):
+ super().setUp()
+ self.request_factory = RequestFactory()
+
+ def test_get_from_user_id_when_no_user_then_not_found(self):
+ user_id = 0
+ with self.assertRaises(LtiGradedResource.DoesNotExist):
+ LtiGradedResource.objects.get_from_user_id(user_id)
+
+ def test_get_from_user_id_when_no_profile_then_not_found(self):
+ user = get_user_model().objects.create(username='foobar')
+ with self.assertRaises(LtiGradedResource.DoesNotExist):
+ LtiGradedResource.objects.get_from_user_id(user.pk)
+
+ def test_get_from_user_id_when_profile_then_found(self):
+ profile = LtiProfile.objects.get_or_create_from_claims(
+ iss=self.iss, aud=self.aud, sub=self.sub)
+ LtiGradedResource.objects.create(profile=profile)
+ resource = LtiGradedResource.objects.get_from_user_id(profile.user.pk)
+ self.assertEqual(profile, resource.profile)
+
+ def test_upsert_from_ags_launch(self):
+ """
+ Give no graded resource
+ When get_or_create_from_launch twice
+ Then created at first, retrieved at second.
+ """
+
+ resource_id = 'resource-foobar'
+ usage_key = 'usage-key-foobar'
+ lineitem = 'http://canvas.docker/api/lti/courses/1/line_items/7'
+ resource_endpoint = {
+ "lineitem": lineitem,
+ "scope": [
+ "https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
+ "https://purl.imsglobal.org/spec/lti-ags/scope/score"
+ ],
+ }
+ resource_link = {
+ "id": resource_id,
+ "title": "A custom title",
+ }
+
+ profile = LtiProfile.objects.get_or_create_from_claims(
+ iss=self.iss, aud=self.aud, sub=self.sub)
+ block_mock = mock.Mock()
+ block_mock.scope_ids.usage_id = usage_key
+ res = LtiGradedResource.objects.upsert_from_ags_launch(
+ profile.user, block_mock, resource_endpoint, resource_link)
+
+ self.assertEqual(resource_id, res.resource_id)
+ self.assertEqual(lineitem, res.ags_lineitem)
+ self.assertEqual(usage_key, res.usage_key)
+ self.assertEqual(profile, res.profile)
+
+ res2 = LtiGradedResource.objects.upsert_from_ags_launch(
+ profile.user, block_mock, resource_endpoint, resource_link)
+
+ self.assertEqual(res, res2)
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_lti.py b/openedx/core/djangoapps/content_libraries/tests/test_views_lti.py
new file mode 100644
index 000000000000..deef7e86f9b4
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/tests/test_views_lti.py
@@ -0,0 +1,62 @@
+"""
+Tests for LTI views.
+"""
+
+from django.test import TestCase
+from openedx.core.djangoapps.content_libraries.constants import PROBLEM
+
+from .base import URL_LIB_LTI_JWKS
+from .base import skip_unless_cms, ContentLibrariesRestApiTest
+
+
+@skip_unless_cms
+class LtiToolJwksViewTest(TestCase):
+ """
+ Test JWKS view.
+ """
+
+ def test_when_no_keys_then_return_empty(self):
+ """
+ Given no LTI tool in the database.
+ When JWKS requested.
+ Then return empty
+ """
+ response = self.client.get(URL_LIB_LTI_JWKS)
+ self.assertEqual(response.status_code, 200)
+ self.assertJSONEqual(response.content, '{"keys": []}')
+
+
+class LibraryBlockLtiUrlViewTest(ContentLibrariesRestApiTest):
+ """
+ Test generating LTI URL for a block in a library.
+ """
+
+ def test_lti_url_generation(self):
+ """
+ Test the LTI URL generated from the block ID.
+ """
+
+ library = self._create_library(
+ slug="libgg", title="A Test Library", description="Testing library", library_type=PROBLEM,
+ )
+
+ block = self._add_block_to_library(library['id'], PROBLEM, PROBLEM)
+ usage_key = str(block.usage_key)
+
+ url = f'/api/libraries/v2/blocks/{usage_key}/lti/'
+ expected_lti_url = f"/api/libraries/v2/lti/1.3/launch/?id={usage_key}"
+
+ response = self._api("GET", url, None, expect_response=200)
+
+ self.assertDictEqual(response, {"lti_url": expected_lti_url})
+
+ def test_block_not_found(self):
+ """
+ Test the LTI URL cannot be generated as the block not found.
+ """
+
+ self._create_library(
+ slug="libgg", title="A Test Library", description="Testing library", library_type=PROBLEM,
+ )
+
+ self._api("GET", '/api/libraries/v2/blocks/not-existing-key/lti/', None, expect_response=404)
diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py
index 4066d72cc8dd..56f3193c54ec 100644
--- a/openedx/core/djangoapps/content_libraries/urls.py
+++ b/openedx/core/djangoapps/content_libraries/urls.py
@@ -38,6 +38,8 @@
url(r'^blocks/(?P[^/]+)/', include([
# Get metadata about a specific XBlock in this library, or delete the block:
url(r'^$', views.LibraryBlockView.as_view()),
+ # Get the LTI URL of a specific XBlock
+ url(r'^lti/$', views.LibraryBlockLtiUrlView.as_view(), name='lti-url'),
# Get the OLX source code of the specified block:
url(r'^olx/$', views.LibraryBlockOlxView.as_view()),
# CRUD for static asset files associated with a block in the library:
@@ -46,5 +48,10 @@
# Future: publish/discard changes for just this one block
# Future: set a block's tags (tags are stored in a Tag bundle and linked in)
])),
+ url(r'^lti/1.3/', include([
+ url(r'^login/$', views.LtiToolLoginView.as_view(), name='lti-login'),
+ url(r'^launch/$', views.LtiToolLaunchView.as_view(), name='lti-launch'),
+ url(r'^pub/jwks/$', views.LtiToolJwksView.as_view(), name='lti-pub-jwks'),
+ ])),
])),
]
diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py
index 150b9a47b8b8..6ac100ab2dbf 100644
--- a/openedx/core/djangoapps/content_libraries/views.py
+++ b/openedx/core/djangoapps/content_libraries/views.py
@@ -1,13 +1,40 @@
"""
-REST API for Blockstore-based content libraries
+=======================
+Content Libraries Views
+=======================
+
+This module contains the REST APIs for blockstore-based content libraries, and
+LTI 1.3 views.
"""
+
+
from functools import wraps
+import itertools
+import json
import logging
+from django.conf import settings
+from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model
+from django.contrib.auth import login
from django.contrib.auth.models import Group
+from django.http import HttpResponseBadRequest
+from django.http import JsonResponse
from django.shortcuts import get_object_or_404
+from django.urls import reverse
+from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
+from django.views.decorators.clickjacking import xframe_options_exempt
+from django.views.decorators.csrf import csrf_exempt
+from django.views.generic.base import TemplateResponseMixin
+from django.views.generic.base import View
+from pylti1p3.contrib.django import DjangoCacheDataStorage
+from pylti1p3.contrib.django import DjangoDbToolConf
+from pylti1p3.contrib.django import DjangoMessageLaunch
+from pylti1p3.contrib.django import DjangoOIDCLogin
+from pylti1p3.exception import LtiException
+from pylti1p3.exception import OIDCException
+
import edx_api_doc_tools as apidocs
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from organizations.api import ensure_organization
@@ -38,6 +65,12 @@
ContentLibraryAddPermissionByEmailSerializer,
)
from openedx.core.lib.api.view_utils import view_auth_classes
+from openedx.core.djangoapps.xblock import api as xblock_api
+
+from .models import ContentLibrary
+from .models import LtiGradedResource
+from .models import LtiProfile
+
User = get_user_model()
log = logging.getLogger(__name__)
@@ -582,6 +615,27 @@ def delete(self, request, usage_key_str): # pylint: disable=unused-argument
return Response({})
+@view_auth_classes()
+class LibraryBlockLtiUrlView(APIView):
+ """
+ Views to generate LTI URL for existing XBlocks in a content library.
+
+ Returns 404 in case the block not found by the given key.
+ """
+ @convert_exceptions
+ def get(self, request, usage_key_str):
+ """
+ Get the LTI launch URL for the XBlock.
+ """
+ key = LibraryUsageLocatorV2.from_string(usage_key_str)
+ api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
+
+ # Get the block to validate its existence
+ api.get_library_block(key)
+ lti_login_url = f"{reverse('content_libraries:lti-launch')}?id={key}"
+ return Response({"lti_url": lti_login_url})
+
+
@view_auth_classes()
class LibraryBlockOlxView(APIView):
"""
@@ -689,3 +743,251 @@ def delete(self, request, usage_key_str, file_path):
except ValueError:
raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from
return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+# LTI 1.3 Views
+# =============
+
+
+class LtiToolView(View):
+ """
+ Base LTI View initializing common attributes.
+ """
+
+ # pylint: disable=attribute-defined-outside-init
+ def setup(self, request, *args, **kwds):
+ """
+ Initialize attributes shared by all LTI views.
+ """
+ super().setup(request, *args, **kwds)
+ self.lti_tool_config = DjangoDbToolConf()
+ self.lti_tool_storage = DjangoCacheDataStorage(cache_name='default')
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class LtiToolLoginView(LtiToolView):
+ """
+ Third-party Initiated Login view.
+
+ The Platform will start the OpenID Connect flow by redirecting the User
+ Agent (UA) to this view. The redirect may be a form POST or a GET. On
+ success the view should redirect the UA to the platforms authentication URL.
+ """
+
+ LAUNCH_URI_PARAMETER = 'target_link_uri'
+
+ def get(self, request):
+ self.post(request)
+
+ def post(self, request):
+ """Initialize 3rd-party login requests to redirect."""
+ oidc_login = DjangoOIDCLogin(
+ self.request,
+ self.lti_tool_config,
+ launch_data_storage=self.lti_tool_storage)
+ launch_url = (self.request.POST.get(self.LAUNCH_URI_PARAMETER)
+ or self.request.GET.get(self.LAUNCH_URI_PARAMETER))
+ try:
+ return oidc_login.redirect(launch_url)
+ except OIDCException as exc:
+ # Relying on downstream error messages, attempt to sanitize it up
+ # for customer facing errors.
+ log.error('LTI OIDC login failed: %s', exc)
+ return HttpResponseBadRequest('Invalid LTI login request.')
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+@method_decorator(xframe_options_exempt, name='dispatch')
+class LtiToolLaunchView(TemplateResponseMixin, LtiToolView):
+ """
+ Platform tool launch view.
+
+ The launch view supports resource link launches and AGS, when enabled by the
+ platform. Other features and resouces are ignored.
+ """
+
+ template_name = 'content_libraries/xblock_iframe.html'
+
+ @property
+ def launch_data(self):
+ return self.launch_message.get_launch_data()
+
+ def _authenticate_and_login(self, usage_key):
+ """
+ Authenticate and authorize the user for this LTI message launch.
+
+ We automatically create LTI profile for every valid launch, and
+ authenticate the LTI user associated with it.
+ """
+
+ # Check library authorization.
+
+ if not ContentLibrary.authorize_lti_launch(
+ usage_key.lib_key,
+ issuer=self.launch_data['iss'],
+ client_id=self.launch_data['aud']
+ ):
+ return None
+
+ # Check LTI profile.
+
+ LtiProfile.objects.get_or_create_from_claims(
+ iss=self.launch_data['iss'],
+ aud=self.launch_data['aud'],
+ sub=self.launch_data['sub'])
+ edx_user = authenticate(
+ self.request,
+ iss=self.launch_data['iss'],
+ aud=self.launch_data['aud'],
+ sub=self.launch_data['sub'])
+
+ if edx_user is not None:
+
+ login(self.request, edx_user)
+ perms = api.get_library_user_permissions(
+ usage_key.lib_key,
+ self.request.user)
+ if not perms:
+ api.set_library_user_permissions(
+ usage_key.lib_key,
+ self.request.user,
+ api.AccessLevel.ADMIN_LEVEL)
+
+ return edx_user
+
+ def _bad_request_response(self):
+ """
+ A default response for bad requests.
+ """
+ return HttpResponseBadRequest('Invalid LTI tool launch.')
+
+ def get_context_data(self):
+ """
+ Setup the template context data.
+ """
+
+ handler_urls = {
+ str(key): xblock_api.get_handler_url(key, 'handler_name', self.request.user)
+ for key
+ in itertools.chain([self.block.scope_ids.usage_id],
+ getattr(self.block, 'children', []))
+ }
+
+ # We are defaulting to student view due to current use case (resource
+ # link launches). Launches within other views are not currently
+ # supported.
+ fragment = self.block.render('student_view')
+
+ return {
+ 'fragment': fragment,
+ 'handler_urls_json': json.dumps(handler_urls),
+ 'lms_root_url': settings.LMS_ROOT_URL,
+ }
+
+ def get_launch_message(self):
+ """
+ Return the LTI 1.3 launch message object for the current request.
+ """
+ launch_message = DjangoMessageLaunch(
+ self.request,
+ self.lti_tool_config,
+ launch_data_storage=self.lti_tool_storage)
+ # This will force the LTI launch validation steps.
+ launch_message.get_launch_data()
+ return launch_message
+
+ # pylint: disable=attribute-defined-outside-init
+ def post(self, request):
+ """
+ Process LTI platform launch requests.
+ """
+
+ # Parse LTI launch message.
+
+ try:
+ self.launch_message = self.get_launch_message()
+ except LtiException as exc:
+ log.exception('LTI 1.3: Tool launch failed: %s', exc)
+ return self._bad_request_response()
+
+ log.info("LTI 1.3: Launch message body: %s",
+ json.dumps(self.launch_data))
+
+ # Parse content key.
+
+ usage_key_str = request.GET.get('id')
+ if not usage_key_str:
+ return self._bad_request_response()
+ usage_key = LibraryUsageLocatorV2.from_string(usage_key_str)
+ log.info('LTI 1.3: Launch block: id=%s', usage_key)
+
+ # Authenticate the launch and setup LTI profiles.
+
+ if not self._authenticate_and_login(usage_key):
+ return self._bad_request_response()
+
+ # Get the block.
+
+ self.block = xblock_api.load_block(
+ usage_key,
+ user=self.request.user)
+
+ # Handle Assignment and Grade Service request.
+
+ self.handle_ags()
+
+ # Render context and response.
+ context = self.get_context_data()
+ return self.render_to_response(context)
+
+ def handle_ags(self):
+ """
+ Handle AGS-enabled launches for block in the request.
+ """
+
+ # Validate AGS.
+
+ if not self.launch_message.has_ags():
+ return
+
+ endpoint_claim = 'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'
+ endpoint = self.launch_data[endpoint_claim]
+ required_scopes = [
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem',
+ 'https://purl.imsglobal.org/spec/lti-ags/scope/score'
+ ]
+
+ for scope in required_scopes:
+ if scope not in endpoint['scope']:
+ log.info('LTI 1.3: AGS: Platform does not support a required '
+ 'scope: %s', scope)
+ return
+ lineitem = endpoint.get('lineitem')
+ if not lineitem:
+ log.info("LTI 1.3: AGS: platform didn't pass lineitem, ignoring "
+ "request: %s", endpoint)
+ return
+
+ # Create graded resource in the database for the current launch.
+
+ resource_claim = 'https://purl.imsglobal.org/spec/lti/claim/resource_link'
+ resource_link = self.launch_data.get(resource_claim)
+
+ resource = LtiGradedResource.objects.upsert_from_ags_launch(
+ self.request.user, self.block, endpoint, resource_link
+ )
+
+ log.info("LTI 1.3: AGS: Upserted LTI graded resource from launch: %s",
+ resource)
+
+
+class LtiToolJwksView(LtiToolView):
+ """
+ JSON Web Key Sets view.
+ """
+
+ def get(self, request):
+ """
+ Return the JWKS.
+ """
+ return JsonResponse(self.lti_tool_config.get_jwks(), safe=False)
diff --git a/requirements/edx/base.in b/requirements/edx/base.in
index 510fa2618031..a7f0127b5327 100644
--- a/requirements/edx/base.in
+++ b/requirements/edx/base.in
@@ -127,6 +127,7 @@ pyjwkest
# TODO Replace PyJWT usage with pyjwkest
# PyJWT 1.6.3 contains PyJWTError, which is required by Apple auth in social-auth-core
PyJWT>=1.6.3
+pylti1p3 # Required by content_libraries core library to suport LTI 1.3 launches
pymongo # MongoDB driver
pynliner # Inlines CSS styles into HTML for email notifications
python-dateutil
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 045603d53b41..6228ccffad83 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -44,11 +44,12 @@ contextlib2==0.6.0.post1 # via -r requirements/edx/base.in
coreapi==2.3.3 # via drf-yasg
coreschema==0.0.4 # via coreapi, drf-yasg
crowdsourcehinter-xblock==0.6 # via -r requirements/edx/base.in
-cryptography==3.2.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/../edx-sandbox/shared.txt, django-fernet-fields, edx-enterprise, pyjwt, social-auth-core
+cryptography==3.2.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/../edx-sandbox/shared.txt, django-fernet-fields, edx-enterprise, jwcrypto, pyjwt, social-auth-core
cssutils==2.2.0 # via pynliner
ddt==1.4.2 # via xblock-drag-and-drop-v2, xblock-poll
decorator==5.0.6 # via pycontracts
defusedxml==0.7.1 # via -r requirements/edx/base.in, djangorestframework-xml, ora2, python3-openid, python3-saml, safe-lxml, social-auth-core
+deprecated==1.2.12 # via jwcrypto
django-appconf==1.0.4 # via -r requirements/edx/base.in, django-statici18n
django-cache-memoize==0.1.8 # via edx-enterprise
django-celery-results==2.0.1 # via -r requirements/edx/base.in
@@ -87,8 +88,8 @@ django==2.2.24 # via -c https://raw.githubusercontent.com/edx/edx-lin
djangorestframework-xml==2.0.0 # via edx-enterprise
djangorestframework==3.12.4 # via -r requirements/edx/base.in, django-config-models, django-user-tasks, drf-jwt, drf-yasg, edx-api-doc-tools, edx-completion, edx-drf-extensions, edx-enterprise, edx-organizations, edx-proctoring, edx-submissions, ora2, rest-condition, super-csv
docopt==0.6.2 # via xmodule
-docutils==0.16 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, botocore
-drf-jwt==1.19.0 # via edx-drf-extensions
+docutils==0.16 # via botocore
+drf-jwt==1.19.0 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, edx-drf-extensions
drf-yasg==1.20.0 # via edx-api-doc-tools
edx-ace==1.1.0 # via -r requirements/edx/base.in
edx-analytics-data-api-client==0.17.0 # via -r requirements/edx/base.in
@@ -142,6 +143,7 @@ jmespath==0.10.0 # via boto3, botocore
joblib==0.14.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/../edx-sandbox/shared.txt, nltk
jsondiff==1.2.0 # via edx-enterprise
jsonfield2==3.0.3 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in, edx-celeryutils, edx-enterprise, edx-event-routing-backends, edx-proctoring, edx-submissions, lti-consumer-xblock, ora2
+jwcrypto==0.9.1 # via pylti1p3
kombu==4.6.11 # via celery
laboratory==1.0.2 # via -r requirements/edx/base.in
lazy==1.4 # via -r requirements/edx/paver.txt, acid-xblock, lti-consumer-xblock, ora2
@@ -182,8 +184,9 @@ pycparser==2.20 # via -r requirements/edx/../edx-sandbox/shared.txt, c
pycryptodomex==3.10.1 # via -r requirements/edx/base.in, edx-proctoring, lti-consumer-xblock, pyjwkest
pygments==2.8.1 # via -r requirements/edx/base.in
pyjwkest==1.4.2 # via -r requirements/edx/base.in, edx-drf-extensions, lti-consumer-xblock
-pyjwt[crypto]==1.7.1 # via -r requirements/edx/base.in, drf-jwt, edx-rest-api-client, social-auth-core
+pyjwt[crypto]==1.7.1 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/base.in, drf-jwt, edx-rest-api-client, pylti1p3, social-auth-core
pylatexenc==2.10 # via olxcleaner
+pylti1p3==1.9.1 # via -r requirements/edx/base.in
pymongo==3.10.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.in, -r requirements/edx/paver.txt, edx-opaque-keys, event-tracking, mongodbproxy, mongoengine
pynliner==0.8.0 # via -r requirements/edx/base.in
pyparsing==2.4.7 # via chem, openedx-calc, packaging, pycontracts
@@ -203,7 +206,7 @@ recommender-xblock==1.4.9 # via -r requirements/edx/base.in
redis==3.5.3 # via -r requirements/edx/base.in
regex==2021.4.4 # via -r requirements/edx/../edx-sandbox/shared.txt, nltk
requests-oauthlib==1.3.0 # via -r requirements/edx/base.in, social-auth-core
-requests==2.25.1 # via -r requirements/edx/paver.txt, analytics-python, coreapi, django-oauth-toolkit, edx-analytics-data-api-client, edx-bulk-grades, edx-drf-extensions, edx-enterprise, edx-event-routing-backends, edx-rest-api-client, geoip2, mailsnake, pyjwkest, python-swiftclient, requests-oauthlib, sailthru-client, slumber, social-auth-core, tableauserverclient
+requests==2.25.1 # via -r requirements/edx/paver.txt, analytics-python, coreapi, django-oauth-toolkit, edx-analytics-data-api-client, edx-bulk-grades, edx-drf-extensions, edx-enterprise, edx-event-routing-backends, edx-rest-api-client, geoip2, mailsnake, pyjwkest, pylti1p3, python-swiftclient, requests-oauthlib, sailthru-client, slumber, social-auth-core, tableauserverclient
rest-condition==1.0.3 # via -r requirements/edx/base.in, edx-drf-extensions
ruamel.yaml.clib==0.2.2 # via ruamel.yaml
ruamel.yaml==0.17.4 # via drf-yasg
@@ -214,10 +217,10 @@ scipy==1.6.2 # via chem, openedx-calc
semantic-version==2.8.5 # via edx-drf-extensions
shapely==1.7.1 # via -r requirements/edx/base.in
simplejson==3.17.2 # via -r requirements/edx/base.in, sailthru-client, super-csv, xblock-utils
-six==1.15.0 # via -r requirements/edx/../edx-sandbox/shared.txt, -r requirements/edx/base.in, -r requirements/edx/paver.txt, analytics-python, bleach, chem, codejail, crowdsourcehinter-xblock, cryptography, django-countries, django-simple-history, edx-ace, edx-bulk-grades, edx-ccx-keys, edx-django-release-util, edx-drf-extensions, edx-enterprise, edx-i18n-tools, edx-milestones, edx-rbac, event-tracking, fs, fs-s3fs, html5lib, isodate, libsass, paver, pycontracts, pyjwkest, python-dateutil, python-memcached, python-swiftclient, social-auth-app-django, social-auth-core, stevedore, xblock
+six==1.15.0 # via -r requirements/edx/../edx-sandbox/shared.txt, -r requirements/edx/base.in, -r requirements/edx/paver.txt, analytics-python, bleach, chem, codejail, crowdsourcehinter-xblock, cryptography, django-countries, django-simple-history, edx-ace, edx-bulk-grades, edx-ccx-keys, edx-django-release-util, edx-drf-extensions, edx-enterprise, edx-i18n-tools, edx-milestones, edx-rbac, event-tracking, fs, fs-s3fs, html5lib, isodate, jwcrypto, libsass, paver, pycontracts, pyjwkest, python-dateutil, python-memcached, python-swiftclient, social-auth-app-django, social-auth-core, stevedore, xblock
slumber==0.7.1 # via edx-bulk-grades, edx-enterprise, edx-rest-api-client
social-auth-app-django==4.0.0 # via -r requirements/edx/base.in
-social-auth-core==4.0.2 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/base.in, social-auth-app-django
+social-auth-core==4.0.2 # via -r requirements/edx/base.in, social-auth-app-django
sorl-thumbnail==12.7.0 # via -r requirements/edx/base.in, django-wiki
sortedcontainers==2.3.0 # via -r requirements/edx/base.in
soupsieve==2.2.1 # via beautifulsoup4
@@ -242,7 +245,7 @@ watchdog==2.0.2 # via -r requirements/edx/paver.txt
web-fragments==1.0.0 # via -r requirements/edx/base.in, crowdsourcehinter-xblock, edx-sga, staff-graded-xblock, xblock, xblock-utils
webencodings==0.5.1 # via bleach, html5lib
webob==1.8.7 # via xblock, xmodule
-wrapt==1.11.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/paver.txt
+wrapt==1.11.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/paver.txt, deprecated
git+https://github.com/edx-solutions/xblock-drag-and-drop-v2@v2.3.4#egg=xblock-drag-and-drop-v2==2.3.4 # via -r requirements/edx/github.in
git+https://github.com/open-craft/xblock-poll@922cd36fb1c3cfe00b4ce03b19a13185d136447d#egg=xblock-poll==1.10.2 # via -r requirements/edx/github.in
xblock-utils==2.1.2 # via -r requirements/edx/base.in, edx-sga, lti-consumer-xblock, staff-graded-xblock, xblock-drag-and-drop-v2, xblock-google-drive
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 51d3ca3d7e02..573f80f5fe7d 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -51,12 +51,13 @@ coreapi==2.3.3 # via -r requirements/edx/testing.txt, drf-yasg
coreschema==0.0.4 # via -r requirements/edx/testing.txt, coreapi, drf-yasg
coverage==5.5 # via -r requirements/edx/testing.txt, pytest-cov
crowdsourcehinter-xblock==0.6 # via -r requirements/edx/testing.txt
-cryptography==3.2.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, django-fernet-fields, edx-enterprise, pyjwt, social-auth-core
+cryptography==3.2.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, django-fernet-fields, edx-enterprise, jwcrypto, pyjwt, social-auth-core
cssselect==1.1.0 # via -r requirements/edx/testing.txt, pyquery
cssutils==2.2.0 # via -r requirements/edx/testing.txt, pynliner
ddt==1.4.2 # via -r requirements/edx/testing.txt, xblock-drag-and-drop-v2, xblock-poll
decorator==5.0.6 # via -r requirements/edx/testing.txt, pycontracts
defusedxml==0.7.1 # via -r requirements/edx/testing.txt, djangorestframework-xml, ora2, python3-openid, python3-saml, safe-lxml, social-auth-core
+deprecated==1.2.12 # via -r requirements/edx/testing.txt, jwcrypto
diff-cover==4.0.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt
distlib==0.3.1 # via -r requirements/edx/testing.txt, virtualenv
django-appconf==1.0.4 # via -r requirements/edx/testing.txt, django-statici18n
@@ -98,8 +99,8 @@ django==2.2.24 # via -c https://raw.githubusercontent.com/edx/edx-lin
djangorestframework-xml==2.0.0 # via -r requirements/edx/testing.txt, edx-enterprise
djangorestframework==3.12.4 # via -r requirements/edx/testing.txt, django-config-models, django-user-tasks, drf-jwt, drf-yasg, edx-api-doc-tools, edx-completion, edx-drf-extensions, edx-enterprise, edx-organizations, edx-proctoring, edx-submissions, ora2, rest-condition, super-csv
docopt==0.6.2 # via -r requirements/edx/testing.txt, xmodule
-docutils==0.16 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/testing.txt, botocore, m2r, sphinx
-drf-jwt==1.19.0 # via -r requirements/edx/testing.txt, edx-drf-extensions
+docutils==0.16 # via -r requirements/edx/testing.txt, botocore, m2r, sphinx
+drf-jwt==1.19.0 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/testing.txt, edx-drf-extensions
drf-yasg==1.20.0 # via -r requirements/edx/testing.txt, edx-api-doc-tools
edx-ace==1.1.0 # via -r requirements/edx/testing.txt
edx-analytics-data-api-client==0.17.0 # via -r requirements/edx/testing.txt
@@ -170,6 +171,7 @@ joblib==0.14.1 # via -c requirements/edx/../constraints.txt, -r requi
jsondiff==1.2.0 # via -r requirements/edx/testing.txt, edx-enterprise
jsonfield2==3.0.3 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, edx-celeryutils, edx-enterprise, edx-event-routing-backends, edx-proctoring, edx-submissions, lti-consumer-xblock, ora2
jsonschema==3.2.0 # via sphinxcontrib-openapi
+jwcrypto==0.9.1 # via -r requirements/edx/testing.txt, pylti1p3
kombu==4.6.11 # via -r requirements/edx/testing.txt, celery
laboratory==1.0.2 # via -r requirements/edx/testing.txt
lazy-object-proxy==1.6.0 # via -r requirements/edx/testing.txt, astroid
@@ -219,12 +221,13 @@ pycparser==2.20 # via -r requirements/edx/testing.txt, cffi
pycryptodomex==3.10.1 # via -r requirements/edx/testing.txt, edx-proctoring, lti-consumer-xblock, pyjwkest
pygments==2.8.1 # via -r requirements/edx/testing.txt, diff-cover, sphinx
pyjwkest==1.4.2 # via -r requirements/edx/testing.txt, edx-drf-extensions, lti-consumer-xblock
-pyjwt[crypto]==1.7.1 # via -r requirements/edx/testing.txt, drf-jwt, edx-rest-api-client, social-auth-core
+pyjwt[crypto]==1.7.1 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/testing.txt, drf-jwt, edx-rest-api-client, pylti1p3, social-auth-core
pylatexenc==2.10 # via -r requirements/edx/testing.txt, olxcleaner
pylint-celery==0.3 # via -r requirements/edx/testing.txt, edx-lint
pylint-django==2.4.2 # via -r requirements/edx/testing.txt, edx-lint
pylint-plugin-utils==0.6 # via -r requirements/edx/testing.txt, pylint-celery, pylint-django
pylint==2.7.4 # via -r requirements/edx/testing.txt, edx-lint, pylint-celery, pylint-django, pylint-plugin-utils
+pylti1p3==1.9.1 # via -r requirements/edx/testing.txt
pymongo==3.10.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, edx-opaque-keys, event-tracking, mongodbproxy, mongoengine
pynliner==0.8.0 # via -r requirements/edx/testing.txt
pyparsing==2.4.7 # via -r requirements/edx/testing.txt, chem, openedx-calc, packaging, pycontracts
@@ -256,7 +259,7 @@ recommender-xblock==1.4.9 # via -r requirements/edx/testing.txt
redis==3.5.3 # via -r requirements/edx/testing.txt
regex==2021.4.4 # via -r requirements/edx/testing.txt, nltk
requests-oauthlib==1.3.0 # via -r requirements/edx/testing.txt, social-auth-core
-requests==2.25.1 # via -r requirements/edx/testing.txt, analytics-python, coreapi, django-oauth-toolkit, edx-analytics-data-api-client, edx-bulk-grades, edx-drf-extensions, edx-enterprise, edx-event-routing-backends, edx-rest-api-client, geoip2, mailsnake, pyjwkest, python-swiftclient, requests-oauthlib, sailthru-client, slumber, social-auth-core, sphinx, tableauserverclient, transifex-client
+requests==2.25.1 # via -r requirements/edx/testing.txt, analytics-python, coreapi, django-oauth-toolkit, edx-analytics-data-api-client, edx-bulk-grades, edx-drf-extensions, edx-enterprise, edx-event-routing-backends, edx-rest-api-client, geoip2, mailsnake, pyjwkest, pylti1p3, python-swiftclient, requests-oauthlib, sailthru-client, slumber, social-auth-core, sphinx, tableauserverclient, transifex-client
rest-condition==1.0.3 # via -r requirements/edx/testing.txt, edx-drf-extensions
ruamel.yaml.clib==0.2.2 # via -r requirements/edx/testing.txt, ruamel.yaml
ruamel.yaml==0.17.4 # via -r requirements/edx/testing.txt, drf-yasg
@@ -269,12 +272,12 @@ semantic-version==2.8.5 # via -r requirements/edx/testing.txt, edx-drf-extensi
shapely==1.7.1 # via -r requirements/edx/testing.txt
simplejson==3.17.2 # via -r requirements/edx/testing.txt, sailthru-client, super-csv, xblock-utils
singledispatch==3.6.1 # via -r requirements/edx/testing.txt
-six==1.15.0 # via -r requirements/edx/pip-tools.txt, -r requirements/edx/testing.txt, analytics-python, bleach, bok-choy, chem, codejail, crowdsourcehinter-xblock, cryptography, django-countries, django-simple-history, edx-ace, edx-bulk-grades, edx-ccx-keys, edx-django-release-util, edx-drf-extensions, edx-enterprise, edx-i18n-tools, edx-lint, edx-milestones, edx-rbac, edx-sphinx-theme, event-tracking, freezegun, fs, fs-s3fs, html5lib, httpretty, isodate, jsonschema, libsass, paver, pip-tools, pycontracts, pyjwkest, python-dateutil, python-memcached, python-swiftclient, singledispatch, social-auth-app-django, social-auth-core, sphinxcontrib-httpdomain, stevedore, tox, transifex-client, virtualenv, xblock
+six==1.15.0 # via -r requirements/edx/pip-tools.txt, -r requirements/edx/testing.txt, analytics-python, bleach, bok-choy, chem, codejail, crowdsourcehinter-xblock, cryptography, django-countries, django-simple-history, edx-ace, edx-bulk-grades, edx-ccx-keys, edx-django-release-util, edx-drf-extensions, edx-enterprise, edx-i18n-tools, edx-lint, edx-milestones, edx-rbac, edx-sphinx-theme, event-tracking, freezegun, fs, fs-s3fs, html5lib, httpretty, isodate, jsonschema, jwcrypto, libsass, paver, pip-tools, pycontracts, pyjwkest, python-dateutil, python-memcached, python-swiftclient, singledispatch, social-auth-app-django, social-auth-core, sphinxcontrib-httpdomain, stevedore, tox, transifex-client, virtualenv, xblock
slumber==0.7.1 # via -r requirements/edx/testing.txt, edx-bulk-grades, edx-enterprise, edx-rest-api-client
smmap==4.0.0 # via -r requirements/edx/testing.txt, gitdb
snowballstemmer==2.1.0 # via sphinx
social-auth-app-django==4.0.0 # via -r requirements/edx/testing.txt
-social-auth-core==4.0.2 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/testing.txt, social-auth-app-django
+social-auth-core==4.0.2 # via -r requirements/edx/testing.txt, social-auth-app-django
sorl-thumbnail==12.7.0 # via -r requirements/edx/testing.txt, django-wiki
sortedcontainers==2.3.0 # via -r requirements/edx/testing.txt
soupsieve==2.2.1 # via -r requirements/edx/testing.txt, beautifulsoup4
@@ -303,7 +306,7 @@ tqdm==4.60.0 # via -r requirements/edx/testing.txt, nltk
transifex-client==0.14.2 # via -r requirements/edx/testing.txt
ua-parser==0.10.0 # via -r requirements/edx/testing.txt, django-cookies-samesite
unicodecsv==0.14.1 # via -r requirements/edx/testing.txt, edx-enterprise
-unidiff==0.6.0 # via -r requirements/edx/testing.txt, coverage-pytest-plugin
+unidiff==0.6.0 # via -r requirements/edx/testing.txt
uritemplate==3.0.1 # via -r requirements/edx/testing.txt, coreapi, drf-yasg
urllib3==1.26.4 # via -r requirements/edx/testing.txt, elasticsearch, geoip2, requests, selenium, transifex-client
user-util==1.0.0 # via -r requirements/edx/testing.txt
@@ -315,7 +318,7 @@ watchdog==2.0.2 # via -r requirements/edx/testing.txt
web-fragments==1.0.0 # via -r requirements/edx/testing.txt, crowdsourcehinter-xblock, edx-sga, staff-graded-xblock, xblock, xblock-utils
webencodings==0.5.1 # via -r requirements/edx/testing.txt, bleach, html5lib
webob==1.8.7 # via -r requirements/edx/testing.txt, xblock, xmodule
-wrapt==1.11.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, astroid
+wrapt==1.11.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/testing.txt, astroid, deprecated
git+https://github.com/edx-solutions/xblock-drag-and-drop-v2@v2.3.4#egg=xblock-drag-and-drop-v2==2.3.4 # via -r requirements/edx/testing.txt
git+https://github.com/open-craft/xblock-poll@922cd36fb1c3cfe00b4ce03b19a13185d136447d#egg=xblock-poll==1.10.2 # via -r requirements/edx/testing.txt
xblock-utils==2.1.2 # via -r requirements/edx/testing.txt, edx-sga, lti-consumer-xblock, staff-graded-xblock, xblock-drag-and-drop-v2, xblock-google-drive
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index 58b36d4fcb15..2c27a13ab6c1 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -11,7 +11,7 @@ chardet==4.0.0 # via requests
click==7.1.2 # via code-annotations
code-annotations==1.1.1 # via -r requirements/edx/doc.in
django==2.2.24 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, code-annotations
-docutils==0.16 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, sphinx
+docutils==0.16 # via sphinx
edx-sphinx-theme==2.1.0 # via -r requirements/edx/doc.in
gitdb==4.0.7 # via gitpython
gitpython==3.1.14 # via -r requirements/edx/doc.in
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 118d0f202c62..7d7fa993e2f7 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -50,12 +50,13 @@ coreapi==2.3.3 # via -r requirements/edx/base.txt, drf-yasg
coreschema==0.0.4 # via -r requirements/edx/base.txt, coreapi, drf-yasg
coverage==5.5 # via -r requirements/edx/coverage.txt, pytest-cov
crowdsourcehinter-xblock==0.6 # via -r requirements/edx/base.txt
-cryptography==3.2.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, django-fernet-fields, edx-enterprise, pyjwt, social-auth-core
+cryptography==3.2.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, django-fernet-fields, edx-enterprise, jwcrypto, pyjwt, social-auth-core
cssselect==1.1.0 # via -r requirements/edx/testing.in, pyquery
cssutils==2.2.0 # via -r requirements/edx/base.txt, pynliner
ddt==1.4.2 # via -r requirements/edx/base.txt, -r requirements/edx/testing.in, xblock-drag-and-drop-v2, xblock-poll
decorator==5.0.6 # via -r requirements/edx/base.txt, pycontracts
defusedxml==0.7.1 # via -r requirements/edx/base.txt, djangorestframework-xml, ora2, python3-openid, python3-saml, safe-lxml, social-auth-core
+deprecated==1.2.12 # via -r requirements/edx/base.txt, jwcrypto
diff-cover==4.0.0 # via -c requirements/edx/../constraints.txt, -r requirements/edx/coverage.txt
distlib==0.3.1 # via virtualenv
django-appconf==1.0.4 # via -r requirements/edx/base.txt, django-statici18n
@@ -95,8 +96,8 @@ django-webpack-loader==0.7.0 # via -r requirements/edx/base.txt, edx-proctoring
djangorestframework-xml==2.0.0 # via -r requirements/edx/base.txt, edx-enterprise
djangorestframework==3.12.4 # via -r requirements/edx/base.txt, django-config-models, django-user-tasks, drf-jwt, drf-yasg, edx-api-doc-tools, edx-completion, edx-drf-extensions, edx-enterprise, edx-organizations, edx-proctoring, edx-submissions, ora2, rest-condition, super-csv
docopt==0.6.2 # via -r requirements/edx/base.txt, xmodule
-docutils==0.16 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/base.txt, botocore
-drf-jwt==1.19.0 # via -r requirements/edx/base.txt, edx-drf-extensions
+docutils==0.16 # via -r requirements/edx/base.txt, botocore
+drf-jwt==1.19.0 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/base.txt, edx-drf-extensions
drf-yasg==1.20.0 # via -r requirements/edx/base.txt, edx-api-doc-tools
edx-ace==1.1.0 # via -r requirements/edx/base.txt
edx-analytics-data-api-client==0.17.0 # via -r requirements/edx/base.txt
@@ -164,6 +165,7 @@ jmespath==0.10.0 # via -r requirements/edx/base.txt, boto3, botocore
joblib==0.14.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, nltk
jsondiff==1.2.0 # via -r requirements/edx/base.txt, edx-enterprise
jsonfield2==3.0.3 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, edx-celeryutils, edx-enterprise, edx-event-routing-backends, edx-proctoring, edx-submissions, lti-consumer-xblock, ora2
+jwcrypto==0.9.1 # via -r requirements/edx/base.txt, pylti1p3
kombu==4.6.11 # via -r requirements/edx/base.txt, celery
laboratory==1.0.2 # via -r requirements/edx/base.txt
lazy-object-proxy==1.6.0 # via astroid
@@ -210,12 +212,13 @@ pycparser==2.20 # via -r requirements/edx/base.txt, cffi
pycryptodomex==3.10.1 # via -r requirements/edx/base.txt, edx-proctoring, lti-consumer-xblock, pyjwkest
pygments==2.8.1 # via -r requirements/edx/base.txt, -r requirements/edx/coverage.txt, diff-cover
pyjwkest==1.4.2 # via -r requirements/edx/base.txt, edx-drf-extensions, lti-consumer-xblock
-pyjwt[crypto]==1.7.1 # via -r requirements/edx/base.txt, drf-jwt, edx-rest-api-client, social-auth-core
+pyjwt[crypto]==1.7.1 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/base.txt, drf-jwt, edx-rest-api-client, pylti1p3, social-auth-core
pylatexenc==2.10 # via -r requirements/edx/base.txt, olxcleaner
pylint-celery==0.3 # via edx-lint
pylint-django==2.4.2 # via edx-lint
pylint-plugin-utils==0.6 # via pylint-celery, pylint-django
pylint==2.7.4 # via edx-lint, pylint-celery, pylint-django, pylint-plugin-utils
+pylti1p3==1.9.1 # via -r requirements/edx/base.txt
pymongo==3.10.1 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, edx-opaque-keys, event-tracking, mongodbproxy, mongoengine
pynliner==0.8.0 # via -r requirements/edx/base.txt
pyparsing==2.4.7 # via -r requirements/edx/base.txt, chem, openedx-calc, packaging, pycontracts
@@ -245,7 +248,7 @@ recommender-xblock==1.4.9 # via -r requirements/edx/base.txt
redis==3.5.3 # via -r requirements/edx/base.txt
regex==2021.4.4 # via -r requirements/edx/base.txt, nltk
requests-oauthlib==1.3.0 # via -r requirements/edx/base.txt, social-auth-core
-requests==2.25.1 # via -r requirements/edx/base.txt, analytics-python, coreapi, django-oauth-toolkit, edx-analytics-data-api-client, edx-bulk-grades, edx-drf-extensions, edx-enterprise, edx-event-routing-backends, edx-rest-api-client, geoip2, mailsnake, pyjwkest, python-swiftclient, requests-oauthlib, sailthru-client, slumber, social-auth-core, tableauserverclient, transifex-client
+requests==2.25.1 # via -r requirements/edx/base.txt, analytics-python, coreapi, django-oauth-toolkit, edx-analytics-data-api-client, edx-bulk-grades, edx-drf-extensions, edx-enterprise, edx-event-routing-backends, edx-rest-api-client, geoip2, mailsnake, pyjwkest, pylti1p3, python-swiftclient, requests-oauthlib, sailthru-client, slumber, social-auth-core, tableauserverclient, transifex-client
rest-condition==1.0.3 # via -r requirements/edx/base.txt, edx-drf-extensions
ruamel.yaml.clib==0.2.2 # via -r requirements/edx/base.txt, ruamel.yaml
ruamel.yaml==0.17.4 # via -r requirements/edx/base.txt, drf-yasg
@@ -258,11 +261,11 @@ semantic-version==2.8.5 # via -r requirements/edx/base.txt, edx-drf-extensions
shapely==1.7.1 # via -r requirements/edx/base.txt
simplejson==3.17.2 # via -r requirements/edx/base.txt, sailthru-client, super-csv, xblock-utils
singledispatch==3.6.1 # via -r requirements/edx/testing.in
-six==1.15.0 # via -r requirements/edx/base.txt, analytics-python, bleach, bok-choy, chem, codejail, crowdsourcehinter-xblock, cryptography, django-countries, django-simple-history, edx-ace, edx-bulk-grades, edx-ccx-keys, edx-django-release-util, edx-drf-extensions, edx-enterprise, edx-i18n-tools, edx-lint, edx-milestones, edx-rbac, event-tracking, freezegun, fs, fs-s3fs, html5lib, httpretty, isodate, libsass, paver, pycontracts, pyjwkest, python-dateutil, python-memcached, python-swiftclient, singledispatch, social-auth-app-django, social-auth-core, stevedore, tox, transifex-client, virtualenv, xblock
+six==1.15.0 # via -r requirements/edx/base.txt, analytics-python, bleach, bok-choy, chem, codejail, crowdsourcehinter-xblock, cryptography, django-countries, django-simple-history, edx-ace, edx-bulk-grades, edx-ccx-keys, edx-django-release-util, edx-drf-extensions, edx-enterprise, edx-i18n-tools, edx-lint, edx-milestones, edx-rbac, event-tracking, freezegun, fs, fs-s3fs, html5lib, httpretty, isodate, jwcrypto, libsass, paver, pycontracts, pyjwkest, python-dateutil, python-memcached, python-swiftclient, singledispatch, social-auth-app-django, social-auth-core, stevedore, tox, transifex-client, virtualenv, xblock
slumber==0.7.1 # via -r requirements/edx/base.txt, edx-bulk-grades, edx-enterprise, edx-rest-api-client
smmap==4.0.0 # via gitdb
social-auth-app-django==4.0.0 # via -r requirements/edx/base.txt
-social-auth-core==4.0.2 # via -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt, -r requirements/edx/base.txt, social-auth-app-django
+social-auth-core==4.0.2 # via -r requirements/edx/base.txt, social-auth-app-django
sorl-thumbnail==12.7.0 # via -r requirements/edx/base.txt, django-wiki
sortedcontainers==2.3.0 # via -r requirements/edx/base.txt
soupsieve==2.2.1 # via -r requirements/edx/base.txt, beautifulsoup4
@@ -282,7 +285,7 @@ tqdm==4.60.0 # via -r requirements/edx/base.txt, nltk
transifex-client==0.14.2 # via -r requirements/edx/testing.in
ua-parser==0.10.0 # via -r requirements/edx/base.txt, django-cookies-samesite
unicodecsv==0.14.1 # via -r requirements/edx/base.txt, edx-enterprise
-unidiff==0.6.0 # via -r requirements/edx/testing.in, coverage-pytest-plugin
+unidiff==0.6.0 # via -r requirements/edx/testing.in
uritemplate==3.0.1 # via -r requirements/edx/base.txt, coreapi, drf-yasg
urllib3==1.26.4 # via -r requirements/edx/base.txt, elasticsearch, geoip2, requests, selenium, transifex-client
user-util==1.0.0 # via -r requirements/edx/base.txt
@@ -293,7 +296,7 @@ watchdog==2.0.2 # via -r requirements/edx/base.txt
web-fragments==1.0.0 # via -r requirements/edx/base.txt, crowdsourcehinter-xblock, edx-sga, staff-graded-xblock, xblock, xblock-utils
webencodings==0.5.1 # via -r requirements/edx/base.txt, bleach, html5lib
webob==1.8.7 # via -r requirements/edx/base.txt, xblock, xmodule
-wrapt==1.11.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, astroid
+wrapt==1.11.2 # via -c requirements/edx/../constraints.txt, -r requirements/edx/base.txt, astroid, deprecated
git+https://github.com/edx-solutions/xblock-drag-and-drop-v2@v2.3.4#egg=xblock-drag-and-drop-v2==2.3.4 # via -r requirements/edx/base.txt
git+https://github.com/open-craft/xblock-poll@922cd36fb1c3cfe00b4ce03b19a13185d136447d#egg=xblock-poll==1.10.2 # via -r requirements/edx/base.txt
xblock-utils==2.1.2 # via -r requirements/edx/base.txt, edx-sga, lti-consumer-xblock, staff-graded-xblock, xblock-drag-and-drop-v2, xblock-google-drive