diff --git a/cms/envs/common.py b/cms/envs/common.py index c9b123e7c3c4..0fe8ffa379eb 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -121,16 +121,6 @@ # only supported in courses using split mongo. ENABLE_CONTENT_LIBRARIES = True -# .. toggle_name: settings.ENABLE_CONTENT_LIBRARIES_LTI_TOOL -# .. toggle_implementation: DjangoSetting -# .. toggle_default: False -# .. toggle_description: When set to True, Content Libraries in -# Studio can be used as an LTI 1.3 tool by external LTI platforms. -# .. toggle_use_cases: open_edx -# .. toggle_creation_date: 2021-08-17 -# .. toggle_tickets: https://github.com/openedx/edx-platform/pull/27411 -ENABLE_CONTENT_LIBRARIES_LTI_TOOL = False - # Toggle course entrance exams feature ENTRANCE_EXAMS = False @@ -320,7 +310,6 @@ def make_lms_template_path(settings): # use the ratelimit backend to prevent brute force attacks AUTHENTICATION_BACKENDS.insert(0, 'auth_backends.backends.EdXOAuth2') -AUTHENTICATION_BACKENDS.insert(2, 'openedx.core.djangoapps.content_libraries.auth.LtiAuthenticationBackend') LMS_BASE = None diff --git a/cms/envs/mock.yml b/cms/envs/mock.yml index 7d81959dfd99..f5ed5209ecbb 100644 --- a/cms/envs/mock.yml +++ b/cms/envs/mock.yml @@ -398,7 +398,6 @@ FEATURES: ENABLE_API_DOCS: true ENABLE_ASYNC_ANSWER_DISTRIBUTION: true ENABLE_COMBINED_LOGIN_REGISTRATION: true - ENABLE_CONTENT_LIBRARIES_LTI_TOOL: true ENABLE_CORS_HEADERS: true ENABLE_COUNTRY_ACCESS: true ENABLE_COURSEWARE_INDEX: true diff --git a/openedx/core/djangoapps/content_libraries/api/__init__.py b/openedx/core/djangoapps/content_libraries/api/__init__.py index b53e79ecf4de..377cb038a4b6 100644 --- a/openedx/core/djangoapps/content_libraries/api/__init__.py +++ b/openedx/core/djangoapps/content_libraries/api/__init__.py @@ -7,6 +7,5 @@ from .collections import * from .container_metadata import * from .containers import * -from .courseware_import import * from .exceptions import * from .libraries import * diff --git a/openedx/core/djangoapps/content_libraries/api/courseware_import.py b/openedx/core/djangoapps/content_libraries/api/courseware_import.py deleted file mode 100644 index f90b2f2cce55..000000000000 --- a/openedx/core/djangoapps/content_libraries/api/courseware_import.py +++ /dev/null @@ -1,353 +0,0 @@ -""" -Content Libraries Python API to import blocks from Courseware -============================================================= - -Content Libraries can import blocks from Courseware (Modulestore). The import -can be done per-course, by listing its content, and supports both access to -remote platform instances as well as local modulestore APIs. Additionally, -there are Celery-based interfaces suitable for background processing controlled -through RESTful APIs (see :mod:`.views`). -""" -import abc -import base64 -import collections -import hashlib -import logging - -import requests -from django.conf import settings -from edx_rest_api_client.client import OAuthAPIClient -from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocator as LibraryLocatorV1 -from opaque_keys.edx.locator import LibraryUsageLocatorV2 - -from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_openedx_content -from xmodule.modulestore.django import modulestore - -from .. import tasks -from ..models import ContentLibrary, ContentLibraryBlockImportTask -from .blocks import ( - LibraryBlockAlreadyExists, - add_library_block_static_asset_file, - create_library_block, - get_library_block, - get_library_block_static_asset_files, - set_library_block_olx, -) -from .libraries import publish_changes - -log = logging.getLogger(__name__) - -__all__ = [ - "EdxModulestoreImportClient", - "EdxApiImportClient", - "import_blocks_create_task", -] - - -class BaseEdxImportClient(abc.ABC): - """ - Base class for all courseware import clients. - - Import clients are wrappers tailored to implement the steps used in the - import APIs 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 __init__(self, library_key=None, library=None, use_course_key_as_block_id_suffix=True): - """ - Initialize an import client for a library. - - The method accepts either a library object or a key to a library object. - """ - self.use_course_key_as_block_id_suffix = use_course_key_as_block_id_suffix - if bool(library_key) == bool(library): - raise ValueError('Provide at least one of `library_key` or ' - '`library`, but not both.') - if library is None: - library = ContentLibrary.objects.get_by_key(library_key) - self.library = library - - @abc.abstractmethod - def get_block_data(self, block_key): - """ - Get the block's OLX and static files, if any. - """ - - @abc.abstractmethod - def get_export_keys(self, course_key): - """ - Get all exportable block keys of a given course. - """ - - @abc.abstractmethod - def get_block_static_data(self, asset_file): - """ - Get the contents of an asset_file.. - """ - - def import_block(self, modulestore_key): - """ - Import a single modulestore block. - """ - block_data = self.get_block_data(modulestore_key) - - # Get or create the block in the library. - # - # To dedup blocks from different courses with the same ID, we hash the - # course key into the imported block id. - - course_key_id = base64.b32encode( - hashlib.blake2s( - str(modulestore_key.course_key).encode() - ).digest() - )[:16].decode().lower() - - # add the course_key_id if use_course_key_as_suffix is enabled to increase the namespace. - # The option exists to not use the course key as a suffix because - # in order to preserve learner state in the v1 to v2 libraries migration, - # the v2 and v1 libraries' child block ids must be the same. - block_id = ( - # Prepend 'c' to allow changing hash without conflicts. - f"{modulestore_key.block_id}_c{course_key_id}" - if self.use_course_key_as_block_id_suffix - else f"{modulestore_key.block_id}" - ) - - log.info('Importing to library block: id=%s', block_id) - try: - library_block = create_library_block( - self.library.library_key, - modulestore_key.block_type, - block_id, - ) - dest_key = library_block.usage_key - except LibraryBlockAlreadyExists: - dest_key = LibraryUsageLocatorV2( - lib_key=self.library.library_key, - block_type=modulestore_key.block_type, - usage_id=block_id, - ) - get_library_block(dest_key) - log.warning('Library block already exists: Appending static files ' - 'and overwriting OLX: %s', str(dest_key)) - - # Handle static files. - - files = [ - f.path for f in - get_library_block_static_asset_files(dest_key) - ] - for filename, static_file in block_data.get('static_files', {}).items(): - if filename in files: - # Files already added, move on. - continue - file_content = self.get_block_static_data(static_file) - add_library_block_static_asset_file(dest_key, filename, file_content) - files.append(filename) - - # Import OLX. - - set_library_block_olx(dest_key, block_data['olx']) - - def import_blocks_from_course(self, course_key, progress_callback): - """ - Import all eligible blocks from course key. - - Progress is reported through ``progress_callback``, guaranteed to be - called within an exception handler if ``exception is not None``. - """ - - # Query the course and rerieve all course blocks. - - export_keys = self.get_export_keys(course_key) - if not export_keys: - raise ValueError(f"The courseware course {course_key} does not have " - "any exportable content. No action taken.") - - # Import each block, skipping the ones that fail. - - for index, block_key in enumerate(export_keys): - try: - log.info('Importing block: %s/%s: %s', index + 1, len(export_keys), block_key) - self.import_block(block_key) - except Exception as exc: # pylint: disable=broad-except - log.exception("Error importing block: %s", block_key) - progress_callback(block_key, index + 1, len(export_keys), exc) - else: - log.info('Successfully imported: %s/%s: %s', index + 1, len(export_keys), block_key) - progress_callback(block_key, index + 1, len(export_keys), None) - - log.info("Publishing library: %s", self.library.library_key) - publish_changes(self.library.library_key) - - -class EdxModulestoreImportClient(BaseEdxImportClient): - """ - An import client based on the local instance of modulestore. - """ - - def __init__(self, modulestore_instance=None, **kwargs): - """ - Initialize the client with a modulestore instance. - """ - super().__init__(**kwargs) - 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 = serialize_modulestore_block_for_openedx_content(block) - return {'olx': data.olx_str, - 'static_files': {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) - if isinstance(course_key, LibraryLocatorV1): - course = self.modulestore.get_library(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 EdxApiImportClient(BaseEdxImportClient): - """ - An import client based on a remote Open Edx API interface. - - TODO: Look over this class. We'll probably need to completely re-implement - the import process. - """ - - 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, *args, **kwargs): - """ - Initialize the API client with URLs and OAuth keys. - """ - super().__init__(**kwargs) - 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._call('get', path) - else: - resp = requests.get(asset_file['url']) - resp.raise_for_status() - return resp.content - - def _get(self, *args, **kwargs): - """ - Perform a get request to the client. - """ - return self._json_call('get', *args, **kwargs) - - 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, **kwargs): - """ - Wrapper around request calls that ensures valid json responses. - """ - return self._call(method, *args, **kwargs).json() - - def _call(self, method, *args, **kwargs): - """ - Wrapper around request calls. - """ - response = getattr(self.oauth_client, method)(*args, **kwargs) - response.raise_for_status() - return response - - -def import_blocks_create_task(library_key, course_key, use_course_key_as_block_id_suffix=True): - """ - Create a new import block task. - - This API will schedule a celery task to perform the import, and it returns a - import task object for polling. - """ - library = ContentLibrary.objects.get_by_key(library_key) - import_task = ContentLibraryBlockImportTask.objects.create( - library=library, - course_id=course_key, - ) - result = tasks.import_blocks_from_course.apply_async( - args=(import_task.pk, str(course_key), use_course_key_as_block_id_suffix) - ) - log.info(f"Import block task created: import_task={import_task} " - f"celery_task={result.id}") - return import_task diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index 72669756e985..827db78406e7 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -139,7 +139,6 @@ class ContentLibraryMetadata: # has_unpublished_deletes will be true when the draft version of the library's bundle # contains deletes of any XBlocks that were in the most recently published version has_unpublished_deletes: bool = False - allow_lti: bool = False # Allow any user (even unregistered users) to view and interact directly # with this library's content in the LMS allow_public_learning: bool = False @@ -404,7 +403,6 @@ def get_library(library_key: LibraryLocatorV2) -> ContentLibraryMetadata: published_by=published_by, last_draft_created=last_draft_created, last_draft_created_by=last_draft_created_by, - allow_lti=ref.allow_lti, allow_public_learning=ref.allow_public_learning, allow_public_read=ref.allow_public_read, has_unpublished_changes=has_unpublished_changes, diff --git a/openedx/core/djangoapps/content_libraries/auth.py b/openedx/core/djangoapps/content_libraries/auth.py deleted file mode 100644 index 9532f3d53b76..000000000000 --- a/openedx/core/djangoapps/content_libraries/auth.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -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 deleted file mode 100644 index cddabdcd687c..000000000000 --- a/openedx/core/djangoapps/content_libraries/management/commands/content_libraries_import.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Command to import modulestore content into Content Libraries. -""" - -import argparse -import logging - -from django.conf import settings -from django.core.management import BaseCommand, CommandError -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import LibraryLocatorV2 - -from openedx.core.djangoapps.content_libraries import api as contentlib_api - -log = logging.getLogger(__name__) - - -class Command(BaseCommand): - """ - Import modulestore content references from 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 instance to retrieve blocks database on " - "the instance where the command is being run. You don't need " - "to enable API access.") - ) - - def handle(self, *args, **options): - """ - Collect all blocks from a course that are "importable" and write them to the - a openedx_content library. - """ - - # Search for the library. - - try: - 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 'api' requires one of the " - "--oauth-* options, and none was specified.") - edx_client = contentlib_api.EdxApiImportClient( - options['lms_url'], - options['studio_url'], - oauth_key, - oauth_secret, - library_key=options['library-key'], - ) - elif options['method'] == 'modulestore': - edx_client = contentlib_api.EdxModulestoreImportClient( - library_key=options['library-key'], - ) - else: - raise CommandError(f"Method not supported: {options['method']}") - - failed_blocks = [] - - def on_progress(block_key, block_num, block_count, exception=None): - self.stdout.write(f"{block_num}/{block_count}: {block_key}: ", ending='') - # In case stdout is a term and line buffered: - self.stdout.flush() - if exception: - self.stdout.write(self.style.ERROR('❌')) - log.error('Failed to import block: %s', block_key, exc_info=exception) - failed_blocks.append(block_key) - else: - self.stdout.write(self.style.SUCCESS('✓')) - - edx_client.import_blocks_from_course(options['course-key'], on_progress) - - if failed_blocks: - self.stdout.write(self.style.ERROR(f"❌ {len(failed_blocks)} failed:")) - for key in failed_blocks: - self.stdout.write(self.style.ERROR(str(key))) diff --git a/openedx/core/djangoapps/content_libraries/migrations/0013_remove_unused_lti_tool.py b/openedx/core/djangoapps/content_libraries/migrations/0013_remove_unused_lti_tool.py new file mode 100644 index 000000000000..10babdc058cf --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/migrations/0013_remove_unused_lti_tool.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.12 on 2026-04-07 17:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_libraries', '0001_squashed_0012_switch_to_openedx_content'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='ltigradedresource', + unique_together=None, + ), + migrations.RemoveField( + model_name='ltigradedresource', + name='profile', + ), + migrations.AlterUniqueTogether( + name='ltiprofile', + unique_together=None, + ), + migrations.RemoveField( + model_name='ltiprofile', + name='user', + ), + migrations.RemoveField( + model_name='contentlibrary', + name='authorized_lti_configs', + ), + migrations.DeleteModel( + name='ContentLibraryBlockImportTask', + ), + migrations.DeleteModel( + name='LtiGradedResource', + ), + migrations.DeleteModel( + name='LtiProfile', + ), + ] diff --git a/openedx/core/djangoapps/content_libraries/models.py b/openedx/core/djangoapps/content_libraries/models.py index 0dd1458d00e0..e0185061eda9 100644 --- a/openedx/core/djangoapps/content_libraries/models.py +++ b/openedx/core/djangoapps/content_libraries/models.py @@ -4,59 +4,24 @@ ======================== This module contains the models for new Content Libraries. - -LTI 1.3 Models -============== - -Content Libraries serves openedx_content-based 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 LTI 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 (openedx_content). 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. """ from __future__ import annotations -import contextlib import logging -import uuid import warnings from typing import ClassVar 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, transaction +from django.db import models from django.utils.translation import gettext_lazy as _ -from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_content.models_api import LearningPackage from organizations.models import Organization # lint-amnesty, pylint: disable=wrong-import-order -from pylti1p3.contrib.django import DjangoDbToolConf, DjangoMessageLaunch -from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiTool -from pylti1p3.grade import Grade from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED, LICENSE_OPTIONS -from .apps import ContentLibrariesConfig - log = logging.getLogger(__name__) @@ -139,15 +104,6 @@ class ContentLibrary(models.Model): """), ) - authorized_lti_configs = models.ManyToManyField( - LtiTool, - related_name='content_libraries', - help_text=("List of authorized LTI tool configurations that can access " - "this library's content through LTI launches, if empty no LTI " - "launch is allowed."), - blank=True, - ) - class Meta: verbose_name_plural = "Content Libraries" unique_together = ("org", "slug") @@ -159,29 +115,6 @@ def library_key(self): """ return LibraryLocatorV2(org=self.org.short_name, slug=self.slug) - @property - def allow_lti(self): - """ - True if there is at least one LTI tool configuration associated if this - library. - """ - return self.authorized_lti_configs.exists() - - @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. - """ - return (ContentLibrary - .objects - .filter(authorized_lti_configs__issuer=issuer, - authorized_lti_configs__client_id=client_id, - authorized_lti_configs__is_active=True, - org__short_name=library_key.org, - slug=library_key.slug) - .exists()) - def __str__(self): return f"ContentLibrary ({str(self.library_key)})" @@ -231,327 +164,3 @@ 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 ContentLibraryBlockImportTask(models.Model): - """ - Model of a task to import blocks from an external source (e.g. modulestore). - - .. no_pii: - """ - - library = models.ForeignKey( - ContentLibrary, - on_delete=models.CASCADE, - related_name='import_tasks', - ) - - TASK_CREATED = 'created' - TASK_PENDING = 'pending' - TASK_RUNNING = 'running' - TASK_FAILED = 'failed' - TASK_SUCCESSFUL = 'successful' - - TASK_STATE_CHOICES = ( - (TASK_CREATED, _('Task was created, but not queued to run.')), - (TASK_PENDING, _('Task was created and queued to run.')), - (TASK_RUNNING, _('Task is running.')), - (TASK_FAILED, _('Task finished, but some blocks failed to import.')), - (TASK_SUCCESSFUL, _('Task finished successfully.')), - ) - - state = models.CharField( - choices=TASK_STATE_CHOICES, - default=TASK_CREATED, - max_length=30, - verbose_name=_('state'), - help_text=_('The state of the block import task.'), - ) - - progress = models.FloatField( - default=0.0, - verbose_name=_('progress'), - help_text=_('A float from 0.0 to 1.0 representing the task progress.'), - ) - - course_id = CourseKeyField( - max_length=255, - db_index=True, - verbose_name=_('course ID'), - help_text=_('ID of the imported course.'), - ) - - created_at = models.DateTimeField(auto_now_add=True) - - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ['-created_at', '-updated_at'] - - @classmethod - @contextlib.contextmanager - def execute(cls, import_task_id): - """ - A context manager to manage a task that is being executed. - """ - self = cls.objects.get(pk=import_task_id) - self.state = self.TASK_RUNNING - self.save() - try: - yield self - self.state = self.TASK_SUCCESSFUL - except: # pylint: disable=broad-except - self.state = self.TASK_FAILED - raise - finally: - self.save() - - def save_progress(self, progress): - self.progress = progress - self.save(update_fields=['progress', 'updated_at']) - - def __str__(self): - return f'{self.course_id} to {self.library} #{self.pk}' - - -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 Libraries 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. - - .. no_pii: - """ - - objects = LtiProfileManager() - - user = models.OneToOneField( - get_user_model(), - null=True, - on_delete=models.CASCADE, - related_name='contentlibraries_lti_profile', - # Translators: 'Open edX' is a trademark, please keep this untranslated - verbose_name=_('open edx user'), - ) - - platform_id = models.CharField( - max_length=255, - verbose_name=_('lti 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 LTI 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 - resource, _ = self.update_or_create( - profile=lti_profile, - usage_key=block.scope_ids.usage_id, - defaults={'resource_title': resource_title, - 'resource_id': resource_id, - 'ags_lineitem': lineitem} - ) - 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. - - .. no_pii: - """ - - objects = LtiGradedResourceManager() - - profile = models.ForeignKey( - LtiProfile, - on_delete=models.CASCADE, - related_name='lti_resources', - help_text=_('The authorized LTI profile that launched the resource ' - '(identifies the user).')) - - usage_key = UsageKeyField( - max_length=255, - help_text=_('The usage key string of the resource serving the ' - 'content of this launch.'), - ) - - resource_id = models.CharField( - max_length=255, - help_text=_('The LTI platform unique identifier of this resource, also ' - 'known as the "resource link id".'), - ) - - resource_title = models.CharField( - max_length=255, - null=True, - help_text=_('The LTI platform descriptive title for this resource.'), - ) - - 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 LTI platform's gradebook. - - This method synchronously send a request to the LTI platform to update - the assignment score. - """ - - 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 str(self.usage_key) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index d9de4917a03b..27ae788e3fbd 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -6,7 +6,6 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.transaction import non_atomic_requests from django.http import Http404, HttpResponse, StreamingHttpResponse -from django.urls import reverse from django.utils.decorators import method_decorator from drf_yasg.utils import swagger_auto_schema from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 @@ -282,28 +281,6 @@ def patch(self, request: RestRequest, usage_key_str) -> Response: return Response({'count': len(collection_keys)}) -@method_decorator(non_atomic_requests, name="dispatch") -@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}) - - @method_decorator(non_atomic_requests, name="dispatch") @view_auth_classes() class LibraryBlockOlxView(APIView): diff --git a/openedx/core/djangoapps/content_libraries/rest_api/libraries.py b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py index 4aa24ced3846..33156ee79be5 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py @@ -62,40 +62,29 @@ to openedx_content) atomic: https://github.com/openedx/edx-platform/pull/30456 """ -import itertools -import json import logging import warnings import edx_api_doc_tools as apidocs -from django.conf import settings -from django.contrib.auth import authenticate, get_user_model, login +from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.db.transaction import atomic, non_atomic_requests -from django.http import Http404, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.utils.translation import gettext 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, View from drf_yasg.utils import swagger_auto_schema from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_authz.constants import permissions as authz_permissions from organizations.api import ensure_organization from organizations.exceptions import InvalidOrganizationException from organizations.models import Organization -from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin -from pylti1p3.exception import LtiException, OIDCException from rest_framework import status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.viewsets import GenericViewSet from user_tasks.models import UserTaskStatus -import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from cms.djangoapps.contentstore.storage import course_import_export_storage from cms.djangoapps.contentstore.views.course import ( get_allowed_organizations_for_libraries, @@ -105,8 +94,6 @@ from openedx.core.djangoapps.content_libraries.api.libraries import get_backup_task_status from openedx.core.djangoapps.content_libraries.rest_api.serializers import ( ContentLibraryAddPermissionByEmailSerializer, - ContentLibraryBlockImportTaskCreateSerializer, - ContentLibraryBlockImportTaskSerializer, ContentLibraryFilterSerializer, ContentLibraryMetadataSerializer, ContentLibraryPermissionLevelSerializer, @@ -123,11 +110,8 @@ PublishableItemSerializer, ) from openedx.core.djangoapps.content_libraries.tasks import backup_library, restore_library -from openedx.core.djangoapps.safe_sessions.middleware import mark_user_change_as_expected -from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.lib.api.view_utils import view_auth_classes -from ..models import ContentLibrary, LtiGradedResource, LtiProfile from .utils import convert_exceptions User = get_user_model() @@ -692,75 +676,6 @@ def delete(self, request, usage_key_str): # pylint: disable=unused-argument return Response({}) -@method_decorator(non_atomic_requests, name="dispatch") -@view_auth_classes() -class LibraryImportTaskViewSet(GenericViewSet): - """ - Import blocks from Courseware through modulestore. - """ - - queryset = [] # type: ignore[assignment] - serializer_class = ContentLibraryBlockImportTaskSerializer - - @convert_exceptions - def list(self, request, lib_key_str): - """ - List all import tasks for this library. - """ - library_key = LibraryLocatorV2.from_string(lib_key_str) - api.require_permission_for_library_key( - library_key, - request.user, - permissions.CAN_VIEW_THIS_CONTENT_LIBRARY - ) - queryset = ContentLibrary.objects.get_by_key(library_key).import_tasks - result = ContentLibraryBlockImportTaskSerializer(queryset, many=True).data - - return self.get_paginated_response( - self.paginate_queryset(result) - ) - - @convert_exceptions - @swagger_auto_schema( - request_body=ContentLibraryBlockImportTaskCreateSerializer, - responses={200: ContentLibraryBlockImportTaskSerializer} - ) - def create(self, request, lib_key_str): - """ - Create and queue an import tasks for this library. - """ - - library_key = LibraryLocatorV2.from_string(lib_key_str) - api.require_permission_for_library_key( - library_key, - request.user, - permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, - ) - - serializer = ContentLibraryBlockImportTaskCreateSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - course_key = serializer.validated_data['course_key'] - - import_task = api.import_blocks_create_task(library_key, course_key) - return Response(ContentLibraryBlockImportTaskSerializer(import_task).data) - - @convert_exceptions - def retrieve(self, request, lib_key_str, pk=None): - """ - Retrieve a import task for inspection. - """ - - library_key = LibraryLocatorV2.from_string(lib_key_str) - api.require_permission_for_library_key( - library_key, - request.user, - permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, - ) - - import_task = api.ContentLibraryBlockImportTask.objects.get(pk=pk) - return Response(ContentLibraryBlockImportTaskSerializer(import_task).data) - - # Library Backup Views # ==================== @@ -938,273 +853,3 @@ def get(self, request): # serialize and return result result_serializer = LibraryRestoreTaskResultSerializer.from_task_status(task_status, request) return Response(result_serializer.data) - - -# LTI 1.3 Views -# ============= - - -def requires_lti_enabled(view_func): - """ - Modify the view function to raise 404 if content librarie LTI tool was not - enabled. - """ - def wrapped_view(*args, **kwargs): - lti_enabled = (settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES') - and settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES_LTI_TOOL')) - if not lti_enabled: - raise Http404() - return view_func(*args, **kwargs) - return wrapped_view - - -@method_decorator(non_atomic_requests, name="dispatch") -@method_decorator(requires_lti_enabled, name='dispatch') -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(non_atomic_requests, name="dispatch") -@method_decorator(csrf_exempt, name='dispatch') -class LtiToolLoginView(LtiToolView): - """ - Third-party Initiated Login view. - - The LTI 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 LTI platform's authentication - URL. - """ - - LAUNCH_URI_PARAMETER = 'target_link_uri' - - def get(self, request): - return 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(non_atomic_requests, name="dispatch") -@method_decorator(csrf_exempt, name='dispatch') -@method_decorator(xframe_options_exempt, name='dispatch') -class LtiToolLaunchView(TemplateResponseMixin, LtiToolView): - """ - LTI platform tool launch view. - - The launch view supports resource link launches and AGS, when enabled by the - LTI platform. Other features and resouces are ignored. - """ - - template_name = 'xblock_v2/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') - lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) - return { - 'fragment': fragment, - 'handler_urls_json': json.dumps(handler_urls), - 'lms_root_url': 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. - - edx_user = self._authenticate_and_login(usage_key) - if not edx_user: - 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() - response = self.render_to_response(context) - mark_user_change_as_expected(edx_user.id) - return response - - 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: LTI platform does not support a required ' - 'scope: %s', scope) - return - lineitem = endpoint.get('lineitem') - if not lineitem: - log.info("LTI 1.3: AGS: LTI 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) - - -@method_decorator(non_atomic_requests, name="dispatch") -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/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index aae0ed9030eb..72c59f695833 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -17,11 +17,9 @@ from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED, LICENSE_OPTIONS from openedx.core.djangoapps.content_libraries.models import ( ContentLibrary, - ContentLibraryBlockImportTask, ContentLibraryPermission, ) from openedx.core.djangoapps.content_libraries.tasks import LibraryRestoreTask -from openedx.core.lib.api.serializers import CourseKeyField from .. import permissions @@ -50,7 +48,6 @@ class ContentLibraryMetadataSerializer(serializers.Serializer): published_by = serializers.CharField(read_only=True) last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) last_draft_created_by = serializers.CharField(read_only=True) - allow_lti = serializers.BooleanField(default=False, read_only=True) allow_public_learning = serializers.BooleanField(default=False) allow_public_read = serializers.BooleanField(default=False) has_unpublished_changes = serializers.BooleanField(read_only=True) @@ -282,32 +279,6 @@ class LibraryContainerUpdateSerializer(serializers.Serializer): display_name = serializers.CharField() -class ContentLibraryBlockImportTaskSerializer(serializers.ModelSerializer): - """ - Serializer for a Content Library block import task. - """ - - org = serializers.SerializerMethodField() - - def get_org(self, obj): - return obj.course_id.org - - class Meta: - model = ContentLibraryBlockImportTask - fields = '__all__' - - -class ContentLibraryBlockImportTaskCreateSerializer(serializers.Serializer): - """ - Serializer to create a new block import task. - - The serializer accepts the following parameter: - - - The courseware course key to import blocks from. - """ - - course_key = CourseKeyField() - class ContentLibraryCollectionSerializer(serializers.ModelSerializer): """ diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py index e566aeffa404..041a49b473e0 100644 --- a/openedx/core/djangoapps/content_libraries/signal_handlers.py +++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py @@ -4,11 +4,10 @@ import logging -from django.conf import settings from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver -from opaque_keys import InvalidKeyError, OpaqueKey -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from opaque_keys import OpaqueKey +from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_content.api import get_components, get_containers from openedx_content.models_api import Collection, CollectionPublishableEntity, PublishableEntity from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData @@ -19,54 +18,12 @@ LIBRARY_COLLECTION_UPDATED, ) -from lms.djangoapps.grades.api import signals as grades_signals - from .api import library_collection_locator, library_component_usage_key, library_container_locator -from .models import ContentLibrary, LtiGradedResource +from .models import ContentLibrary 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. - """ - - lti_enabled = (settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES') - and settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES_LTI_TOOL')) - if not lti_enabled: - return - - 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.debug("LTI 1.3: Score Signal: Missing a required parameters, " - "ignoring: kwargs=%s", kwargs) - return - try: - usage_key = LibraryUsageLocatorV2.from_string(usage_id) - except InvalidKeyError: - log.debug("LTI 1.3: Score Signal: Not a content libraries v2 usage key, " - "ignoring: usage_id=%s", usage_id) - return - try: - resource = LtiGradedResource.objects.get_from_user_id( - user_id, usage_key=usage_key - ) - except LtiGradedResource.DoesNotExist: - log.debug("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) - - @receiver(post_save, sender=Collection, dispatch_uid="library_collection_saved") def library_collection_saved(sender, instance, created, **kwargs): """ diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index aa27f646b1f1..a8fd398ad04b 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -38,7 +38,6 @@ set_code_owner_attribute_from_module, set_custom_attribute, ) -from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import ( BlockUsageLocator, LibraryCollectionLocator, @@ -75,7 +74,6 @@ from xmodule.modulestore.mixed import MixedModuleStore from . import api -from .models import ContentLibraryBlockImportTask log = logging.getLogger(__name__) TASK_LOGGER = get_task_logger(__name__) @@ -313,33 +311,6 @@ def wait_for_post_revert_events(draft_change_log: DraftChangeLog, library_key: L # the background by the celery worker until everything is updated. -@shared_task(base=LoggedTask) -@set_code_owner_attribute -def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_block_id_suffix=True): - """ - A Celery task to import blocks from a course through modulestore. - """ - ensure_cms("import_blocks_from_course may only be executed in a CMS context") - - course_key = CourseKey.from_string(course_key_str) - - with ContentLibraryBlockImportTask.execute(import_task_id) as import_task: - - def on_progress(block_key, block_num, block_count, exception=None): - if exception: - log.exception('Import block failed: %s', block_key) - else: - log.info('Import block succesful: %s', block_key) - import_task.save_progress(block_num / block_count) - - edx_client = api.EdxModulestoreImportClient( - library=import_task.library, - use_course_key_as_block_id_suffix=use_course_key_as_block_id_suffix - ) - edx_client.import_blocks_from_course( - course_key, on_progress - ) - def _filter_child(store, usage_key, capa_type): """ diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 4be4d8cfb1f0..dce317c3ec71 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -53,10 +53,6 @@ URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library -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_EMBED_VIEW = '/xblocks/v2/{block_key}/embed/{view_name}/' # Returns HTML not JSON so its URL is different URL_BLOCK_GET_HANDLER_URL = '/api/xblock/v2/xblocks/{block_key}/handler_url/{handler_name}/' diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 607179270900..bfd0657f8364 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -2,14 +2,11 @@ Tests for Content Library internal api. """ -import base64 -import hashlib import uuid from unittest import mock from django.db import transaction -from django.test import TestCase -from opaque_keys.edx.keys import CourseKey, UsageKey, UsageKeyV2 +from opaque_keys.edx.keys import UsageKeyV2 from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_authz.api.users import get_user_role_assignments_in_scope from openedx_content import api as content_api @@ -42,232 +39,6 @@ from .base import ContentLibrariesRestApiTest -class EdxModulestoreImportClientTest(TestCase): - """ - Tests for course importing APIs. - """ - - def setUp(self): - """ - Setup mocks and the test client. - """ - super().setUp() - self.mock_library = mock.MagicMock() - self.modulestore_mock = mock.MagicMock() - self.client = api.EdxModulestoreImportClient( - modulestore_instance=self.modulestore_mock, - library=self.mock_library - ) - - def test_instantiate_without_args(self): - """ - When instantiated without args, - Then raises. - """ - with self.assertRaises(ValueError): - api.EdxModulestoreImportClient() - - def test_import_blocks_from_course_without_course(self): - """ - Given no course, - Then raises. - """ - self.modulestore_mock.get_course.return_value.get_children.return_value = [] - with self.assertRaises(ValueError): - self.client.import_blocks_from_course('foobar', lambda *_: None) - - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.publish_changes') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.set_library_block_olx') - def test_import_blocks_from_course_on_block_with_olx( - self, - mock_set_library_block_olx, - mock_publish_changes, - mock_get_library_block_static_asset_files, - mock_get_library_block, - mock_create_library_block, - ): - """ - Given a course with one block - When called - Then extract OLX, write to library and publish. - """ - - usage_key_str = 'lb:foo:bar:foobar:1234' - library_key_str = 'lib:foo:bar' - - self.client.get_export_keys = mock.MagicMock(return_value=[UsageKey.from_string(usage_key_str)]) - self.client.get_block_data = mock.MagicMock(return_value={'olx': 'fake-olx'}) - - mock_create_library_block.side_effect = api.LibraryBlockAlreadyExists - self.mock_library.library_key = LibraryLocatorV2.from_string(library_key_str) - - self.client.import_blocks_from_course('foobar', lambda *_: None) - - mock_get_library_block.assert_called_once() - mock_get_library_block_static_asset_files.assert_called_once() - mock_set_library_block_olx.assert_called_once_with( - mock.ANY, 'fake-olx') - mock_publish_changes.assert_called_once() - - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.set_library_block_olx') - def test_import_block_when_called_twice_same_block_but_different_course( - self, - mock_set_library_block_olx, - mock_get_library_block_static_asset_files, - mock_create_library_block, - ): - """ - Given an block used by one course - And another block with same id use by a different course - And import_block() was called on the first block - When import_block() is called on the second block - Then create a library block for the second block - """ - course_key_str = 'block-v1:FakeCourse+FakeOrg+FakeRun+type@a-fake-block-type+block@fake-block-id' - - modulestore_usage_key = UsageKey.from_string(course_key_str) - expected_course_key_hash = base64.b32encode( - hashlib.blake2s( - str(modulestore_usage_key.course_key).encode() - ).digest() - )[:16].decode().lower() - expected_usage_id = f"{modulestore_usage_key.block_id}_c{expected_course_key_hash}" - - self.client.get_block_data = mock.MagicMock() - self.client.import_block(modulestore_usage_key) - - mock_create_library_block.assert_called_with( - self.client.library.library_key, - modulestore_usage_key.block_type, - expected_usage_id) - mock_get_library_block_static_asset_files.assert_called_once() - mock_set_library_block_olx.assert_called_once() - - -@mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.OAuthAPIClient') -class EdxApiImportClientTest(TestCase): - """ - Tests for EdxApiImportClient. - """ - - LMS_URL = 'https://foobar_lms.example.com/' - - STUDIO_URL = 'https://foobar_studio.example.com/' - - library_key_str = 'lib:foobar_content:foobar_library' - - course_key_str = 'course-v1:AFakeCourse+FooBar+1' - - def create_mock_library(self, *, course_id=None, course_key_str=None): - """ - Create a library mock. - """ - mock_library = mock.MagicMock() - mock_library.library_key = LibraryLocatorV2.from_string( - self.library_key_str - ) - if course_key_str is None: - course_key_str = self.course_key_str - if course_id is None: - course_id = CourseKey.from_string(course_key_str) - type(mock_library).course_id = mock.PropertyMock(return_value=course_id) - return mock_library - - def create_client(self, *, mock_library=None): - """ - Create a edX API import client mock. - """ - return api.EdxApiImportClient( - self.LMS_URL, - self.STUDIO_URL, - 'foobar_oauth_key', - 'foobar_oauth_secret', - library=(mock_library or self.create_mock_library()), - ) - - def mock_oauth_client_response(self, mock_oauth_client, *, content=None, exception=None): - """ - Setup a mock response for oauth client GET calls. - """ - mock_response = mock.MagicMock() - mock_content = None - if exception: - mock_response.raise_for_status.side_effect = exception - if content: - mock_content = mock.PropertyMock(return_value='foobar_file_content') - type(mock_response).content = mock_content - mock_oauth_client.get.return_value = mock_response - if mock_content: - return mock_response, mock_content - return mock_response - - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.add_library_block_static_asset_file') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.publish_changes') - @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.set_library_block_olx') - def test_import_block_when_url_is_from_studio( - self, - mock_set_library_block_olx, - mock_publish_changes, - mock_get_library_block_static_asset_files, - mock_create_library_block, - mock_add_library_block_static_asset_file, - mock_oauth_client_class, - ): - """ - Given an block with one asset provided by a studio. - When import_block() is called on the block. - Then a GET to the API endpoint is. - """ - - # Setup mocks. - - static_filename = 'foobar_filename' - static_content = 'foobar_file_content' - block_olx = 'foobar-olx' - usage_key = UsageKey.from_string('lb:foo:bar:foobar:1234') - # We ensure ``export-file`` belongs to the URL. - asset_studio_url = f"{self.STUDIO_URL}/foo/bar/export-file/foo/bar" - block_data = { - 'olx': block_olx, - 'static_files': {static_filename: {'url': asset_studio_url}} - } - _, mock_content = self.mock_oauth_client_response( - mock_oauth_client_class.return_value, - content=static_content, - ) - mock_create_library_block.return_value.usage_key = usage_key - - # Create client and call. - - client = self.create_client() - client.get_block_data = mock.MagicMock(return_value=block_data) - client.import_block(usage_key) - - # Assertions. - - client.get_block_data.assert_called_once_with(usage_key) - mock_create_library_block.assert_called_once() - mock_get_library_block_static_asset_files.assert_called_once() - mock_content.assert_called() - mock_add_library_block_static_asset_file.assert_called_once_with( - usage_key, - static_filename, - static_content - ) - mock_set_library_block_olx.assert_called_once_with( - usage_key, - block_olx - ) - mock_publish_changes.assert_not_called() - - class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest): """ Tests for Content Library API collections methods. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_auth.py b/openedx/core/djangoapps/content_libraries/tests/test_auth.py deleted file mode 100644 index d01c46eba482..000000000000 --- a/openedx/core/djangoapps/content_libraries/tests/test_auth.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Unit tests for Content Libraries authentication module. -""" - - -from django.test import TestCase - -from ..auth import LtiAuthenticationBackend -from ..models import LtiProfile, get_user_model - - -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 deleted file mode 100644 index 1cbcebfdffe0..000000000000 --- a/openedx/core/djangoapps/content_libraries/tests/test_command_content_libraries_import.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Unit tests for content_libraries_import command. -""" - - -from io import StringIO -from unittest import mock - -from django.core.management import call_command -from django.core.management.base import CommandError -from django.test import TestCase - - -@mock.patch('openedx.core.djangoapps.content_libraries.management.commands.content_libraries_import.contentlib_api') -class ContentLibrariesImportTest(TestCase): - """ - Unit tests for content_libraries_import command. - """ - - library_key_str = 'lib:foo:bar' - - course_key_str = 'course-v1:foo+bar+foobar' - - def call_command(self, *args, **kwargs): - """ - Call command with default test paramters. - """ - out = StringIO() - kwargs['stdout'] = out - library_key = kwargs.pop('library_key', self.library_key_str) - course_key = kwargs.pop('course_key', self.course_key_str) - call_command('content_libraries_import', library_key, course_key, - 'api', - '--oauth-creds', 'fake-key', 'fake-secret', - *args, **kwargs) - return out - - def test_call_without_library(self, api_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() diff --git a/openedx/core/djangoapps/content_libraries/tests/test_models.py b/openedx/core/djangoapps/content_libraries/tests/test_models.py index 04670645e25e..88213499afbc 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_models.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_models.py @@ -3,15 +3,10 @@ """ -from unittest import mock - -from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from django.test import TestCase from organizations.models import Organization -from pylti1p3.contrib.django.lti1p3_tool_config.models import LtiToolKey -from ..models import ALL_RIGHTS_RESERVED, ContentLibrary, LtiGradedResource, LtiProfile, LtiTool +from ..models import ALL_RIGHTS_RESERVED, ContentLibrary class ContentLibraryTest(TestCase): @@ -32,264 +27,3 @@ def _create_library(self, **kwds): 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() - library.authorized_lti_configs.add(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() - library.authorized_lti_configs.add(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() - library.authorized_lti_configs.add(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 = 'lb:foo:bar:fooz:barz' - 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 = LibraryUsageLocatorV2.from_string(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, str(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 deleted file mode 100644 index e9c215899e35..000000000000 --- a/openedx/core/djangoapps/content_libraries/tests/test_views_lti.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -Tests for LTI views. -""" - -from django.conf import settings -from django.test import TestCase, override_settings - -from .base import URL_LIB_LTI_JWKS, ContentLibrariesRestApiTest, skip_unless_cms - - -def override_features(**kwargs): - """ - Wrapps ``override_settings`` to override ``settings.FEATURES``. - """ - return override_settings(FEATURES={**settings.FEATURES, **kwargs}) - - -@skip_unless_cms -class LtiToolJwksViewTest(TestCase): - """ - Test JWKS view. - """ - - def test_when_lti_disabled_return_404(self): - """ - Given LTI toggle is disabled - When JWKS requested - Then return 404 - """ - response = self.client.get(URL_LIB_LTI_JWKS) - self.assertEqual(response.status_code, 404) - - @override_features(ENABLE_CONTENT_LIBRARIES=True, - ENABLE_CONTENT_LIBRARIES_LTI_TOOL=True) - 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 LibraryBlockLtiUrlViewTestMixin: - """ - 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", - ) - - block = self._add_block_to_library(library['id'], 'problem', 'problem') - usage_key = str(block['id']) - - 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._api("get", '/api/libraries/v2/blocks/lb:CL-TEST:libgg:problem:bad-block/lti/', None, expect_response=404) - - -@override_features(ENABLE_CONTENT_LIBRARIES=True, - ENABLE_CONTENT_LIBRARIES_LTI_TOOL=True) -class LibraryBlockLtiUrlViewTest( - LibraryBlockLtiUrlViewTestMixin, - ContentLibrariesRestApiTest, -): - """ - Test generating LTI URL for a block in a library, using the installed openedx_content app. - """ diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 9dc12e943156..1cbcc23610b6 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -2,7 +2,7 @@ URL configuration for Studio's Content Libraries REST API """ -from django.urls import include, path, re_path, register_converter +from django.urls import include, path, register_converter from rest_framework import routers from .rest_api import blocks, collections, containers, libraries, url_converters @@ -15,11 +15,6 @@ register_converter(url_converters.LibraryContainerLocatorConverter, "lib_container_key") -# Router for importing blocks from courseware. - -import_blocks_router = routers.DefaultRouter() -import_blocks_router.register(r'tasks', libraries.LibraryImportTaskViewSet, basename='import-block-task') - library_collections_router = routers.DefaultRouter() library_collections_router.register( r'collections', collections.LibraryCollectionsView, basename="library-collections" @@ -51,8 +46,6 @@ path('team/user//', libraries.LibraryTeamUserView.as_view()), # Add/Edit (PUT) or remove (DELETE) a group's permission to use this library path('team/group//', libraries.LibraryTeamGroupView.as_view()), - # Import blocks into this library. - path('import_blocks/', include(import_blocks_router.urls)), # Paste contents of clipboard into library path('paste_clipboard/', libraries.LibraryPasteClipboardView.as_view()), # Start a backup task for this library @@ -69,8 +62,6 @@ path('collections/', blocks.LibraryBlockCollectionsView.as_view(), name='update-collections'), # Get the full hierarchy that the block belongs to path('hierarchy/', blocks.LibraryBlockHierarchy.as_view()), - # Get the LTI URL of a specific XBlock - path('lti/', blocks.LibraryBlockLtiUrlView.as_view(), name='lti-url'), # Get the OLX source code of the specified block: path('olx/', blocks.LibraryBlockOlxView.as_view()), # CRUD for static asset files associated with a block in the library: @@ -95,11 +86,6 @@ path('publish/', containers.LibraryContainerPublishView.as_view()), path('copy/', containers.LibraryContainerCopyView.as_view()), ])), - re_path(r'^lti/1.3/', include([ - path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'), - path('launch/', libraries.LtiToolLaunchView.as_view(), name='lti-launch'), - path('pub/jwks/', libraries.LtiToolJwksView.as_view(), name='lti-pub-jwks'), - ])), ])), path('library_assets/', include([ path( diff --git a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py index fc6bcdd9bad1..11eaa9389e2f 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py @@ -442,7 +442,7 @@ def test_build_library_object_tree(self) -> None: """ Test if we can export a library """ - with self.assertNumQueries(11): + with self.assertNumQueries(10): tagged_library = build_object_tree_with_objecttags(self.library.key, self.all_library_object_tags) assert tagged_library == self.expected_library_tagged_xblock diff --git a/pyproject.toml b/pyproject.toml index 64ff48abafec..be0c768a5eb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -276,9 +276,6 @@ ignore_imports = [ # cms.djangoapps.export_course_metadata.tasks # -> openedx.core.djangoapps.schedules.content_highlights # -> lms.djangoapps.courseware.block_render & lms.djangoapps.courseware.model_data - "openedx.core.djangoapps.content_libraries.* -> lms.djangoapps.*.*", - # cms.djangoapps.contentstore.tasks -> openedx.core.djangoapps.content_libraries.[various] - # -> lms.djangoapps.grades.api "openedx.core.djangoapps.xblock.*.* -> lms.djangoapps.*.*", # cms.djangoapps.contentstore.tasks -> openedx.core.djangoapps.content_libraries.[various] -> openedx.core.djangoapps.xblock.[various] # -> lms.djangoapps.courseware & lms.djangoapps.courseware.grades