diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 9a4ccbd6456e..3fc9a1e72d4a 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -69,15 +69,20 @@ 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, +) from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_TAGS_CHANGED, CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, @@ -86,7 +91,7 @@ LIBRARY_BLOCK_UPDATED, ) from openedx_learning.api import authoring as authoring_api -from openedx_learning.api.authoring_models import Component, MediaType, LearningPackage +from openedx_learning.api.authoring_models import Collection, Component, MediaType, LearningPackage from organizations.models import Organization from xblock.core import XBlock from xblock.exceptions import XBlockNotFoundError @@ -1062,6 +1067,69 @@ def revert_changes(library_key): ) +def update_collection_contents( + library: ContentLibraryMetadata, + collection_pk: int, + usage_keys: list[UsageKeyV2], + remove=False, +) -> int: + """ + 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. + + Raises: + * Collection.DoesNotExist if no Collection with the given pk is found in the given library. + * Component.DoesNotExist if any of the given usage_keys don't correspond to a Component in the + library/learning_package. + """ + learning_package = library.learning_package + collections_qset = authoring_api.get_learning_package_collections( + learning_package.id, + ).filter(pk=collection_pk) + + collection = collections_qset.first() + if not collection: + raise Collection.DoesNotExist() + + # 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)) + + # Can raise Component.DoesNotExist + component = authoring_api.get_component_by_key( + learning_package.id, + namespace=block_type.block_family, + type_name=usage_key.block_type, + local_key=usage_key.block_id, + ) + component_keys.append(component.key) + + # Note: Component.key matches its PublishableEntity.key + contents_qset = learning_package.publishable_entities.filter( + key__in=component_keys, + ) + if not contents_qset.count(): + raise Component.DoesNotExist() + + if remove: + count = authoring_api.remove_from_collections(collections_qset, contents_qset) + else: + 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_keys = contents_qset.values_list("key", flat=True) + for object_key in object_keys: + CONTENT_OBJECT_TAGS_CHANGED.send_event( + content_object=ContentObjectData(object_id=object_key), + ) + + return count + + # V1/V2 Compatibility Helpers # (Should be removed as part of # https://github.com/openedx/edx-platform/issues/32457) diff --git a/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_libraries/collections/rest_api/v1/tests/test_views.py index dbaa5f4c1cf9..798e83116b29 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,106 @@ 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={ + "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_CONTENTS.format(lib_key=self.lib1.library_key, collection_id=self.col1.id), + 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_CONTENTS.format(lib_key=self.lib2.library_key, collection_id=self.col1.id), + 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 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 == { + "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 validation error. + """ + resp = getattr(self.client, method)( + URL_LIB_COLLECTION_CONTENTS.format(lib_key=self.lib2.library_key, collection_id=self.col3.id), + data={ + "usage_keys": [ + self.lib1_problem_block["id"], + self.lib1_html_block["id"], + ] + } + ) + assert resp.status_code == 400 + assert resp.data == { + "usage_keys": "Component(s) 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..c0a104101534 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,11 +5,16 @@ 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 openedx_learning.api.authoring_models import Collection, Component +from openedx_learning.api import authoring as authoring_api from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_events.content_authoring.data import LibraryCollectionData @@ -21,12 +26,10 @@ from openedx.core.djangoapps.content_libraries import api, permissions from openedx.core.djangoapps.content_libraries.serializers import ( ContentLibraryCollectionSerializer, + ContentLibraryCollectionContentsUpdateSerializer, ContentLibraryCollectionCreateOrUpdateSerializer, ) -from openedx_learning.api.authoring_models import Collection -from openedx_learning.api import authoring as authoring_api - class LibraryCollectionsView(ModelViewSet): """ @@ -179,3 +182,38 @@ 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, + ) + + serializer = ContentLibraryCollectionContentsUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + count = api.update_collection_contents( + library_obj, + collection_pk=pk, + usage_keys=serializer.validated_data["usage_keys"], + remove=(request.method == "DELETE"), + ) + except Collection.DoesNotExist as exc: + raise Http404() from exc + + except Component.DoesNotExist as exc: + # Only allows adding/removing components that are in the collection's learning package. + raise ValidationError({ + "usage_keys": _("Component(s) not found in library"), + }) from exc + + return Response({'count': count}) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 7c49d4af3c2b..c3eb2f056f9c 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -4,7 +4,10 @@ # 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 ( @@ -266,3 +269,33 @@ class ContentLibraryCollectionCreateOrUpdateSerializer(serializers.Serializer): title = serializers.CharField() description = serializers.CharField() + + +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 ContentLibraryCollectionContentsUpdateSerializer(serializers.Serializer): + """ + Serializer for adding/removing Components to/from a Collection. + """ + + usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False)