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 - 2023-07-20
* - `LIBRARY_BLOCK_CREATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L167>`_ * - `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 - 2023-07-20
* - `LIBRARY_BLOCK_UPDATED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L178>`_ * - `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 - 2023-07-20
* - `LIBRARY_BLOCK_DELETED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L189>`_ * - `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 - 2023-07-20
* - `CONTENT_OBJECT_TAGS_CHANGED <https://github.com/openedx/openedx-events/blob/c0eb4ba1a3d7d066d58e5c87920b8ccb0645f769/openedx_events/content_authoring/signals.py#L207>`_ * - `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 - org.openedx.content_authoring.content.object.tags.changed.v1
- 2024-03-31 - 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...") status_cb("Counting courses...")
num_courses = CourseOverview.objects.count() 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: # 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_contexts_done = 0 # How many courses/libraries we've indexed
num_blocks_done = 0 # How many individual components/XBlocks 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: with _using_temp_index(status_cb) as temp_index_name:
############## Configure the index ############## ############## 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}") status_cb(f"Error indexing library {lib_key}: {err}")
return docs 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: 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) lib_docs = index_library(lib_key)
num_blocks_done += len(lib_docs) 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 num_contexts_done += 1
############## Courses ############## ############## Courses ##############
@@ -430,39 +459,6 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
num_contexts_done += 1 num_contexts_done += 1
num_blocks_done += len(course_docs) 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.") 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: # Create a collection:
self.learning_package = authoring_api.get_learning_package_by_key(self.library.key) 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 = { self.collection_dict = {
'id': 1, 'id': self.collection.id,
'type': 'collection', 'type': 'collection',
'display_name': 'my_collection', 'display_name': 'my_collection',
'description': 'my collection description', 'description': 'my collection description',
@@ -189,13 +197,6 @@ class TestSearchApi(ModuleStoreTestCase):
"access_id": lib_access.id, "access_id": lib_access.id,
'breadcrumbs': [{'display_name': 'Library'}] '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) @override_settings(MEILISEARCH_ENABLED=False)
def test_reindex_meilisearch_disabled(self, mock_meilisearch): def test_reindex_meilisearch_disabled(self, mock_meilisearch):

View File

@@ -215,6 +215,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
) )
collection = authoring_api.create_collection( collection = authoring_api.create_collection(
learning_package_id=learning_package.id, learning_package_id=learning_package.id,
key="MYCOL",
title="my_collection", title="my_collection",
created_by=None, created_by=None,
description="my collection description" description="my collection description"
@@ -223,11 +224,11 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
assert doc == { assert doc == {
"id": collection.id, "id": collection.id,
"type": "collection", "type": "collection",
"display_name": collection.title, "display_name": "my_collection",
"description": collection.description, "description": "my collection description",
"context_key": learning_package.key, "context_key": learning_package.key,
"access_id": self.toy_course_access_id, "access_id": self.toy_course_access_id,
"breadcrumbs": [{"display_name": learning_package.title}], "breadcrumbs": [{"display_name": "some learning_package"}],
"created": created_date.timestamp(), "created": created_date.timestamp(),
"modified": 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 django.utils.translation import gettext as _
from edx_rest_api_client.client import OAuthAPIClient from edx_rest_api_client.client import OAuthAPIClient
from lxml import etree 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 ( from opaque_keys.edx.locator import (
LibraryLocatorV2, LibraryLocatorV2,
LibraryUsageLocatorV2, LibraryUsageLocatorV2,
LibraryLocator as LibraryLocatorV1 LibraryLocator as LibraryLocatorV1
) )
from opaque_keys import InvalidKeyError 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 ( from openedx_events.content_authoring.signals import (
CONTENT_OBJECT_TAGS_CHANGED,
CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_CREATED,
CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_DELETED,
CONTENT_LIBRARY_UPDATED, CONTENT_LIBRARY_UPDATED,
LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_CREATED,
LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_DELETED,
LIBRARY_BLOCK_UPDATED, LIBRARY_BLOCK_UPDATED,
LIBRARY_COLLECTION_CREATED,
LIBRARY_COLLECTION_UPDATED,
) )
from openedx_learning.api import authoring as authoring_api 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 organizations.models import Organization
from xblock.core import XBlock from xblock.core import XBlock
from xblock.exceptions import XBlockNotFoundError from xblock.exceptions import XBlockNotFoundError
@@ -111,6 +119,8 @@ log = logging.getLogger(__name__)
ContentLibraryNotFound = ContentLibrary.DoesNotExist ContentLibraryNotFound = ContentLibrary.DoesNotExist
ContentLibraryCollectionNotFound = Collection.DoesNotExist
class ContentLibraryBlockNotFound(XBlockNotFoundError): class ContentLibraryBlockNotFound(XBlockNotFoundError):
""" XBlock not found in the content library """ """ XBlock not found in the content library """
@@ -120,6 +130,10 @@ class LibraryAlreadyExists(KeyError):
""" A library with the specified slug already exists """ """ A library with the specified slug already exists """
class LibraryCollectionAlreadyExists(IntegrityError):
""" A Collection with that key already exists in the library """
class LibraryBlockAlreadyExists(KeyError): class LibraryBlockAlreadyExists(KeyError):
""" An XBlock with that ID already exists in the library """ """ 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. Class that represents the metadata about a content library.
""" """
key = attr.ib(type=LibraryLocatorV2) key = attr.ib(type=LibraryLocatorV2)
learning_package = attr.ib(type=LearningPackage)
title = attr.ib("") title = attr.ib("")
description = attr.ib("") description = attr.ib("")
num_blocks = attr.ib(0) num_blocks = attr.ib(0)
@@ -323,13 +338,14 @@ def get_metadata(queryset, text_search=None):
has_unpublished_changes=False, has_unpublished_changes=False,
has_unpublished_deletes=False, has_unpublished_deletes=False,
license=lib.license, license=lib.license,
learning_package=lib.learning_package,
) )
for lib in queryset for lib in queryset
] ]
return libraries 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 Given any of the content library permission strings defined in
openedx.core.djangoapps.content_libraries.permissions, 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 Raises django.core.exceptions.PermissionDenied if the user doesn't have
permission. 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): if not user.has_perm(permission, obj=library_obj):
raise PermissionDenied raise PermissionDenied
return library_obj
def get_library(library_key): def get_library(library_key):
""" """
@@ -408,6 +426,7 @@ def get_library(library_key):
license=ref.license, license=ref.license,
created=learning_package.created, created=learning_package.created,
updated=learning_package.updated, updated=learning_package.updated,
learning_package=learning_package
) )
@@ -479,6 +498,7 @@ def create_library(
allow_public_learning=ref.allow_public_learning, allow_public_learning=ref.allow_public_learning,
allow_public_read=ref.allow_public_read, allow_public_read=ref.allow_public_read,
license=library_license, 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 # V1/V2 Compatibility Helpers
# (Should be removed as part of # (Should be removed as part of
# https://github.com/openedx/edx-platform/issues/32457) # 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 # pylint: disable=abstract-method
from django.core.validators import validate_unicode_slug from django.core.validators import validate_unicode_slug
from rest_framework import serializers 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 ( from openedx.core.djangoapps.content_libraries.constants import (
LIBRARY_TYPES, LIBRARY_TYPES,
COMPLEX, COMPLEX,
@@ -245,3 +250,52 @@ class ContentLibraryBlockImportTaskCreateSerializer(serializers.Serializer):
""" """
course_key = CourseKeyField() 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, UsageKey,
) )
from opaque_keys.edx.locator import LibraryLocatorV2 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 .. import api
from ..models import ContentLibrary
from .base import ContentLibrariesRestApiTest
class EdxModulestoreImportClientTest(TestCase): class EdxModulestoreImportClientTest(TestCase):
@@ -241,3 +253,220 @@ class EdxApiImportClientTest(TestCase):
block_olx block_olx
) )
mock_publish_changes.assert_not_called() 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 rest_framework import routers
from . import views from . import views
from . import views_collections
# Django application name. # Django application name.
@@ -18,6 +19,11 @@ app_name = 'openedx.core.djangoapps.content_libraries'
import_blocks_router = routers.DefaultRouter() import_blocks_router = routers.DefaultRouter()
import_blocks_router.register(r'tasks', views.LibraryImportTaskViewSet, basename='import-block-task') 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 # These URLs are only used in Studio. The LMS already provides all the
# API endpoints needed to serve XBlocks from content libraries using 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) # 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)), path('import_blocks/', include(import_blocks_router.urls)),
# Paste contents of clipboard into library # Paste contents of clipboard into library
path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()), path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()),
# Library Collections
path('', include(library_collections_router.urls)),
])), ])),
path('blocks/<str:usage_key_str>/', include([ path('blocks/<str:usage_key_str>/', include([
# Get metadata about a specific XBlock in this library, or delete the block: # 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 from pylti1p3.exception import LtiException, OIDCException
import edx_api_doc_tools as apidocs import edx_api_doc_tools as apidocs
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from organizations.api import ensure_organization from organizations.api import ensure_organization
from organizations.exceptions import InvalidOrganizationException from organizations.exceptions import InvalidOrganizationException
@@ -136,12 +137,21 @@ def convert_exceptions(fn):
def wrapped_fn(*args, **kwargs): def wrapped_fn(*args, **kwargs):
try: try:
return fn(*args, **kwargs) return fn(*args, **kwargs)
except InvalidKeyError as exc:
log.exception(str(exc))
raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
except api.ContentLibraryNotFound: except api.ContentLibraryNotFound:
log.exception("Content library not found") log.exception("Content library not found")
raise NotFound # lint-amnesty, pylint: disable=raise-missing-from raise NotFound # lint-amnesty, pylint: disable=raise-missing-from
except api.ContentLibraryBlockNotFound: except api.ContentLibraryBlockNotFound:
log.exception("XBlock not found in content library") log.exception("XBlock not found in content library")
raise NotFound # lint-amnesty, pylint: disable=raise-missing-from 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: except api.LibraryBlockAlreadyExists as exc:
log.exception(str(exc)) log.exception(str(exc))
raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from 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 click==8.1.6
# pinning this version to avoid updates while the library is being developed # 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. # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.
openai<=0.28.1 openai<=0.28.1

View File

@@ -811,7 +811,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/kernel.in # via -r requirements/edx/kernel.in
openedx-django-wiki==2.1.0 openedx-django-wiki==2.1.0
# via -r requirements/edx/kernel.in # via -r requirements/edx/kernel.in
openedx-events==9.12.0 openedx-events==9.14.0
# via # via
# -r requirements/edx/kernel.in # -r requirements/edx/kernel.in
# edx-enterprise # edx-enterprise
@@ -824,7 +824,7 @@ openedx-filters==1.9.0
# -r requirements/edx/kernel.in # -r requirements/edx/kernel.in
# lti-consumer-xblock # lti-consumer-xblock
# ora2 # ora2
openedx-learning==0.11.2 openedx-learning==0.11.4
# via # via
# -c requirements/edx/../constraints.txt # -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in # -r requirements/edx/kernel.in

View File

@@ -1358,7 +1358,7 @@ openedx-django-wiki==2.1.0
# via # via
# -r requirements/edx/doc.txt # -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt # -r requirements/edx/testing.txt
openedx-events==9.12.0 openedx-events==9.14.0
# via # via
# -r requirements/edx/doc.txt # -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt # -r requirements/edx/testing.txt
@@ -1373,7 +1373,7 @@ openedx-filters==1.9.0
# -r requirements/edx/testing.txt # -r requirements/edx/testing.txt
# lti-consumer-xblock # lti-consumer-xblock
# ora2 # ora2
openedx-learning==0.11.2 openedx-learning==0.11.4
# via # via
# -c requirements/edx/../constraints.txt # -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt # -r requirements/edx/doc.txt

View File

@@ -970,7 +970,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/base.txt # via -r requirements/edx/base.txt
openedx-django-wiki==2.1.0 openedx-django-wiki==2.1.0
# via -r requirements/edx/base.txt # via -r requirements/edx/base.txt
openedx-events==9.12.0 openedx-events==9.14.0
# via # via
# -r requirements/edx/base.txt # -r requirements/edx/base.txt
# edx-enterprise # edx-enterprise
@@ -983,7 +983,7 @@ openedx-filters==1.9.0
# -r requirements/edx/base.txt # -r requirements/edx/base.txt
# lti-consumer-xblock # lti-consumer-xblock
# ora2 # ora2
openedx-learning==0.11.2 openedx-learning==0.11.4
# via # via
# -c requirements/edx/../constraints.txt # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt # -r requirements/edx/base.txt

View File

@@ -1021,7 +1021,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/base.txt # via -r requirements/edx/base.txt
openedx-django-wiki==2.1.0 openedx-django-wiki==2.1.0
# via -r requirements/edx/base.txt # via -r requirements/edx/base.txt
openedx-events==9.12.0 openedx-events==9.14.0
# via # via
# -r requirements/edx/base.txt # -r requirements/edx/base.txt
# edx-enterprise # edx-enterprise
@@ -1034,7 +1034,7 @@ openedx-filters==1.9.0
# -r requirements/edx/base.txt # -r requirements/edx/base.txt
# lti-consumer-xblock # lti-consumer-xblock
# ora2 # ora2
openedx-learning==0.11.2 openedx-learning==0.11.4
# via # via
# -c requirements/edx/../constraints.txt # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt # -r requirements/edx/base.txt