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

@@ -6,11 +6,12 @@ from __future__ import annotations
import logging
from hashlib import blake2b
from django.utils.text import slugify
from django.core.exceptions import ObjectDoesNotExist
from django.utils.text import slugify
from opaque_keys.edx.keys import LearningContextKey, UsageKey
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
from openedx_learning.api import authoring as authoring_api
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator
from openedx_learning.api.authoring_models import Collection
from rest_framework.exceptions import NotFound
from openedx.core.djangoapps.content.search.models import SearchAccess
@@ -19,7 +20,6 @@ from openedx.core.djangoapps.content_libraries import api as lib_api
from openedx.core.djangoapps.content_tagging import api as tagging_api
from openedx.core.djangoapps.xblock import api as xblock_api
from openedx.core.djangoapps.xblock.data import LatestVersion
from openedx_learning.api.authoring_models import Collection
log = logging.getLogger(__name__)
@@ -554,7 +554,7 @@ def searchable_doc_for_container(
) -> dict:
"""
Generate a dictionary document suitable for ingestion into a search engine
like Meilisearch or Elasticsearch, so that the given collection can be
like Meilisearch or Elasticsearch, so that the given container can be
found using faceted search.
If no container is found for the given container key, the returned document
@@ -576,29 +576,33 @@ def searchable_doc_for_container(
try:
container = lib_api.get_container(container_key)
except lib_api.ContentLibraryCollectionNotFound:
except lib_api.ContentLibraryContainerNotFound:
# Container not found, so we can only return the base doc
pass
return doc
if container:
# TODO: check if there's a more efficient way to load these num_children counts?
draft_num_children = len(lib_api.get_container_children(container_key, published=False))
draft_num_children = lib_api.get_container_children_count(container_key, published=False)
publish_status = PublishStatus.published
if container.last_published is None:
publish_status = PublishStatus.never
elif container.has_unpublished_changes:
publish_status = PublishStatus.modified
doc.update({
Fields.display_name: container.display_name,
Fields.created: container.created.timestamp(),
Fields.modified: container.modified.timestamp(),
Fields.num_children: draft_num_children,
})
library = lib_api.get_library(container_key.library_key)
if library:
doc[Fields.breadcrumbs] = [{"display_name": library.title}]
doc.update({
Fields.display_name: container.display_name,
Fields.created: container.created.timestamp(),
Fields.modified: container.modified.timestamp(),
Fields.num_children: draft_num_children,
Fields.publish_status: publish_status,
})
library = lib_api.get_library(container_key.library_key)
if library:
doc[Fields.breadcrumbs] = [{"display_name": library.title}]
if container.published_version_num is not None:
published_num_children = len(lib_api.get_container_children(container_key, published=True))
doc[Fields.published] = {
# Fields.published_display_name: container_published.title, TODO: set the published title
Fields.published_num_children: published_num_children,
}
if container.published_version_num is not None:
published_num_children = lib_api.get_container_children_count(container_key, published=True)
doc[Fields.published] = {
# Fields.published_display_name: container_published.title, TODO: set the published title
Fields.published_num_children: published_num_children,
}
return doc

View File

@@ -227,6 +227,7 @@ class TestSearchApi(ModuleStoreTestCase):
"display_name": "Unit 1",
# description is not set for containers
"num_children": 0,
"publish_status": "never",
"context_key": "lib:org1:lib",
"org": "org1",
"created": created_date.timestamp(),

View File

@@ -3,12 +3,13 @@ Tests for the Studio content search documents (what gets stored in the index)
"""
from dataclasses import replace
from datetime import datetime, timezone
from organizations.models import Organization
from freezegun import freeze_time
from openedx_learning.api import authoring as authoring_api
from organizations.models import Organization
from openedx.core.djangoapps.content_tagging import api as tagging_api
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_tagging import api as tagging_api
from openedx.core.djangolib.testing.utils import skip_unless_cms
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
@@ -17,13 +18,13 @@ from xmodule.modulestore.tests.factories import BlockFactory, ToyCourseFactory
try:
# This import errors in the lms because content.search is not an installed app there.
from ..documents import (
searchable_doc_for_course_block,
searchable_doc_tags,
searchable_doc_tags_for_collection,
searchable_doc_collections,
searchable_doc_for_collection,
searchable_doc_for_container,
searchable_doc_for_course_block,
searchable_doc_for_library_block,
searchable_doc_tags,
searchable_doc_tags_for_collection,
)
from ..models import SearchAccess
except RuntimeError:
@@ -522,6 +523,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
"display_name": "A Unit in the Search Index",
# description is not set for containers
"num_children": 0,
"publish_status": "never",
"context_key": "lib:edX:2012_Fall",
"access_id": self.library_access_id,
"breadcrumbs": [{"display_name": "some content_library"}],
@@ -531,6 +533,106 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
# "published" is not set since we haven't published it yet
}
def test_published_container(self):
"""
Test creating a search document for a published container
"""
created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc)
with freeze_time(created_date):
container_meta = library_api.create_container(
self.library.key,
container_type=library_api.ContainerType.Unit,
slug="unit1",
title="A Unit in the Search Index",
user_id=None,
)
library_api.update_container_children(
container_meta.container_key,
[self.library_block.usage_key],
user_id=None,
)
library_api.publish_changes(self.library.key)
doc = searchable_doc_for_container(container_meta.container_key)
assert doc == {
"id": "lctedx2012_fallunitunit1-edd13a0c",
"block_id": "unit1",
"block_type": "unit",
"usage_key": "lct:edX:2012_Fall:unit:unit1",
"type": "library_container",
"org": "edX",
"display_name": "A Unit in the Search Index",
# description is not set for containers
"num_children": 1,
"publish_status": "published",
"context_key": "lib:edX:2012_Fall",
"access_id": self.library_access_id,
"breadcrumbs": [{"display_name": "some content_library"}],
"created": 1680674828.0,
"modified": 1680674828.0,
"published": {"num_children": 1},
# "tags" should be here but we haven't implemented them yet
# "published" is not set since we haven't published it yet
}
def test_published_container_with_changes(self):
"""
Test creating a search document for a published container
"""
created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc)
with freeze_time(created_date):
container_meta = library_api.create_container(
self.library.key,
container_type=library_api.ContainerType.Unit,
slug="unit1",
title="A Unit in the Search Index",
user_id=None,
)
library_api.update_container_children(
container_meta.container_key,
[self.library_block.usage_key],
user_id=None,
)
library_api.publish_changes(self.library.key)
block_2 = library_api.create_library_block(
self.library.key,
"html",
"text3",
)
# Add another component after publish
with freeze_time(created_date):
library_api.update_container_children(
container_meta.container_key,
[block_2.usage_key],
user_id=None,
entities_action=authoring_api.ChildrenEntitiesAction.APPEND,
)
doc = searchable_doc_for_container(container_meta.container_key)
assert doc == {
"id": "lctedx2012_fallunitunit1-edd13a0c",
"block_id": "unit1",
"block_type": "unit",
"usage_key": "lct:edX:2012_Fall:unit:unit1",
"type": "library_container",
"org": "edX",
"display_name": "A Unit in the Search Index",
# description is not set for containers
"num_children": 2,
"publish_status": "modified",
"context_key": "lib:edX:2012_Fall",
"access_id": self.library_access_id,
"breadcrumbs": [{"display_name": "some content_library"}],
"created": 1680674828.0,
"modified": 1680674828.0,
"published": {"num_children": 1},
# "tags" should be here but we haven't implemented them yet
# "published" is not set since we haven't published it yet
}
def test_mathjax_plain_text_conversion_for_search(self):
"""
Test how an HTML block with mathjax equations gets converted to plain text in search description.

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()),

View File

@@ -112,7 +112,7 @@ numpy<2.0.0
# Date: 2023-09-18
# pinning this version to avoid updates while the library is being developed
# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269
openedx-learning==0.19.1
openedx-learning==0.19.2
# Date: 2023-11-29
# Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.

View File

@@ -820,7 +820,7 @@ openedx-filters==2.0.1
# ora2
openedx-forum==0.1.9
# via -r requirements/edx/kernel.in
openedx-learning==0.19.1
openedx-learning==0.19.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in

View File

@@ -1383,7 +1383,7 @@ openedx-forum==0.1.9
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
openedx-learning==0.19.1
openedx-learning==0.19.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt

View File

@@ -992,7 +992,7 @@ openedx-filters==2.0.1
# ora2
openedx-forum==0.1.9
# via -r requirements/edx/base.txt
openedx-learning==0.19.1
openedx-learning==0.19.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt

View File

@@ -1050,7 +1050,7 @@ openedx-filters==2.0.1
# ora2
openedx-forum==0.1.9
# via -r requirements/edx/base.txt
openedx-learning==0.19.1
openedx-learning==0.19.2
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt