diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 14c7f712dcfd..c6ca52098abc 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -6,11 +6,12 @@ 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_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}] - - 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, - } + 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 = 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 diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 2decf2374fac..813db0241db1 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -227,6 +227,7 @@ def setUp(self): "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(), diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 38b1d607ab05..a2964436d039 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -3,12 +3,13 @@ """ 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 @@ 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,11 +523,112 @@ def test_draft_container(self): "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"}], + "created": 1680674828.0, + "modified": 1680674828.0, + # "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(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 } diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 5b24b6540b10..6719c07065e4 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -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 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_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 @@ "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) diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index 13d41921e860..a4b001cd9a47 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -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 @@ def from_component(cls, library_key, component, associated_collections=None): 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, diff --git a/openedx/core/djangoapps/content_libraries/rest_api/collections.py b/openedx/core/djangoapps/content_libraries/rest_api/collections.py index f1b63b2c1845..c49822ae2f0f 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/collections.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/collections.py @@ -21,8 +21,8 @@ from .utils import convert_exceptions from .serializers import ( ContentLibraryCollectionSerializer, - ContentLibraryCollectionComponentsUpdateSerializer, ContentLibraryCollectionUpdateSerializer, + ContentLibraryComponentKeysSerializer, ) from openedx.core.types.http import RestRequest @@ -200,7 +200,7 @@ def update_components(self, request: RestRequest, *args, **kwargs) -> Response: 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"] diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index ad23a51d55b3..95e468b4a43a 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -8,10 +8,10 @@ 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 @@ def delete(self, request, container_key: LibraryContainerLocator): ) 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//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//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//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//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, + ) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py index 67b8ec00e56f..46bec36e3c94 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -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 @@ def to_internal_value(self, value: str) -> UsageKeyV2: raise ValidationError from err -class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer): +class ContentLibraryComponentKeysSerializer(serializers.Serializer): """ Serializer for adding/removing Components to/from a Collection. """ diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index d8030afae7f0..6adb8184ea00 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -33,6 +33,7 @@ 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 @@ def _get_library_blocks(self, lib_key, query_params_dict=None, expect_response=2 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 @@ def _update_container(self, container_key: str, display_name: str, expect_respon 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 + ) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py index 23a519899a85..52546396f2bb 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_containers.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -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 @@ def test_unit_gets_auto_slugs(self): 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, + ) diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 1656a8589616..99e98a2cc424 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -80,6 +80,8 @@ path('containers//', 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()), diff --git a/requirements/constraints.txt b/requirements/constraints.txt index fed7e47c5447..2870901ee6be 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -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. diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 24ede4f1f253..fb768f360ae0 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c9acc77e898b..aaecf53c55c1 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -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 diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index afe2bedf5954..6a22620520c8 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -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 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 795e66c61adc..a677d1ab17f5 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -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