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:
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
198
openedx/core/djangoapps/content_libraries/views_collections.py
Normal file
198
openedx/core/djangoapps/content_libraries/views_collections.py
Normal 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)})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user