Skip to content

Commit

Permalink
feat: adds REST API to add/remove components to/from a collection
Browse files Browse the repository at this point in the history
Raises a CONTENT_OBJECT_TAGS_CHANGED for each component added/removed.
  • Loading branch information
pomegranited committed Aug 28, 2024
1 parent 0c4e872 commit 704bb11
Show file tree
Hide file tree
Showing 3 changed files with 178 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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="[email protected]")
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,27 @@
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,
)

from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.serializers import (
ContentLibraryCollectionSerializer,
ContentLibraryCollectionContentsUpdateSerializer,
ContentLibraryCollectionCreateOrUpdateSerializer,
)

Expand Down Expand Up @@ -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})
8 changes: 8 additions & 0 deletions openedx/core/djangoapps/content_libraries/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

0 comments on commit 704bb11

Please sign in to comment.