diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py index dbaa5f4c1cf9..43c42048fb8e 100644 --- a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +import ddt from openedx_learning.api.authoring_models import Collection from opaque_keys.edx.locator import LibraryLocatorV2 @@ -15,8 +16,10 @@ URL_PREFIX = '/api/libraries/v2/{lib_key}/' URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/' URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_id}/' +URL_LIB_COLLECTION_CONTENTS = URL_LIB_COLLECTION + 'contents/' +@ddt.ddt @skip_unless_cms # Content Library Collections REST API is only available in Studio class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest): """ @@ -52,6 +55,20 @@ def setUp(self): created_by=self.user, ) + # 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 @@ -254,3 +271,101 @@ def test_delete_library_collection(self): ) 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_CONTENTS.format(lib_key=self.lib1.library_key, collection_id=self.col1.id), + ) + 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_CONTENTS.format(lib_key=self.lib1.library_key, collection_id=self.col1.id), + data={ + "component_keys": [ + self.lib1_problem_block["component_key"], + self.lib1_html_block["component_key"], + ] + } + ) + 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_CONTENTS.format(lib_key=self.lib1.library_key, collection_id=self.col1.id), + data={ + "component_keys": [ + self.lib1_problem_block["component_key"], + ] + } + ) + 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_CONTENTS.format(lib_key=self.lib2.library_key, collection_id=self.col1.id), + ) + assert resp.status_code == 404 + + @ddt.data("patch", "delete") + def test_update_components_missing_data(self, method): + """ + List of component keys must contain at least one item. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_CONTENTS.format(lib_key=self.lib2.library_key, collection_id=self.col3.id), + ) + assert resp.status_code == 400 + assert resp.data == { + "component_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 validation error. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_CONTENTS.format(lib_key=self.lib2.library_key, collection_id=self.col3.id), + data={ + "component_keys": [ + self.lib1_problem_block["component_key"], + self.lib1_html_block["component_key"], + ] + } + ) + assert resp.status_code == 400 + assert resp.data == { + "component_keys": "Components not found in library", + } + + @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_CONTENTS.format(lib_key=self.lib1.library_key, collection_id=self.col1.id), + ) + assert resp.status_code == 403 + + resp = self.client.patch( + URL_LIB_COLLECTION_CONTENTS.format(lib_key=self.lib1.library_key, collection_id=self.col1.id), + ) + assert resp.status_code == 403 diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py index 0d89c4b8a2c7..ef381c5da735 100644 --- a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/views.py @@ -5,15 +5,19 @@ from __future__ import annotations from django.http import Http404 +from django.utils.translation import gettext as _ +from rest_framework.decorators import action +from rest_framework.exceptions import ValidationError from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED +from rest_framework.viewsets import ModelViewSet from opaque_keys.edx.locator import LibraryLocatorV2 -from openedx_events.content_authoring.data import LibraryCollectionData +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, ) @@ -21,6 +25,7 @@ from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.djangoapps.content_libraries.serializers import ( ContentLibraryCollectionSerializer, + ContentLibraryCollectionContentsUpdateSerializer, ContentLibraryCollectionCreateOrUpdateSerializer, ) @@ -179,3 +184,51 @@ def destroy(self, request, *args, **kwargs): # TODO: Implement the deletion logic and emit event signal return Response(None, status=HTTP_405_METHOD_NOT_ALLOWED) + + @action(detail=True, methods=['delete', 'patch'], url_path='contents', url_name='contents:update') + def update_contents(self, request, lib_key_str, pk=None): + """ + Adds (PATCH) or removes (DELETE) Components to/from a Collection. + + Collection and Components must all be part of the given library/learning package. + """ + library_key = LibraryLocatorV2.from_string(lib_key_str) + library_obj = api.require_permission_for_library_key( + library_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + collections_qset = authoring_api.get_learning_package_collections(library_obj.learning_package_id).filter(id=pk) + + collection = collections_qset.first() + if not collection: + raise Http404() + + serializer = ContentLibraryCollectionContentsUpdateSerializer(collection, data=request.data) + serializer.is_valid(raise_exception=True) + + # Only allow adding/removing components that are in the library's learning package. + # Note: a Component.key matches its PublishableEntity.key + contents_qset = library_obj.learning_package.publishable_entities.filter( + key__in=serializer.validated_data['component_keys'], + ) + + if not contents_qset.count(): + raise ValidationError({ + "component_keys": _("Components not found in library"), + }) + + if request.method == "DELETE": + count = authoring_api.remove_from_collections(collections_qset, contents_qset) + else: + assert request.method == "PATCH" + count = authoring_api.add_to_collections(collections_qset, contents_qset) + + # Emit a CONTENT_OBJECT_TAGS_CHANGED event for each of the objects added/removed + object_ids = contents_qset.values_list("pk", flat=True) + for object_id in object_ids: + CONTENT_OBJECT_TAGS_CHANGED.send_event( + content_object=ContentObjectData(object_id=object_id), + ) + + return Response({'count': count}) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 60892cb61ebc..899aa6a8a6be 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -269,3 +269,11 @@ class ContentLibraryCollectionCreateOrUpdateSerializer(serializers.Serializer): title = serializers.CharField() description = serializers.CharField() + + +class ContentLibraryCollectionContentsUpdateSerializer(serializers.Serializer): + """ + Serializer for adding/removing Components to/from a Collection. + """ + + component_keys = serializers.ListField(child=serializers.CharField(), allow_empty=False)