feat: api for adding, removing and updating components in container (#36434)

* feat: add components to container api

* feat: remove and replace components in container api

* refactor: container childern api

* chore: fix lint issues

* temp: install openedx-learning dev branch

* feat: update publish_status and children count in index

* chore: fix mypy issues

* test: fix reindex test

* refactor: rebase and fix conflicts

* test: update test to check signals

* docs: document can_stand_alone flag

* chore: bump openedx-learning version
This commit is contained in:
Navin Karkera
2025-03-31 12:10:17 +00:00
committed by GitHub
parent 896ca99c79
commit bcaa79cc38
16 changed files with 601 additions and 56 deletions

View File

@@ -2,6 +2,7 @@
API for containers (Sections, Subsections, Units) in Content Libraries
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
@@ -9,10 +10,10 @@ from uuid import uuid4
from django.utils.text import slugify
from opaque_keys.edx.locator import (
LibraryLocatorV2,
LibraryContainerLocator,
LibraryLocatorV2,
UsageKeyV2,
)
from openedx_events.content_authoring.data import LibraryContainerData
from openedx_events.content_authoring.signals import (
LIBRARY_CONTAINER_CREATED,
@@ -22,8 +23,10 @@ from openedx_events.content_authoring.signals import (
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import Container
from openedx.core.djangoapps.xblock.api import get_component_from_usage_key
from ..models import ContentLibrary
from .libraries import PublishableItem
from .libraries import LibraryXBlockMetadata, PublishableItem
# The public API is only the following symbols:
@@ -34,9 +37,11 @@ __all__ = [
"get_container",
"create_container",
"get_container_children",
"get_container_children_count",
"library_container_locator",
"update_container",
"delete_container",
"update_container_children",
]
@@ -252,14 +257,62 @@ def get_container_children(
"""
Get the entities contained in the given container (e.g. the components/xblocks in a unit)
"""
assert isinstance(container_key, LibraryContainerLocator)
content_library = ContentLibrary.objects.get_by_key(container_key.library_key)
learning_package = content_library.learning_package
assert learning_package is not None
container = authoring_api.get_container_by_key(
learning_package.id,
key=container_key.container_id,
container = _get_container(container_key)
if container_key.container_type == ContainerType.Unit.value:
child_components = authoring_api.get_components_in_unit(container.unit, published=published)
return [LibraryXBlockMetadata.from_component(
container_key.library_key,
entry.component
) for entry in child_components]
else:
child_entities = authoring_api.get_entities_in_container(container, published=published)
return [ContainerMetadata.from_container(
container_key.library_key,
entry.entity
) for entry in child_entities]
def get_container_children_count(
container_key: LibraryContainerLocator,
published=False,
) -> int:
"""
Get the count of entities contained in the given container (e.g. the components/xblocks in a unit)
"""
container = _get_container(container_key)
return authoring_api.get_container_children_count(container, published=published)
def update_container_children(
container_key: LibraryContainerLocator,
children_ids: list[UsageKeyV2] | list[LibraryContainerLocator],
user_id: int | None,
entities_action: authoring_api.ChildrenEntitiesAction = authoring_api.ChildrenEntitiesAction.REPLACE,
):
"""
Adds children components or containers to given container.
"""
library_key = container_key.library_key
container_type = container_key.container_type
container = _get_container(container_key)
match container_type:
case ContainerType.Unit.value:
components = [get_component_from_usage_key(key) for key in children_ids] # type: ignore[arg-type]
new_version = authoring_api.create_next_unit_version(
container.unit,
components=components, # type: ignore[arg-type]
created=datetime.now(),
created_by=user_id,
entities_action=entities_action,
)
case _:
raise ValueError(f"Invalid container type: {container_type}")
LIBRARY_CONTAINER_UPDATED.send_event(
library_container=LibraryContainerData(
library_key=library_key,
container_key=str(container_key),
)
)
child_entities = authoring_api.get_entities_in_container(container, published=published)
# TODO: convert the return type to list[ContainerMetadata | LibraryXBlockMetadata] ?
return child_entities
return ContainerMetadata.from_container(library_key, new_version.container)

View File

@@ -302,6 +302,7 @@ class PublishableItem(LibraryItem):
last_draft_created_by: str = ""
has_unpublished_changes: bool = False
collections: list[CollectionMetadata] = field(default_factory=list)
can_stand_alone: bool = True
@dataclass(frozen=True, kw_only=True)
@@ -343,6 +344,7 @@ class LibraryXBlockMetadata(PublishableItem):
last_draft_created_by=last_draft_created_by,
has_unpublished_changes=component.versioning.has_unpublished_changes,
collections=associated_collections or [],
can_stand_alone=component.publishable_entity.can_stand_alone,
)
@@ -958,9 +960,17 @@ def validate_can_add_block_to_library(
return content_library, usage_key
def create_library_block(library_key, block_type, definition_id, user_id=None):
def create_library_block(
library_key: LibraryLocatorV2,
block_type: str,
definition_id: str,
user_id: int | None = None,
can_stand_alone: bool = True,
):
"""
Create a new XBlock in this library of the specified type (e.g. "html").
Set can_stand_alone = False when a component is created under a container, like unit.
"""
# It's in the serializer as ``definition_id``, but for our purposes, it's
# the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for
@@ -969,7 +979,7 @@ def create_library_block(library_key, block_type, definition_id, user_id=None):
content_library, usage_key = validate_can_add_block_to_library(library_key, block_type, block_id)
_create_component_for_block(content_library, usage_key, user_id)
_create_component_for_block(content_library, usage_key, user_id, can_stand_alone)
# Now return the metadata about the new block:
LIBRARY_BLOCK_CREATED.send_event(
@@ -1135,6 +1145,7 @@ def _create_component_for_block(
content_lib: ContentLibrary,
usage_key: LibraryUsageLocatorV2,
user_id: int | None = None,
can_stand_alone: bool = True,
):
"""
Create a Component for an XBlock type, initialize it, and return the ComponentVersion.
@@ -1144,6 +1155,8 @@ def _create_component_for_block(
will be set as the current draft. This function does not publish the
Component.
Set can_stand_alone = False when a component is created under a container, like unit.
TODO: We should probably shift this to openedx.core.djangoapps.xblock.api
(along with its caller) since it gives runtime storage specifics. The
Library-specific logic stays in this module, so "create a block for my lib"
@@ -1168,6 +1181,7 @@ def _create_component_for_block(
title=display_name,
created=now,
created_by=user_id,
can_stand_alone=can_stand_alone,
)
content = authoring_api.get_or_create_text_content(
learning_package.id,

View File

@@ -21,8 +21,8 @@ from ..models import ContentLibrary
from .utils import convert_exceptions
from .serializers import (
ContentLibraryCollectionSerializer,
ContentLibraryCollectionComponentsUpdateSerializer,
ContentLibraryCollectionUpdateSerializer,
ContentLibraryComponentKeysSerializer,
)
from openedx.core.types.http import RestRequest
@@ -200,7 +200,7 @@ class LibraryCollectionsView(ModelViewSet):
content_library = self.get_content_library()
collection_key = kwargs["key"]
serializer = ContentLibraryCollectionComponentsUpdateSerializer(data=request.data)
serializer = ContentLibraryComponentKeysSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
usage_keys = serializer.validated_data["usage_keys"]

View File

@@ -8,10 +8,10 @@ import logging
from django.contrib.auth import get_user_model
from django.db.transaction import non_atomic_requests
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from drf_yasg.utils import swagger_auto_schema
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator
from openedx_learning.api import authoring as authoring_api
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.status import HTTP_204_NO_CONTENT
@@ -124,3 +124,152 @@ class LibraryContainerView(GenericAPIView):
)
return Response({}, status=HTTP_204_NO_CONTENT)
@method_decorator(non_atomic_requests, name="dispatch")
@view_auth_classes()
class LibraryContainerChildrenView(GenericAPIView):
"""
View to get or update children of specific container (a section, subsection, or unit)
"""
serializer_class = serializers.LibraryXBlockMetadataSerializer
@convert_exceptions
@swagger_auto_schema(
responses={200: list[serializers.LibraryXBlockMetadataSerializer]}
)
def get(self, request, container_key: LibraryContainerLocator):
"""
Get children components of given container
Example:
GET /api/libraries/v2/containers/<container_key>/children/
Result:
[
{
'block_type': 'problem',
'can_stand_alone': True,
'collections': [],
'created': '2025-03-21T13:53:55Z',
'def_key': None,
'display_name': 'Blank Problem',
'has_unpublished_changes': True,
'id': 'lb:CL-TEST:containers:problem:Problem1',
'last_draft_created': '2025-03-21T13:53:55Z',
'last_draft_created_by': 'Bob',
'last_published': None,
'modified': '2025-03-21T13:53:55Z',
'published_by': None,
},
{
'block_type': 'html',
'can_stand_alone': False,
'collections': [],
'created': '2025-03-21T13:53:55Z',
'def_key': None,
'display_name': 'Text',
'has_unpublished_changes': True,
'id': 'lb:CL-TEST:containers:html:Html1',
'last_draft_created': '2025-03-21T13:53:55Z',
'last_draft_created_by': 'Bob',
'last_published': None,
'modified': '2025-03-21T13:53:55Z',
'published_by': None,
}
]
"""
published = request.GET.get('published', False)
api.require_permission_for_library_key(
container_key.library_key,
request.user,
permissions.CAN_VIEW_THIS_CONTENT_LIBRARY,
)
child_entities = api.get_container_children(container_key, published)
if container_key.container_type == api.ContainerType.Unit.value:
data = serializers.LibraryXBlockMetadataSerializer(child_entities, many=True).data
else:
data = serializers.LibraryContainerMetadataSerializer(child_entities, many=True).data
return Response(data)
def _update_component_children(
self,
request,
container_key: LibraryContainerLocator,
action: authoring_api.ChildrenEntitiesAction,
):
"""
Helper function to update children in container.
"""
api.require_permission_for_library_key(
container_key.library_key,
request.user,
permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
)
serializer = serializers.ContentLibraryComponentKeysSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# Only components under units are supported for now.
assert container_key.container_type == api.ContainerType.Unit.value
container = api.update_container_children(
container_key,
children_ids=serializer.validated_data["usage_keys"],
user_id=request.user.id,
entities_action=action,
)
return Response(serializers.LibraryContainerMetadataSerializer(container).data)
@convert_exceptions
@swagger_auto_schema(
request_body=serializers.ContentLibraryComponentKeysSerializer,
responses={200: serializers.LibraryContainerMetadataSerializer}
)
def post(self, request, container_key: LibraryContainerLocator):
"""
Add components to unit
Example:
POST /api/libraries/v2/containers/<container_key>/children/
Request body:
{"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']}
"""
return self._update_component_children(
request,
container_key,
action=authoring_api.ChildrenEntitiesAction.APPEND,
)
@convert_exceptions
@swagger_auto_schema(
request_body=serializers.ContentLibraryComponentKeysSerializer,
responses={200: serializers.LibraryContainerMetadataSerializer}
)
def delete(self, request, container_key: LibraryContainerLocator):
"""
Remove components from unit
Example:
DELETE /api/libraries/v2/containers/<container_key>/children/
Request body:
{"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']}
"""
return self._update_component_children(
request,
container_key,
action=authoring_api.ChildrenEntitiesAction.REMOVE,
)
@convert_exceptions
@swagger_auto_schema(
request_body=serializers.ContentLibraryComponentKeysSerializer,
responses={200: serializers.LibraryContainerMetadataSerializer}
)
def patch(self, request, container_key: LibraryContainerLocator):
"""
Replace components in unit, can be used to reorder components as well.
Example:
PATCH /api/libraries/v2/containers/<container_key>/children/
Request body:
{"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']}
"""
return self._update_component_children(
request,
container_key,
action=authoring_api.ChildrenEntitiesAction.REPLACE,
)

View File

@@ -159,6 +159,7 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer):
tags_count = serializers.IntegerField(read_only=True)
collections = CollectionMetadataSerializer(many=True, required=False)
can_stand_alone = serializers.BooleanField(read_only=True)
class LibraryXBlockTypeSerializer(serializers.Serializer):
@@ -193,6 +194,9 @@ class LibraryXBlockCreationSerializer(serializers.Serializer):
# creating new block from scratch
staged_content = serializers.CharField(required=False)
# Optional param defaults to True, set to False if block is being created under a container.
can_stand_alone = serializers.BooleanField(required=False, default=True)
class LibraryPasteClipboardSerializer(serializers.Serializer):
"""
@@ -345,7 +349,7 @@ class UsageKeyV2Serializer(serializers.BaseSerializer):
raise ValidationError from err
class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer):
class ContentLibraryComponentKeysSerializer(serializers.Serializer):
"""
Serializer for adding/removing Components to/from a Collection.
"""

View File

@@ -33,6 +33,7 @@ URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specifie
URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock
URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file
URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library
URL_LIB_CONTAINER_COMPONENTS = URL_LIB_CONTAINER + 'children/' # Get, add or delete a component in this container
URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/'
URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
@@ -229,9 +230,21 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
expect_response
)
def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200):
def _add_block_to_library(
self,
lib_key,
block_type,
slug,
parent_block=None,
can_stand_alone=True,
expect_response=200,
):
""" Add a new XBlock to the library """
data = {"block_type": block_type, "definition_id": slug}
data = {
"block_type": block_type,
"definition_id": slug,
"can_stand_alone": can_stand_alone,
}
if parent_block:
data["parent_block"] = parent_block
return self._api('post', URL_LIB_BLOCKS.format(lib_key=lib_key), data, expect_response)
@@ -372,3 +385,54 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
def _delete_container(self, container_key: str, expect_response=204):
""" Delete a container (unit etc.) """
return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response)
def _get_container_components(self, container_key: str, expect_response=200):
""" Get container components"""
return self._api(
'get',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
None,
expect_response
)
def _add_container_components(
self,
container_key: str,
children_ids: list[str],
expect_response=200,
):
""" Add container components"""
return self._api(
'post',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
{'usage_keys': children_ids},
expect_response
)
def _remove_container_components(
self,
container_key: str,
children_ids: list[str],
expect_response=200,
):
""" Remove container components"""
return self._api(
'delete',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
{'usage_keys': children_ids},
expect_response
)
def _patch_container_components(
self,
container_key: str,
children_ids: list[str],
expect_response=200,
):
""" Update container components"""
return self._api(
'patch',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
{'usage_keys': children_ids},
expect_response
)

View File

@@ -2,10 +2,10 @@
Tests for Learning-Core-based Content Libraries
"""
from datetime import datetime, timezone
from unittest import mock
import ddt
from freezegun import freeze_time
from unittest import mock
from opaque_keys.edx.locator import LibraryLocatorV2
from openedx_events.content_authoring.data import LibraryContainerData
@@ -178,3 +178,155 @@ class ContainersTestCase(OpenEdxEventsTestMixin, ContentLibrariesRestApiTest):
assert container1_data["container_key"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-")
assert container2_data["container_key"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-")
assert container1_data["container_key"] != container2_data["container_key"]
def test_unit_add_children(self):
"""
Test that we can add and get unit children components
"""
update_receiver = mock.Mock()
LIBRARY_CONTAINER_UPDATED.connect(update_receiver)
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
lib_key = LibraryLocatorV2.from_string(lib["id"])
# Create container and add some components
container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None)
problem_block = self._add_block_to_library(lib["id"], "problem", "Problem1", can_stand_alone=False)
html_block = self._add_block_to_library(lib["id"], "html", "Html1", can_stand_alone=False)
self._add_container_components(
container_data["container_key"],
children_ids=[problem_block["id"], html_block["id"]]
)
data = self._get_container_components(container_data["container_key"])
assert len(data) == 2
assert data[0]['id'] == problem_block['id']
assert not data[0]['can_stand_alone']
assert data[1]['id'] == html_block['id']
assert not data[1]['can_stand_alone']
problem_block_2 = self._add_block_to_library(lib["id"], "problem", "Problem2", can_stand_alone=False)
html_block_2 = self._add_block_to_library(lib["id"], "html", "Html2")
# Add two more components
self._add_container_components(
container_data["container_key"],
children_ids=[problem_block_2["id"], html_block_2["id"]]
)
self.assertDictContainsSubset(
{
"signal": LIBRARY_CONTAINER_UPDATED,
"sender": None,
"library_container": LibraryContainerData(
lib_key,
container_key=container_data["container_key"],
),
},
update_receiver.call_args_list[0].kwargs,
)
data = self._get_container_components(container_data["container_key"])
# Verify total number of components to be 2 + 2 = 4
assert len(data) == 4
assert data[2]['id'] == problem_block_2['id']
assert not data[2]['can_stand_alone']
assert data[3]['id'] == html_block_2['id']
assert data[3]['can_stand_alone']
def test_unit_remove_children(self):
"""
Test that we can remove unit children components
"""
update_receiver = mock.Mock()
LIBRARY_CONTAINER_UPDATED.connect(update_receiver)
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
lib_key = LibraryLocatorV2.from_string(lib["id"])
# Create container and add some components
container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None)
problem_block = self._add_block_to_library(lib["id"], "problem", "Problem1", can_stand_alone=False)
html_block = self._add_block_to_library(lib["id"], "html", "Html1", can_stand_alone=False)
problem_block_2 = self._add_block_to_library(lib["id"], "problem", "Problem2", can_stand_alone=False)
html_block_2 = self._add_block_to_library(lib["id"], "html", "Html2")
self._add_container_components(
container_data["container_key"],
children_ids=[problem_block["id"], html_block["id"], problem_block_2["id"], html_block_2["id"]]
)
data = self._get_container_components(container_data["container_key"])
assert len(data) == 4
# Remove both problem blocks.
self._remove_container_components(
container_data["container_key"],
children_ids=[problem_block_2["id"], problem_block["id"]]
)
data = self._get_container_components(container_data["container_key"])
assert len(data) == 2
assert data[0]['id'] == html_block['id']
assert data[1]['id'] == html_block_2['id']
self.assertDictContainsSubset(
{
"signal": LIBRARY_CONTAINER_UPDATED,
"sender": None,
"library_container": LibraryContainerData(
lib_key,
container_key=container_data["container_key"],
),
},
update_receiver.call_args_list[0].kwargs,
)
def test_unit_replace_children(self):
"""
Test that we can completely replace/reorder unit children components.
"""
update_receiver = mock.Mock()
LIBRARY_CONTAINER_UPDATED.connect(update_receiver)
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
lib_key = LibraryLocatorV2.from_string(lib["id"])
# Create container and add some components
container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None)
problem_block = self._add_block_to_library(lib["id"], "problem", "Problem1", can_stand_alone=False)
html_block = self._add_block_to_library(lib["id"], "html", "Html1", can_stand_alone=False)
problem_block_2 = self._add_block_to_library(lib["id"], "problem", "Problem2", can_stand_alone=False)
html_block_2 = self._add_block_to_library(lib["id"], "html", "Html2")
self._add_container_components(
container_data["container_key"],
children_ids=[problem_block["id"], html_block["id"], problem_block_2["id"], html_block_2["id"]]
)
data = self._get_container_components(container_data["container_key"])
assert len(data) == 4
assert data[0]['id'] == problem_block['id']
assert data[1]['id'] == html_block['id']
assert data[2]['id'] == problem_block_2['id']
assert data[3]['id'] == html_block_2['id']
# Reorder the components
self._patch_container_components(
container_data["container_key"],
children_ids=[problem_block["id"], problem_block_2["id"], html_block["id"], html_block_2["id"]]
)
data = self._get_container_components(container_data["container_key"])
assert len(data) == 4
assert data[0]['id'] == problem_block['id']
assert data[1]['id'] == problem_block_2['id']
assert data[2]['id'] == html_block['id']
assert data[3]['id'] == html_block_2['id']
# Replace with new components
new_problem_block = self._add_block_to_library(lib["id"], "problem", "New_Problem", can_stand_alone=False)
new_html_block = self._add_block_to_library(lib["id"], "html", "New_Html", can_stand_alone=False)
self._patch_container_components(
container_data["container_key"],
children_ids=[new_problem_block["id"], new_html_block["id"]],
)
data = self._get_container_components(container_data["container_key"])
assert len(data) == 2
assert data[0]['id'] == new_problem_block['id']
assert data[1]['id'] == new_html_block['id']
self.assertDictContainsSubset(
{
"signal": LIBRARY_CONTAINER_UPDATED,
"sender": None,
"library_container": LibraryContainerData(
lib_key,
container_key=container_data["container_key"],
),
},
update_receiver.call_args_list[0].kwargs,
)

View File

@@ -80,6 +80,8 @@ urlpatterns = [
path('containers/<lib_container_key:container_key>/', include([
# Get metadata about a specific container in this library, update or delete the container:
path('', containers.LibraryContainerView.as_view()),
# update components under container
path('children/', containers.LibraryContainerChildrenView.as_view()),
# Update collections for a given container
# path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
# path('publish/', views.LibraryContainerPublishView.as_view()),