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