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
|
import logging
|
||||||
from hashlib import blake2b
|
from hashlib import blake2b
|
||||||
|
|
||||||
from django.utils.text import slugify
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.utils.text import slugify
|
||||||
from opaque_keys.edx.keys import LearningContextKey, UsageKey
|
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 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 rest_framework.exceptions import NotFound
|
||||||
|
|
||||||
from openedx.core.djangoapps.content.search.models import SearchAccess
|
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.content_tagging import api as tagging_api
|
||||||
from openedx.core.djangoapps.xblock import api as xblock_api
|
from openedx.core.djangoapps.xblock import api as xblock_api
|
||||||
from openedx.core.djangoapps.xblock.data import LatestVersion
|
from openedx.core.djangoapps.xblock.data import LatestVersion
|
||||||
from openedx_learning.api.authoring_models import Collection
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -554,7 +554,7 @@ def searchable_doc_for_container(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Generate a dictionary document suitable for ingestion into a search engine
|
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.
|
found using faceted search.
|
||||||
|
|
||||||
If no container is found for the given container key, the returned document
|
If no container is found for the given container key, the returned document
|
||||||
@@ -576,29 +576,33 @@ def searchable_doc_for_container(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
container = lib_api.get_container(container_key)
|
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
|
# Container not found, so we can only return the base doc
|
||||||
pass
|
return doc
|
||||||
|
|
||||||
if container:
|
draft_num_children = lib_api.get_container_children_count(container_key, published=False)
|
||||||
# TODO: check if there's a more efficient way to load these num_children counts?
|
publish_status = PublishStatus.published
|
||||||
draft_num_children = len(lib_api.get_container_children(container_key, published=False))
|
if container.last_published is None:
|
||||||
|
publish_status = PublishStatus.never
|
||||||
|
elif container.has_unpublished_changes:
|
||||||
|
publish_status = PublishStatus.modified
|
||||||
|
|
||||||
doc.update({
|
doc.update({
|
||||||
Fields.display_name: container.display_name,
|
Fields.display_name: container.display_name,
|
||||||
Fields.created: container.created.timestamp(),
|
Fields.created: container.created.timestamp(),
|
||||||
Fields.modified: container.modified.timestamp(),
|
Fields.modified: container.modified.timestamp(),
|
||||||
Fields.num_children: draft_num_children,
|
Fields.num_children: draft_num_children,
|
||||||
})
|
Fields.publish_status: publish_status,
|
||||||
library = lib_api.get_library(container_key.library_key)
|
})
|
||||||
if library:
|
library = lib_api.get_library(container_key.library_key)
|
||||||
doc[Fields.breadcrumbs] = [{"display_name": library.title}]
|
if library:
|
||||||
|
doc[Fields.breadcrumbs] = [{"display_name": library.title}]
|
||||||
|
|
||||||
if container.published_version_num is not None:
|
if container.published_version_num is not None:
|
||||||
published_num_children = len(lib_api.get_container_children(container_key, published=True))
|
published_num_children = lib_api.get_container_children_count(container_key, published=True)
|
||||||
doc[Fields.published] = {
|
doc[Fields.published] = {
|
||||||
# Fields.published_display_name: container_published.title, TODO: set the published title
|
# Fields.published_display_name: container_published.title, TODO: set the published title
|
||||||
Fields.published_num_children: published_num_children,
|
Fields.published_num_children: published_num_children,
|
||||||
}
|
}
|
||||||
|
|
||||||
return doc
|
return doc
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ class TestSearchApi(ModuleStoreTestCase):
|
|||||||
"display_name": "Unit 1",
|
"display_name": "Unit 1",
|
||||||
# description is not set for containers
|
# description is not set for containers
|
||||||
"num_children": 0,
|
"num_children": 0,
|
||||||
|
"publish_status": "never",
|
||||||
"context_key": "lib:org1:lib",
|
"context_key": "lib:org1:lib",
|
||||||
"org": "org1",
|
"org": "org1",
|
||||||
"created": created_date.timestamp(),
|
"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 dataclasses import replace
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from organizations.models import Organization
|
|
||||||
|
|
||||||
from freezegun import freeze_time
|
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_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 openedx.core.djangolib.testing.utils import skip_unless_cms
|
||||||
from xmodule.modulestore.django import modulestore
|
from xmodule.modulestore.django import modulestore
|
||||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||||
@@ -17,13 +18,13 @@ from xmodule.modulestore.tests.factories import BlockFactory, ToyCourseFactory
|
|||||||
try:
|
try:
|
||||||
# This import errors in the lms because content.search is not an installed app there.
|
# This import errors in the lms because content.search is not an installed app there.
|
||||||
from ..documents import (
|
from ..documents import (
|
||||||
searchable_doc_for_course_block,
|
|
||||||
searchable_doc_tags,
|
|
||||||
searchable_doc_tags_for_collection,
|
|
||||||
searchable_doc_collections,
|
searchable_doc_collections,
|
||||||
searchable_doc_for_collection,
|
searchable_doc_for_collection,
|
||||||
searchable_doc_for_container,
|
searchable_doc_for_container,
|
||||||
|
searchable_doc_for_course_block,
|
||||||
searchable_doc_for_library_block,
|
searchable_doc_for_library_block,
|
||||||
|
searchable_doc_tags,
|
||||||
|
searchable_doc_tags_for_collection,
|
||||||
)
|
)
|
||||||
from ..models import SearchAccess
|
from ..models import SearchAccess
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
@@ -522,6 +523,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
|
|||||||
"display_name": "A Unit in the Search Index",
|
"display_name": "A Unit in the Search Index",
|
||||||
# description is not set for containers
|
# description is not set for containers
|
||||||
"num_children": 0,
|
"num_children": 0,
|
||||||
|
"publish_status": "never",
|
||||||
"context_key": "lib:edX:2012_Fall",
|
"context_key": "lib:edX:2012_Fall",
|
||||||
"access_id": self.library_access_id,
|
"access_id": self.library_access_id,
|
||||||
"breadcrumbs": [{"display_name": "some content_library"}],
|
"breadcrumbs": [{"display_name": "some content_library"}],
|
||||||
@@ -531,6 +533,106 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
|
|||||||
# "published" is not set since we haven't published it 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
|
||||||
|
}
|
||||||
|
|
||||||
def test_mathjax_plain_text_conversion_for_search(self):
|
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.
|
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
|
API for containers (Sections, Subsections, Units) in Content Libraries
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -9,10 +10,10 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from opaque_keys.edx.locator import (
|
from opaque_keys.edx.locator import (
|
||||||
LibraryLocatorV2,
|
|
||||||
LibraryContainerLocator,
|
LibraryContainerLocator,
|
||||||
|
LibraryLocatorV2,
|
||||||
|
UsageKeyV2,
|
||||||
)
|
)
|
||||||
|
|
||||||
from openedx_events.content_authoring.data import LibraryContainerData
|
from openedx_events.content_authoring.data import LibraryContainerData
|
||||||
from openedx_events.content_authoring.signals import (
|
from openedx_events.content_authoring.signals import (
|
||||||
LIBRARY_CONTAINER_CREATED,
|
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 import authoring as authoring_api
|
||||||
from openedx_learning.api.authoring_models import Container
|
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 ..models import ContentLibrary
|
||||||
from .libraries import PublishableItem
|
from .libraries import LibraryXBlockMetadata, PublishableItem
|
||||||
|
|
||||||
|
|
||||||
# The public API is only the following symbols:
|
# The public API is only the following symbols:
|
||||||
@@ -34,9 +37,11 @@ __all__ = [
|
|||||||
"get_container",
|
"get_container",
|
||||||
"create_container",
|
"create_container",
|
||||||
"get_container_children",
|
"get_container_children",
|
||||||
|
"get_container_children_count",
|
||||||
"library_container_locator",
|
"library_container_locator",
|
||||||
"update_container",
|
"update_container",
|
||||||
"delete_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)
|
Get the entities contained in the given container (e.g. the components/xblocks in a unit)
|
||||||
"""
|
"""
|
||||||
assert isinstance(container_key, LibraryContainerLocator)
|
container = _get_container(container_key)
|
||||||
content_library = ContentLibrary.objects.get_by_key(container_key.library_key)
|
if container_key.container_type == ContainerType.Unit.value:
|
||||||
learning_package = content_library.learning_package
|
child_components = authoring_api.get_components_in_unit(container.unit, published=published)
|
||||||
assert learning_package is not None
|
return [LibraryXBlockMetadata.from_component(
|
||||||
container = authoring_api.get_container_by_key(
|
container_key.library_key,
|
||||||
learning_package.id,
|
entry.component
|
||||||
key=container_key.container_id,
|
) 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 ContainerMetadata.from_container(library_key, new_version.container)
|
||||||
return child_entities
|
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ class PublishableItem(LibraryItem):
|
|||||||
last_draft_created_by: str = ""
|
last_draft_created_by: str = ""
|
||||||
has_unpublished_changes: bool = False
|
has_unpublished_changes: bool = False
|
||||||
collections: list[CollectionMetadata] = field(default_factory=list)
|
collections: list[CollectionMetadata] = field(default_factory=list)
|
||||||
|
can_stand_alone: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
@@ -343,6 +344,7 @@ class LibraryXBlockMetadata(PublishableItem):
|
|||||||
last_draft_created_by=last_draft_created_by,
|
last_draft_created_by=last_draft_created_by,
|
||||||
has_unpublished_changes=component.versioning.has_unpublished_changes,
|
has_unpublished_changes=component.versioning.has_unpublished_changes,
|
||||||
collections=associated_collections or [],
|
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
|
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").
|
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
|
# It's in the serializer as ``definition_id``, but for our purposes, it's
|
||||||
# the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for
|
# 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)
|
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:
|
# Now return the metadata about the new block:
|
||||||
LIBRARY_BLOCK_CREATED.send_event(
|
LIBRARY_BLOCK_CREATED.send_event(
|
||||||
@@ -1135,6 +1145,7 @@ def _create_component_for_block(
|
|||||||
content_lib: ContentLibrary,
|
content_lib: ContentLibrary,
|
||||||
usage_key: LibraryUsageLocatorV2,
|
usage_key: LibraryUsageLocatorV2,
|
||||||
user_id: int | None = None,
|
user_id: int | None = None,
|
||||||
|
can_stand_alone: bool = True,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a Component for an XBlock type, initialize it, and return the ComponentVersion.
|
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
|
will be set as the current draft. This function does not publish the
|
||||||
Component.
|
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
|
TODO: We should probably shift this to openedx.core.djangoapps.xblock.api
|
||||||
(along with its caller) since it gives runtime storage specifics. The
|
(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"
|
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,
|
title=display_name,
|
||||||
created=now,
|
created=now,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
|
can_stand_alone=can_stand_alone,
|
||||||
)
|
)
|
||||||
content = authoring_api.get_or_create_text_content(
|
content = authoring_api.get_or_create_text_content(
|
||||||
learning_package.id,
|
learning_package.id,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ from ..models import ContentLibrary
|
|||||||
from .utils import convert_exceptions
|
from .utils import convert_exceptions
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ContentLibraryCollectionSerializer,
|
ContentLibraryCollectionSerializer,
|
||||||
ContentLibraryCollectionComponentsUpdateSerializer,
|
|
||||||
ContentLibraryCollectionUpdateSerializer,
|
ContentLibraryCollectionUpdateSerializer,
|
||||||
|
ContentLibraryComponentKeysSerializer,
|
||||||
)
|
)
|
||||||
from openedx.core.types.http import RestRequest
|
from openedx.core.types.http import RestRequest
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ class LibraryCollectionsView(ModelViewSet):
|
|||||||
content_library = self.get_content_library()
|
content_library = self.get_content_library()
|
||||||
collection_key = kwargs["key"]
|
collection_key = kwargs["key"]
|
||||||
|
|
||||||
serializer = ContentLibraryCollectionComponentsUpdateSerializer(data=request.data)
|
serializer = ContentLibraryComponentKeysSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
usage_keys = serializer.validated_data["usage_keys"]
|
usage_keys = serializer.validated_data["usage_keys"]
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import logging
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.transaction import non_atomic_requests
|
from django.db.transaction import non_atomic_requests
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
|
||||||
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator
|
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.generics import GenericAPIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.status import HTTP_204_NO_CONTENT
|
from rest_framework.status import HTTP_204_NO_CONTENT
|
||||||
@@ -124,3 +124,152 @@ class LibraryContainerView(GenericAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return Response({}, status=HTTP_204_NO_CONTENT)
|
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)
|
tags_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
collections = CollectionMetadataSerializer(many=True, required=False)
|
collections = CollectionMetadataSerializer(many=True, required=False)
|
||||||
|
can_stand_alone = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class LibraryXBlockTypeSerializer(serializers.Serializer):
|
class LibraryXBlockTypeSerializer(serializers.Serializer):
|
||||||
@@ -193,6 +194,9 @@ class LibraryXBlockCreationSerializer(serializers.Serializer):
|
|||||||
# creating new block from scratch
|
# creating new block from scratch
|
||||||
staged_content = serializers.CharField(required=False)
|
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):
|
class LibraryPasteClipboardSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
@@ -345,7 +349,7 @@ class UsageKeyV2Serializer(serializers.BaseSerializer):
|
|||||||
raise ValidationError from err
|
raise ValidationError from err
|
||||||
|
|
||||||
|
|
||||||
class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer):
|
class ContentLibraryComponentKeysSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
Serializer for adding/removing Components to/from a Collection.
|
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_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_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 = 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_PREFIX = URL_PREFIX + 'lti/1.3/'
|
||||||
URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
|
URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
|
||||||
@@ -229,9 +230,21 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
|
|||||||
expect_response
|
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 """
|
""" 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:
|
if parent_block:
|
||||||
data["parent_block"] = parent_block
|
data["parent_block"] = parent_block
|
||||||
return self._api('post', URL_LIB_BLOCKS.format(lib_key=lib_key), data, expect_response)
|
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):
|
def _delete_container(self, container_key: str, expect_response=204):
|
||||||
""" Delete a container (unit etc.) """
|
""" Delete a container (unit etc.) """
|
||||||
return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response)
|
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
|
Tests for Learning-Core-based Content Libraries
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import ddt
|
import ddt
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from opaque_keys.edx.locator import LibraryLocatorV2
|
from opaque_keys.edx.locator import LibraryLocatorV2
|
||||||
from openedx_events.content_authoring.data import LibraryContainerData
|
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 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 container2_data["container_key"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-")
|
||||||
assert container1_data["container_key"] != container2_data["container_key"]
|
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([
|
path('containers/<lib_container_key:container_key>/', include([
|
||||||
# Get metadata about a specific container in this library, update or delete the container:
|
# Get metadata about a specific container in this library, update or delete the container:
|
||||||
path('', containers.LibraryContainerView.as_view()),
|
path('', containers.LibraryContainerView.as_view()),
|
||||||
|
# update components under container
|
||||||
|
path('children/', containers.LibraryContainerChildrenView.as_view()),
|
||||||
# Update collections for a given container
|
# Update collections for a given container
|
||||||
# path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
|
# path('collections/', views.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'),
|
||||||
# path('publish/', views.LibraryContainerPublishView.as_view()),
|
# path('publish/', views.LibraryContainerPublishView.as_view()),
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ numpy<2.0.0
|
|||||||
# Date: 2023-09-18
|
# Date: 2023-09-18
|
||||||
# pinning this version to avoid updates while the library is being developed
|
# pinning this version to avoid updates while the library is being developed
|
||||||
# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269
|
# 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
|
# Date: 2023-11-29
|
||||||
# Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.
|
# 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
|
# ora2
|
||||||
openedx-forum==0.1.9
|
openedx-forum==0.1.9
|
||||||
# via -r requirements/edx/kernel.in
|
# via -r requirements/edx/kernel.in
|
||||||
openedx-learning==0.19.1
|
openedx-learning==0.19.2
|
||||||
# via
|
# via
|
||||||
# -c requirements/edx/../constraints.txt
|
# -c requirements/edx/../constraints.txt
|
||||||
# -r requirements/edx/kernel.in
|
# -r requirements/edx/kernel.in
|
||||||
|
|||||||
@@ -1383,7 +1383,7 @@ openedx-forum==0.1.9
|
|||||||
# via
|
# via
|
||||||
# -r requirements/edx/doc.txt
|
# -r requirements/edx/doc.txt
|
||||||
# -r requirements/edx/testing.txt
|
# -r requirements/edx/testing.txt
|
||||||
openedx-learning==0.19.1
|
openedx-learning==0.19.2
|
||||||
# via
|
# via
|
||||||
# -c requirements/edx/../constraints.txt
|
# -c requirements/edx/../constraints.txt
|
||||||
# -r requirements/edx/doc.txt
|
# -r requirements/edx/doc.txt
|
||||||
|
|||||||
@@ -992,7 +992,7 @@ openedx-filters==2.0.1
|
|||||||
# ora2
|
# ora2
|
||||||
openedx-forum==0.1.9
|
openedx-forum==0.1.9
|
||||||
# via -r requirements/edx/base.txt
|
# via -r requirements/edx/base.txt
|
||||||
openedx-learning==0.19.1
|
openedx-learning==0.19.2
|
||||||
# via
|
# via
|
||||||
# -c requirements/edx/../constraints.txt
|
# -c requirements/edx/../constraints.txt
|
||||||
# -r requirements/edx/base.txt
|
# -r requirements/edx/base.txt
|
||||||
|
|||||||
@@ -1050,7 +1050,7 @@ openedx-filters==2.0.1
|
|||||||
# ora2
|
# ora2
|
||||||
openedx-forum==0.1.9
|
openedx-forum==0.1.9
|
||||||
# via -r requirements/edx/base.txt
|
# via -r requirements/edx/base.txt
|
||||||
openedx-learning==0.19.1
|
openedx-learning==0.19.2
|
||||||
# via
|
# via
|
||||||
# -c requirements/edx/../constraints.txt
|
# -c requirements/edx/../constraints.txt
|
||||||
# -r requirements/edx/base.txt
|
# -r requirements/edx/base.txt
|
||||||
|
|||||||
Reference in New Issue
Block a user