feat: Add Library Collections REST endpoints [FC-0062] (#35321)

* feat: Add Library Collections REST endpoints

* test: Add tests for Collections REST APIs

* chore: Add missing __init__ files

* feat: Add events emitting for Collections

* feat: Add REST endpoints to update Components in a Collections (temp) (#674)

* feat: add/remove components to/from a collection

* docs: Add warning about unstable REST APIs

* chore: updates openedx-events==9.14.0

* chore: updates openedx-learning==0.11.4

* fix: assert collection doc have unique id

---------

Co-authored-by: Jillian <jill@opencraft.com>
Co-authored-by: Chris Chávez <xnpiochv@gmail.com>
Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
This commit is contained in:
Yusuf Musleh
2024-09-11 21:21:10 +03:00
committed by GitHub
parent d59e2f4fad
commit 7665f13547
16 changed files with 1222 additions and 68 deletions

View File

@@ -233,17 +233,29 @@ Content Authoring Events
- 2023-07-20
* - `LIBRARY_BLOCK_CREATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L167>`_
- org.openedx.content_authoring.content_library.created.v1
- org.openedx.content_authoring.library_block.created.v1
- 2023-07-20
* - `LIBRARY_BLOCK_UPDATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L178>`_
- org.openedx.content_authoring.content_library.updated.v1
- org.openedx.content_authoring.library_block.updated.v1
- 2023-07-20
* - `LIBRARY_BLOCK_DELETED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L189>`_
- org.openedx.content_authoring.content_library.deleted.v1
- org.openedx.content_authoring.library_block.deleted.v1
- 2023-07-20
* - `CONTENT_OBJECT_TAGS_CHANGED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L207>`_
- org.openedx.content_authoring.content.object.tags.changed.v1
- 2024-03-31
* - `LIBRARY_COLLECTION_CREATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L219>`_
- org.openedx.content_authoring.content_library.collection.created.v1
- 2024-08-23
* - `LIBRARY_COLLECTION_UPDATED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L230>`_
- org.openedx.content_authoring.content_library.collection.updated.v1
- 2024-08-23
* - `LIBRARY_COLLECTION_DELETED <https://github.com/openedx/openedx-events/blob/main/openedx_events/content_authoring/signals.py#L241>`_
- org.openedx.content_authoring.content_library.collection.deleted.v1
- 2024-08-23

View File

@@ -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 rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
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 rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
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.")

View File

@@ -177,8 +177,16 @@ class TestSearchApi(ModuleStoreTestCase):
# 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 @@ class TestSearchApi(ModuleStoreTestCase):
"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):

View File

@@ -215,6 +215,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
)
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 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
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(),
}

View File

@@ -69,24 +69,32 @@ from django.db.models import Q, QuerySet
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 @@ log = logging.getLogger(__name__)
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)

View File

@@ -4,7 +4,12 @@ Serializers for the content libraries REST API
# 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)

View File

@@ -13,8 +13,20 @@ from opaque_keys.edx.keys import (
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 @@ class EdxApiImportClientTest(TestCase):
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)

View File

@@ -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

View File

@@ -7,6 +7,7 @@ from django.urls import include, path, re_path
from rest_framework import routers
from . import views
from . import views_collections
# Django application name.
@@ -18,6 +19,11 @@ app_name = 'openedx.core.djangoapps.content_libraries'
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 @@ urlpatterns = [
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/<str:usage_key_str>/', include([
# Get metadata about a specific XBlock in this library, or delete the block:

View File

@@ -84,6 +84,7 @@ from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, Dj
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

View File

@@ -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)})

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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