Skip to content

Commit

Permalink
feat: add/remove components to/from a collection
Browse files Browse the repository at this point in the history
Python API:
* Converts UsageKeyV2 object keys into component keys for use with the oel_collections api.
* Sends a CONTENT_OBJECT_TAGS_CHANGED for each component added/removed.

REST API:
* Calls the python API
* Receives a collection PK + a list of UsageKeys to add to the collection.
  • Loading branch information
pomegranited committed Aug 28, 2024
1 parent 250434f commit f4521b5
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 7 deletions.
74 changes: 71 additions & 3 deletions openedx/core/djangoapps/content_libraries/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
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,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="[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,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
Expand All @@ -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):
"""
Expand Down Expand Up @@ -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})
33 changes: 33 additions & 0 deletions openedx/core/djangoapps/content_libraries/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)

0 comments on commit f4521b5

Please sign in to comment.