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:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user