diff --git a/docs/hooks/events.rst b/docs/hooks/events.rst index 7f9584c9e8e1..2a7561df24e1 100644 --- a/docs/hooks/events.rst +++ b/docs/hooks/events.rst @@ -233,17 +233,29 @@ Content Authoring Events - 2023-07-20 * - `LIBRARY_BLOCK_CREATED `_ - - org.openedx.content_authoring.content_library.created.v1 + - org.openedx.content_authoring.library_block.created.v1 - 2023-07-20 * - `LIBRARY_BLOCK_UPDATED `_ - - org.openedx.content_authoring.content_library.updated.v1 + - org.openedx.content_authoring.library_block.updated.v1 - 2023-07-20 * - `LIBRARY_BLOCK_DELETED `_ - - org.openedx.content_authoring.content_library.deleted.v1 + - org.openedx.content_authoring.library_block.deleted.v1 - 2023-07-20 * - `CONTENT_OBJECT_TAGS_CHANGED `_ - org.openedx.content_authoring.content.object.tags.changed.v1 - 2024-03-31 + + * - `LIBRARY_COLLECTION_CREATED `_ + - org.openedx.content_authoring.content_library.collection.created.v1 + - 2024-08-23 + + * - `LIBRARY_COLLECTION_UPDATED `_ + - org.openedx.content_authoring.content_library.collection.updated.v1 + - 2024-08-23 + + * - `LIBRARY_COLLECTION_DELETED `_ + - org.openedx.content_authoring.content_library.collection.deleted.v1 + - 2024-08-23 diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 9fb49b24b6d1..76bb5eb3f4a4 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -296,16 +296,12 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: status_cb("Counting courses...") num_courses = CourseOverview.objects.count() - # Get the list of collections - status_cb("Counting collections...") - num_collections = authoring_api.get_collections().count() - # Some counters so we can track our progress as indexing progresses: - num_contexts = num_courses + num_libraries + num_collections + num_contexts = num_courses + num_libraries num_contexts_done = 0 # How many courses/libraries we've indexed num_blocks_done = 0 # How many individual components/XBlocks we've indexed - status_cb(f"Found {num_courses} courses, {num_libraries} libraries and {num_collections} collections.") + status_cb(f"Found {num_courses} courses, {num_libraries} libraries.") with _using_temp_index(status_cb) as temp_index_name: ############## Configure the index ############## @@ -390,10 +386,43 @@ def index_library(lib_key: str) -> list: status_cb(f"Error indexing library {lib_key}: {err}") return docs + ############## Collections ############## + def index_collection_batch(batch, num_done) -> int: + docs = [] + for collection in batch: + try: + doc = searchable_doc_for_collection(collection) + # Uncomment below line once collections are tagged. + # doc.update(searchable_doc_tags(collection.id)) + docs.append(doc) + except Exception as err: # pylint: disable=broad-except + status_cb(f"Error indexing collection {collection}: {err}") + num_done += 1 + + if docs: + try: + # Add docs in batch of 100 at once (usually faster than adding one at a time): + _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + except (TypeError, KeyError, MeilisearchError) as err: + status_cb(f"Error indexing collection batch {p}: {err}") + return num_done + for lib_key in lib_keys: - status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing library {lib_key}") + status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing blocks in library {lib_key}") lib_docs = index_library(lib_key) num_blocks_done += len(lib_docs) + + # To reduce memory usage on large instances, split up the Collections into pages of 100 collections: + library = lib_api.get_library(lib_key) + collections = authoring_api.get_collections(library.learning_package.id, enabled=True) + num_collections = collections.count() + num_collections_done = 0 + status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}") + paginator = Paginator(collections, 100) + for p in paginator.page_range: + num_collections_done = index_collection_batch(paginator.page(p).object_list, num_collections_done) + status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") + num_contexts_done += 1 ############## Courses ############## @@ -430,39 +459,6 @@ def add_with_children(block): num_contexts_done += 1 num_blocks_done += len(course_docs) - ############## Collections ############## - status_cb("Indexing collections...") - - def index_collection_batch(batch, num_contexts_done) -> int: - docs = [] - for collection in batch: - status_cb( - f"{num_contexts_done + 1}/{num_contexts}. " - f"Now indexing collection {collection.title} ({collection.id})" - ) - try: - doc = searchable_doc_for_collection(collection) - # Uncomment below line once collections are tagged. - # doc.update(searchable_doc_tags(collection.id)) - docs.append(doc) - except Exception as err: # pylint: disable=broad-except - status_cb(f"Error indexing collection {collection}: {err}") - finally: - num_contexts_done += 1 - - if docs: - try: - # Add docs in batch of 100 at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) - except (TypeError, KeyError, MeilisearchError) as err: - status_cb(f"Error indexing collection batch {p}: {err}") - return num_contexts_done - - # To reduce memory usage on large instances, split up the Collections into pages of 100 collections: - paginator = Paginator(authoring_api.get_collections(enabled=True), 100) - for p in paginator.page_range: - num_contexts_done = index_collection_batch(paginator.page(p).object_list, num_contexts_done) - status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses, collections and libraries.") diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 549817700370..d6b58674f5ec 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -177,8 +177,16 @@ def setUp(self): # Create a collection: self.learning_package = authoring_api.get_learning_package_by_key(self.library.key) + with freeze_time(created_date): + self.collection = authoring_api.create_collection( + learning_package_id=self.learning_package.id, + key="MYCOL", + title="my_collection", + created_by=None, + description="my collection description" + ) self.collection_dict = { - 'id': 1, + 'id': self.collection.id, 'type': 'collection', 'display_name': 'my_collection', 'description': 'my collection description', @@ -189,13 +197,6 @@ def setUp(self): "access_id": lib_access.id, 'breadcrumbs': [{'display_name': 'Library'}] } - with freeze_time(created_date): - self.collection = authoring_api.create_collection( - learning_package_id=self.learning_package.id, - title="my_collection", - created_by=None, - description="my collection description" - ) @override_settings(MEILISEARCH_ENABLED=False) def test_reindex_meilisearch_disabled(self, mock_meilisearch): diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 6140411705bb..1f10fc3c988f 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -215,6 +215,7 @@ def test_collection_with_no_library(self): ) collection = authoring_api.create_collection( learning_package_id=learning_package.id, + key="MYCOL", title="my_collection", created_by=None, description="my collection description" @@ -223,11 +224,11 @@ def test_collection_with_no_library(self): assert doc == { "id": collection.id, "type": "collection", - "display_name": collection.title, - "description": collection.description, + "display_name": "my_collection", + "description": "my collection description", "context_key": learning_package.key, "access_id": self.toy_course_access_id, - "breadcrumbs": [{"display_name": learning_package.title}], + "breadcrumbs": [{"display_name": "some learning_package"}], "created": created_date.timestamp(), "modified": created_date.timestamp(), } diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 17bea80b3a96..00110143456a 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -69,24 +69,32 @@ from django.utils.translation import gettext as _ from edx_rest_api_client.client import OAuthAPIClient from lxml import etree -from opaque_keys.edx.keys import UsageKey, UsageKeyV2 +from opaque_keys.edx.keys import BlockTypeKey, UsageKey, UsageKeyV2 from opaque_keys.edx.locator import ( LibraryLocatorV2, LibraryUsageLocatorV2, LibraryLocator as LibraryLocatorV1 ) from opaque_keys import InvalidKeyError -from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData +from openedx_events.content_authoring.data import ( + ContentLibraryData, + ContentObjectData, + LibraryBlockData, + LibraryCollectionData, +) from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_TAGS_CHANGED, CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_UPDATED, ) from openedx_learning.api import authoring as authoring_api -from openedx_learning.api.authoring_models import Component, MediaType +from openedx_learning.api.authoring_models import Collection, Component, MediaType, LearningPackage, PublishableEntity from organizations.models import Organization from xblock.core import XBlock from xblock.exceptions import XBlockNotFoundError @@ -111,6 +119,8 @@ ContentLibraryNotFound = ContentLibrary.DoesNotExist +ContentLibraryCollectionNotFound = Collection.DoesNotExist + class ContentLibraryBlockNotFound(XBlockNotFoundError): """ XBlock not found in the content library """ @@ -120,6 +130,10 @@ class LibraryAlreadyExists(KeyError): """ A library with the specified slug already exists """ +class LibraryCollectionAlreadyExists(IntegrityError): + """ A Collection with that key already exists in the library """ + + class LibraryBlockAlreadyExists(KeyError): """ An XBlock with that ID already exists in the library """ @@ -150,6 +164,7 @@ class ContentLibraryMetadata: Class that represents the metadata about a content library. """ key = attr.ib(type=LibraryLocatorV2) + learning_package = attr.ib(type=LearningPackage) title = attr.ib("") description = attr.ib("") num_blocks = attr.ib(0) @@ -323,13 +338,14 @@ def get_metadata(queryset, text_search=None): has_unpublished_changes=False, has_unpublished_deletes=False, license=lib.license, + learning_package=lib.learning_package, ) for lib in queryset ] return libraries -def require_permission_for_library_key(library_key, user, permission): +def require_permission_for_library_key(library_key, user, permission) -> ContentLibrary: """ Given any of the content library permission strings defined in openedx.core.djangoapps.content_libraries.permissions, @@ -339,10 +355,12 @@ def require_permission_for_library_key(library_key, user, permission): Raises django.core.exceptions.PermissionDenied if the user doesn't have permission. """ - library_obj = ContentLibrary.objects.get_by_key(library_key) + library_obj = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] if not user.has_perm(permission, obj=library_obj): raise PermissionDenied + return library_obj + def get_library(library_key): """ @@ -408,6 +426,7 @@ def get_library(library_key): license=ref.license, created=learning_package.created, updated=learning_package.updated, + learning_package=learning_package ) @@ -479,6 +498,7 @@ def create_library( allow_public_learning=ref.allow_public_learning, allow_public_read=ref.allow_public_read, license=library_license, + learning_package=ref.learning_package ) @@ -1056,6 +1076,174 @@ def revert_changes(library_key): ) +def create_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + title: str, + *, + description: str = "", + created_by: int | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Creates a Collection in the given ContentLibrary, + and emits a LIBRARY_COLLECTION_CREATED event. + + If you've already fetched a ContentLibrary for the given library_key, pass it in here to avoid refetching. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.create_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + created_by=created_by, + ) + except IntegrityError as err: + raise LibraryCollectionAlreadyExists from err + + # Emit event for library collection created + LIBRARY_COLLECTION_CREATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + return collection + + +def update_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + *, + title: str | None = None, + description: str | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Creates a Collection in the given ContentLibrary, + and emits a LIBRARY_COLLECTION_CREATED event. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.update_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc + + # Emit event for library collection updated + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + return collection + + +def update_library_collection_components( + library_key: LibraryLocatorV2, + collection_key: str, + *, + usage_keys: list[UsageKeyV2], + created_by: int | None = None, + remove=False, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Associates the Collection with Components for the given UsageKeys. + + By default the Components are added to the Collection. + If remove=True, the Components are removed from the Collection. + + If you've already fetched the ContentLibrary, pass it in to avoid refetching. + + Raises: + * ContentLibraryCollectionNotFound if no Collection with the given pk is found in the given library. + * ContentLibraryBlockNotFound if any of the given usage_keys don't match Components in the given library. + + Returns the updated Collection. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + # Fetch the Component.key values for the provided UsageKeys. + component_keys = [] + for usage_key in usage_keys: + # Parse the block_family from the key to use as namespace. + block_type = BlockTypeKey.from_string(str(usage_key)) + + try: + component = authoring_api.get_component_by_key( + content_library.learning_package_id, + namespace=block_type.block_family, + type_name=usage_key.block_type, + local_key=usage_key.block_id, + ) + except Component.DoesNotExist as exc: + raise ContentLibraryBlockNotFound(usage_key) from exc + + component_keys.append(component.key) + + # Note: Component.key matches its PublishableEntity.key + entities_qset = PublishableEntity.objects.filter( + key__in=component_keys, + ) + + if remove: + collection = authoring_api.remove_from_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + ) + else: + collection = authoring_api.add_to_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + created_by=created_by, + ) + + # Emit event for library collection updated + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + ) + ) + + # Emit a CONTENT_OBJECT_TAGS_CHANGED event for each of the objects added/removed + for usage_key in usage_keys: + CONTENT_OBJECT_TAGS_CHANGED.send_event( + content_object=ContentObjectData(object_id=usage_key), + ) + + return collection + + # V1/V2 Compatibility Helpers # (Should be removed as part of # https://github.com/openedx/edx-platform/issues/32457) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 497eda81475b..2062f96d93ae 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -4,7 +4,12 @@ # pylint: disable=abstract-method from django.core.validators import validate_unicode_slug from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from opaque_keys.edx.keys import UsageKeyV2 +from opaque_keys import InvalidKeyError + +from openedx_learning.api.authoring_models import Collection from openedx.core.djangoapps.content_libraries.constants import ( LIBRARY_TYPES, COMPLEX, @@ -245,3 +250,52 @@ class ContentLibraryBlockImportTaskCreateSerializer(serializers.Serializer): """ course_key = CourseKeyField() + + +class ContentLibraryCollectionSerializer(serializers.ModelSerializer): + """ + Serializer for a Content Library Collection + """ + + class Meta: + model = Collection + fields = '__all__' + + +class ContentLibraryCollectionUpdateSerializer(serializers.Serializer): + """ + Serializer for updating a Collection in a Content Library + """ + + title = serializers.CharField() + description = serializers.CharField(allow_blank=True) + + +class UsageKeyV2Serializer(serializers.Serializer): + """ + Serializes a UsageKeyV2. + """ + def to_representation(self, value: UsageKeyV2) -> str: + """ + Returns the UsageKeyV2 value as a string. + """ + return str(value) + + def to_internal_value(self, value: str) -> UsageKeyV2: + """ + Returns a UsageKeyV2 from the string value. + + Raises ValidationError if invalid UsageKeyV2. + """ + try: + return UsageKeyV2.from_string(value) + except InvalidKeyError as err: + raise ValidationError from err + + +class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer): + """ + Serializer for adding/removing Components to/from a Collection. + """ + + usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index 87ae180d291e..b7b646acac56 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -13,8 +13,20 @@ UsageKey, ) from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_events.content_authoring.data import ( + ContentObjectData, + LibraryCollectionData, +) +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_TAGS_CHANGED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_UPDATED, +) +from openedx_events.tests.utils import OpenEdxEventsTestMixin from .. import api +from ..models import ContentLibrary +from .base import ContentLibrariesRestApiTest class EdxModulestoreImportClientTest(TestCase): @@ -241,3 +253,220 @@ def test_import_block_when_url_is_from_studio( block_olx ) mock_publish_changes.assert_not_called() + + +class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): + """ + Tests for Content Library API collections methods. + + Same guidelines as ContentLibrariesTestCase. + """ + ENABLED_OPENEDX_EVENTS = [ + CONTENT_OBJECT_TAGS_CHANGED.event_type, + LIBRARY_COLLECTION_CREATED.event_type, + LIBRARY_COLLECTION_UPDATED.event_type, + ] + + @classmethod + def setUpClass(cls): + """ + Set up class method for the Test class. + + TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on + OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, + so we're following a pattern here. But that pattern doesn't really make sense. + """ + super().setUpClass() + cls.start_events_isolation() + + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-col-1", "Test Library 1") + self._create_library("test-lib-col-2", "Test Library 2") + + # Fetch the created ContentLibrare objects so we can access their learning_package.id + self.lib1 = ContentLibrary.objects.get(slug="test-lib-col-1") + self.lib2 = ContentLibrary.objects.get(slug="test-lib-col-2") + + # Create Content Library Collections + self.col1 = api.create_library_collection( + self.lib1.library_key, + collection_key="COL1", + title="Collection 1", + description="Description for Collection 1", + created_by=self.user.id, + ) + self.col2 = api.create_library_collection( + self.lib2.library_key, + collection_key="COL2", + title="Collection 2", + description="Description for Collection 2", + created_by=self.user.id, + ) + + # Create some library blocks in lib1 + self.lib1_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problem1", + ) + self.lib1_html_block = self._add_block_to_library( + self.lib1.library_key, "html", "html1", + ) + + def test_create_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_CREATED.connect(event_receiver) + + collection = api.create_library_collection( + self.lib2.library_key, + collection_key="COL4", + title="Collection 4", + description="Description for Collection 4", + created_by=self.user.id, + ) + assert collection.key == "COL4" + assert collection.title == "Collection 4" + assert collection.description == "Description for Collection 4" + assert collection.created_by == self.user + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_CREATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib2.library_key, + collection_key="COL4", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + + def test_create_library_collection_invalid_library(self): + library_key = LibraryLocatorV2.from_string("lib:INVALID:test-lib-does-not-exist") + with self.assertRaises(api.ContentLibraryNotFound) as exc: + api.create_library_collection( + library_key, + collection_key="COL4", + title="Collection 3", + ) + + def test_update_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + self.col1 = api.update_library_collection( + self.lib1.library_key, + self.col1.key, + title="New title for Collection 1", + ) + assert self.col1.key == "COL1" + assert self.col1.title == "New title for Collection 1" + assert self.col1.description == "Description for Collection 1" + assert self.col1.created_by == self.user + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + + def test_update_library_collection_wrong_library(self): + with self.assertRaises(api.ContentLibraryCollectionNotFound) as exc: + api.update_library_collection( + self.lib1.library_key, + self.col2.key, + ) + + def test_update_library_collection_components(self): + assert not list(self.col1.entities.all()) + + self.col1 = api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + assert len(self.col1.entities.all()) == 2 + + self.col1 = api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_html_block["id"]), + ], + remove=True, + ) + assert len(self.col1.entities.all()) == 1 + + def test_update_library_collection_components_event(self): + """ + Check that a CONTENT_OBJECT_TAGS_CHANGED event is raised for each added/removed component. + """ + event_receiver = mock.Mock() + CONTENT_OBJECT_TAGS_CHANGED.connect(event_receiver) + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + + assert event_receiver.call_count == 3 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_TAGS_CHANGED, + "sender": None, + "content_object": ContentObjectData( + object_id=UsageKey.from_string(self.lib1_problem_block["id"]), + ), + }, + event_receiver.call_args_list[1].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_TAGS_CHANGED, + "sender": None, + "content_object": ContentObjectData( + object_id=UsageKey.from_string(self.lib1_html_block["id"]), + ), + }, + event_receiver.call_args_list[2].kwargs, + ) + + def test_update_collection_components_from_wrong_library(self): + with self.assertRaises(api.ContentLibraryBlockNotFound) as exc: + api.update_library_collection_components( + self.lib2.library_key, + self.col2.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + assert self.lib1_problem_block["id"] in str(exc.exception) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py new file mode 100644 index 000000000000..bc600759b5b3 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py @@ -0,0 +1,457 @@ +""" +Tests Library Collections REST API views +""" + +from __future__ import annotations +import ddt + +from openedx_learning.api.authoring_models import Collection +from opaque_keys.edx.locator import LibraryLocatorV2 + +from openedx.core.djangolib.testing.utils import skip_unless_cms +from openedx.core.djangoapps.content_libraries import api +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangoapps.content_libraries.models import ContentLibrary +from common.djangoapps.student.tests.factories import UserFactory + +URL_PREFIX = '/api/libraries/v2/{lib_key}/' +URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/' +URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' +URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'components/' + + +@ddt.ddt +@skip_unless_cms # Content Library Collections REST API is only available in Studio +class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest): + """ + Tests for Content Library Collection REST API Views + """ + + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-col-1", "Test Library 1") + self._create_library("test-lib-col-2", "Test Library 2") + self.lib1 = ContentLibrary.objects.get(slug="test-lib-col-1") + self.lib2 = ContentLibrary.objects.get(slug="test-lib-col-2") + + # Create Content Library Collections + self.col1 = api.create_library_collection( + self.lib1.library_key, + "COL1", + title="Collection 1", + created_by=self.user.id, + description="Description for Collection 1", + ) + + self.col2 = api.create_library_collection( + self.lib1.library_key, + "COL2", + title="Collection 2", + created_by=self.user.id, + description="Description for Collection 2", + ) + self.col3 = api.create_library_collection( + self.lib2.library_key, + "COL3", + title="Collection 3", + created_by=self.user.id, + description="Description for Collection 3", + ) + + # Create some library blocks + self.lib1_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problem1", + ) + self.lib1_html_block = self._add_block_to_library( + self.lib1.library_key, "html", "html1", + ) + self.lib2_problem_block = self._add_block_to_library( + self.lib2.library_key, "problem", "problem2", + ) + self.lib2_html_block = self._add_block_to_library( + self.lib2.library_key, "html", "html2", + ) + + def test_get_library_collection(self): + """ + Test retrieving a Content Library Collection + """ + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + + # Check that correct Content Library Collection data retrieved + expected_collection = { + "title": "Collection 3", + "description": "Description for Collection 3", + } + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, expected_collection) + + # Check that a random user without permissions cannot access Content Library Collection + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + assert resp.status_code == 403 + + def test_get_invalid_library_collection(self): + """ + Test retrieving a an invalid Content Library Collection or one that does not exist + """ + # Fetch collection that belongs to a different library, it should fail + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.key) + ) + + assert resp.status_code == 404 + + # Fetch collection with invalid ID provided, it should fail + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key='123') + ) + + assert resp.status_code == 404 + + # Fetch collection with invalid library_key provided, it should fail + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=123, collection_key='123') + ) + assert resp.status_code == 404 + + def test_list_library_collections(self): + """ + Test listing Content Library Collections + """ + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key)) + + # Check that the correct collections are listed + assert resp.status_code == 200 + assert len(resp.data["results"]) == 2 + expected_collections = [ + {"key": "COL1", "title": "Collection 1", "description": "Description for Collection 1"}, + {"key": "COL2", "title": "Collection 2", "description": "Description for Collection 2"}, + ] + for collection, expected in zip(resp.data["results"], expected_collections): + self.assertDictContainsEntries(collection, expected) + + # Check that a random user without permissions cannot access Content Library Collections + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key)) + assert resp.status_code == 403 + + def test_list_invalid_library_collections(self): + """ + Test listing invalid Content Library Collections + """ + non_existing_key = LibraryLocatorV2.from_string("lib:DoesNotExist:NE1") + resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=non_existing_key)) + + assert resp.status_code == 404 + + # List collections with invalid library_key provided, it should fail + resp = resp = self.client.get(URL_LIB_COLLECTIONS.format(lib_key=123)) + assert resp.status_code == 404 + + def test_create_library_collection(self): + """ + Test creating a Content Library Collection + """ + post_data = { + "title": "Collection 4", + "description": "Description for Collection 4", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + # Check that the new Content Library Collection is returned in response and created in DB + assert resp.status_code == 200 + post_data["key"] = 'collection-4' + self.assertDictContainsEntries(resp.data, post_data) + + created_collection = Collection.objects.get(id=resp.data["id"]) + self.assertIsNotNone(created_collection) + + # Check that user with read only access cannot create new Content Library Collection + reader = UserFactory.create(username="Reader", email="reader@example.com") + self._add_user_by_email(self.lib1.library_key, reader.email, access_level="read") + + with self.as_user(reader): + post_data = { + "title": "Collection 5", + "description": "Description for Collection 5", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + + assert resp.status_code == 403 + + def test_create_collection_same_key(self): + """ + Test collection creation with same key + """ + post_data = { + "title": "Same Collection", + "description": "Description for Collection 4", + } + self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + + for i in range(100): + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data, format="json" + ) + expected_data = { + "key": f"same-collection-{i + 1}", + "title": "Same Collection", + "description": "Description for Collection 4", + } + + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, expected_data) + + def test_create_invalid_library_collection(self): + """ + Test creating an invalid Content Library Collection + """ + post_data_missing_title = { + "key": "COL_KEY", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data_missing_title, format="json" + ) + + assert resp.status_code == 400 + + post_data_missing_key = { + "title": "Collection 4", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), post_data_missing_key, format="json" + ) + + assert resp.status_code == 400 + + # Create collection with an existing collection.key; it should fail + post_data_existing_key = { + "key": self.col1.key, + "title": "Collection 4", + } + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=self.lib1.library_key), + post_data_existing_key, + format="json" + ) + assert resp.status_code == 400 + + # Create collection with invalid library_key provided, it should fail + resp = self.client.post( + URL_LIB_COLLECTIONS.format(lib_key=123), + {**post_data_missing_title, **post_data_missing_key}, + format="json" + ) + assert resp.status_code == 404 + + def test_update_library_collection(self): + """ + Test updating a Content Library Collection + """ + patch_data = { + "title": "Collection 3 Updated", + } + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key), + patch_data, + format="json" + ) + + # Check that updated Content Library Collection is returned in response and updated in DB + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, patch_data) + + created_collection = Collection.objects.get(id=resp.data["id"]) + self.assertIsNotNone(created_collection) + self.assertEqual(created_collection.title, patch_data["title"]) + + # Check that user with read only access cannot update a Content Library Collection + reader = UserFactory.create(username="Reader", email="reader@example.com") + self._add_user_by_email(self.lib1.library_key, reader.email, access_level="read") + + with self.as_user(reader): + patch_data = { + "title": "Collection 3 should not update", + } + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key), + patch_data, + format="json" + ) + + assert resp.status_code == 403 + + def test_update_invalid_library_collection(self): + """ + Test updating an invalid Content Library Collection or one that does not exist + """ + patch_data = { + "title": "Collection 3 Updated", + } + # Update collection that belongs to a different library, it should fail + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key=self.col3.key), + patch_data, + format="json" + ) + + assert resp.status_code == 404 + + # Update collection with invalid ID provided, it should fail + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=self.lib1.library_key, collection_key='123'), + patch_data, + format="json" + ) + + assert resp.status_code == 404 + + # Update collection with invalid library_key provided, it should fail + resp = self.client.patch( + URL_LIB_COLLECTION.format(lib_key=123, collection_key=self.col3.key), + patch_data, + format="json" + ) + assert resp.status_code == 404 + + def test_delete_library_collection(self): + """ + Test deleting a Content Library Collection + + Note: Currently not implemented and should return a 405 + """ + resp = self.client.delete( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + + assert resp.status_code == 405 + + def test_get_components(self): + """ + Retrieving components is not supported by the REST API; + use Meilisearch instead. + """ + resp = self.client.get( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + ) + assert resp.status_code == 405 + + def test_update_components(self): + """ + Test adding and removing components from a collection. + """ + # Add two components to col1 + resp = self.client.patch( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + self.lib1_html_block["id"], + ] + } + ) + assert resp.status_code == 200 + assert resp.data == {"count": 2} + + # Remove one of the added components from col1 + resp = self.client.delete( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + ] + } + ) + assert resp.status_code == 200 + assert resp.data == {"count": 1} + + @ddt.data("patch", "delete") + def test_update_components_wrong_collection(self, method): + """ + Collection must belong to the requested library. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib2.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + ] + } + ) + assert resp.status_code == 404 + + @ddt.data("patch", "delete") + def test_update_components_missing_data(self, method): + """ + List of usage keys must contain at least one item. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib2.library_key, + collection_key=self.col3.key, + ), + ) + assert resp.status_code == 400 + assert resp.data == { + "usage_keys": ["This field is required."], + } + + @ddt.data("patch", "delete") + def test_update_components_from_another_library(self, method): + """ + Adding/removing components from another library raises a 404. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib2.library_key, + collection_key=self.col3.key, + ), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + self.lib1_html_block["id"], + ] + } + ) + assert resp.status_code == 404 + + @ddt.data("patch", "delete") + def test_update_components_permissions(self, method): + """ + Check that a random user without permissions cannot update a Content Library Collection's components. + """ + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + ) + assert resp.status_code == 403 diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 6e450df63522..9455f0de5e61 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -7,6 +7,7 @@ from rest_framework import routers from . import views +from . import views_collections # Django application name. @@ -18,6 +19,11 @@ import_blocks_router = routers.DefaultRouter() import_blocks_router.register(r'tasks', views.LibraryImportTaskViewSet, basename='import-block-task') +library_collections_router = routers.DefaultRouter() +library_collections_router.register( + r'collections', views_collections.LibraryCollectionsView, basename="library-collections" +) + # These URLs are only used in Studio. The LMS already provides all the # API endpoints needed to serve XBlocks from content libraries using the # standard XBlock REST API (see openedx.core.django_apps.xblock.rest_api.urls) @@ -45,6 +51,8 @@ path('import_blocks/', include(import_blocks_router.urls)), # Paste contents of clipboard into library path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()), + # Library Collections + path('', include(library_collections_router.urls)), ])), path('blocks//', include([ # Get metadata about a specific XBlock in this library, or delete the block: diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index bde8142d3fcc..835a5de1f1f2 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -84,6 +84,7 @@ from pylti1p3.exception import LtiException, OIDCException import edx_api_doc_tools as apidocs +from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from organizations.api import ensure_organization from organizations.exceptions import InvalidOrganizationException @@ -136,12 +137,21 @@ def convert_exceptions(fn): def wrapped_fn(*args, **kwargs): try: return fn(*args, **kwargs) + except InvalidKeyError as exc: + log.exception(str(exc)) + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from except api.ContentLibraryNotFound: log.exception("Content library not found") raise NotFound # lint-amnesty, pylint: disable=raise-missing-from except api.ContentLibraryBlockNotFound: log.exception("XBlock not found in content library") raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryCollectionNotFound: + log.exception("Collection not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.LibraryCollectionAlreadyExists as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from except api.LibraryBlockAlreadyExists as exc: log.exception(str(exc)) raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from diff --git a/openedx/core/djangoapps/content_libraries/views_collections.py b/openedx/core/djangoapps/content_libraries/views_collections.py new file mode 100644 index 000000000000..2f40a1788628 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/views_collections.py @@ -0,0 +1,198 @@ +""" +Collections API Views +""" + +from __future__ import annotations + +from django.db.models import QuerySet +from django.utils.text import slugify +from django.db import transaction + +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet +from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED + +from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Collection + +from openedx.core.djangoapps.content_libraries import api, permissions +from openedx.core.djangoapps.content_libraries.models import ContentLibrary +from openedx.core.djangoapps.content_libraries.views import convert_exceptions +from openedx.core.djangoapps.content_libraries.serializers import ( + ContentLibraryCollectionSerializer, + ContentLibraryCollectionComponentsUpdateSerializer, + ContentLibraryCollectionUpdateSerializer, +) + + +class LibraryCollectionsView(ModelViewSet): + """ + Views to get, create and update Library Collections. + """ + + serializer_class = ContentLibraryCollectionSerializer + lookup_field = 'key' + + def __init__(self, *args, **kwargs) -> None: + """ + Caches the ContentLibrary for the duration of the request. + """ + super().__init__(*args, **kwargs) + self._content_library: ContentLibrary | None = None + + def get_content_library(self) -> ContentLibrary: + """ + Returns the requested ContentLibrary object, if access allows. + """ + if self._content_library: + return self._content_library + + lib_key_str = self.kwargs["lib_key_str"] + library_key = LibraryLocatorV2.from_string(lib_key_str) + permission = ( + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY + if self.request.method in ['OPTIONS', 'GET'] + else permissions.CAN_EDIT_THIS_CONTENT_LIBRARY + ) + + self._content_library = api.require_permission_for_library_key( + library_key, + self.request.user, + permission, + ) + return self._content_library + + def get_queryset(self) -> QuerySet[Collection]: + """ + Returns a queryset for the requested Collections, if access allows. + + This method may raise exceptions; these are handled by the @convert_exceptions wrapper on the views. + """ + content_library = self.get_content_library() + assert content_library.learning_package_id + return authoring_api.get_collections(content_library.learning_package_id) + + def get_object(self) -> Collection: + """ + Returns the requested Collections, if access allows. + + This method may raise exceptions; these are handled by the @convert_exceptions wrapper on the views. + """ + collection = super().get_object() + content_library = self.get_content_library() + + # Ensure the ContentLibrary and Collection share the same learning package + if collection.learning_package_id != content_library.learning_package_id: + raise api.ContentLibraryCollectionNotFound + return collection + + @convert_exceptions + def retrieve(self, request, *args, **kwargs) -> Response: + """ + Retrieve the Content Library Collection + """ + # View declared so we can wrap it in @convert_exceptions + return super().retrieve(request, *args, **kwargs) + + @convert_exceptions + def list(self, request, *args, **kwargs) -> Response: + """ + List Collections that belong to Content Library + """ + # View declared so we can wrap it in @convert_exceptions + return super().list(request, *args, **kwargs) + + @convert_exceptions + def create(self, request, *args, **kwargs) -> Response: + """ + Create a Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + create_serializer = ContentLibraryCollectionUpdateSerializer(data=request.data) + create_serializer.is_valid(raise_exception=True) + + title = create_serializer.validated_data['title'] + key = slugify(title) + + attempt = 0 + collection = None + while not collection: + modified_key = key if attempt == 0 else key + '-' + str(attempt) + try: + # Add transaction here to avoid TransactionManagementError on retry + with transaction.atomic(): + collection = api.create_library_collection( + library_key=content_library.library_key, + content_library=content_library, + collection_key=modified_key, + title=title, + description=create_serializer.validated_data["description"], + created_by=request.user.id, + ) + except api.LibraryCollectionAlreadyExists: + attempt += 1 + + serializer = self.get_serializer(collection) + + return Response(serializer.data) + + @convert_exceptions + def partial_update(self, request, *args, **kwargs) -> Response: + """ + Update a Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + collection_key = kwargs["key"] + + update_serializer = ContentLibraryCollectionUpdateSerializer( + data=request.data, partial=True + ) + update_serializer.is_valid(raise_exception=True) + updated_collection = api.update_library_collection( + library_key=content_library.library_key, + collection_key=collection_key, + content_library=content_library, + **update_serializer.validated_data + ) + serializer = self.get_serializer(updated_collection) + + return Response(serializer.data) + + @convert_exceptions + def destroy(self, request, *args, **kwargs) -> Response: + """ + Deletes a Collection that belongs to a Content Library + + Note: (currently not allowed) + """ + # TODO: Implement the deletion logic and emit event signal + + return Response(None, status=HTTP_405_METHOD_NOT_ALLOWED) + + @convert_exceptions + @action(detail=True, methods=['delete', 'patch'], url_path='components', url_name='components-update') + def update_components(self, request, *args, **kwargs) -> Response: + """ + Adds (PATCH) or removes (DELETE) Components to/from a Collection. + + Collection and Components must all be part of the given library/learning package. + """ + content_library = self.get_content_library() + collection_key = kwargs["key"] + + serializer = ContentLibraryCollectionComponentsUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + usage_keys = serializer.validated_data["usage_keys"] + api.update_library_collection_components( + library_key=content_library.library_key, + content_library=content_library, + collection_key=collection_key, + usage_keys=usage_keys, + created_by=self.request.user.id, + remove=(request.method == "DELETE"), + ) + + return Response({'count': len(usage_keys)}) diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 7fab48dea63e..b5d838156124 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -93,7 +93,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.11.2 +openedx-learning==0.11.4 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. openai<=0.28.1 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 6c789e9f84bc..40d64855cb52 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -811,7 +811,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -824,7 +824,7 @@ openedx-filters==1.9.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 190f02b687b6..a18ddb26b8b9 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1358,7 +1358,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1373,7 +1373,7 @@ openedx-filters==1.9.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index c0a779283935..00fc580dedc4 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -970,7 +970,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -983,7 +983,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 391f183bf3e4..966bd772a876 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1021,7 +1021,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.12.0 +openedx-events==9.14.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1034,7 +1034,7 @@ openedx-filters==1.9.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.2 +openedx-learning==0.11.4 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt