feat: Basic CRUD support for sections/subsections as containers [FC-0090] (#36762)

- Refactor of `test_containers.py` 8b9731dfa1
- Updates the `content_libraries/api/containers` and `content_libraries/rest_api` to support:
    - Basic CRUD for sections and subsections as containers
    - Basic CRUD support for section and subsection children
- Updates the `content/search` to support:
    - Basic CRUD for sections and subsections as containers in the search index
    - Basic CRUD support for section and subsection children in the search index
- Updates the `content_libraries/tests/test_containers.py` to test the new support
- Updates the `content/search/tests` to test the new support.
- I verified that the add/remove to collection works without major changes. Tests updated
This commit is contained in:
Chris Chávez
2025-05-28 16:05:45 -05:00
committed by GitHub
parent c600e52767
commit c2eb913dcb
14 changed files with 936 additions and 344 deletions

View File

@@ -95,6 +95,9 @@ class Fields:
published_content = "content"
published_num_children = "num_children"
# List of children keys
child_usage_keys = "child_usage_keys"
# Note: new fields or values can be added at any time, but if they need to be indexed for filtering or keyword
# search, the index configuration will need to be changed, which is only done as part of the 'reindex_studio'
# command (changing those settings on an large active index is not recommended).
@@ -656,16 +659,28 @@ def searchable_doc_for_container(
elif container.has_unpublished_changes:
publish_status = PublishStatus.modified
container_type = lib_api.ContainerType(container_key.container_type)
def get_child_keys(children) -> list[str]:
match container_type:
case lib_api.ContainerType.Unit:
return [
str(child.usage_key)
for child in children
]
case lib_api.ContainerType.Subsection | lib_api.ContainerType.Section:
return [
str(child.container_key)
for child in children
]
doc.update({
Fields.display_name: container.display_name,
Fields.created: container.created.timestamp(),
Fields.modified: container.modified.timestamp(),
Fields.num_children: len(draft_children),
Fields.content: {
"child_usage_keys": [
str(child.usage_key)
for child in draft_children
],
Fields.child_usage_keys: get_child_keys(draft_children)
},
Fields.publish_status: publish_status,
Fields.last_published: container.last_published.timestamp() if container.last_published else None,
@@ -683,10 +698,7 @@ def searchable_doc_for_container(
Fields.published_display_name: container.published_display_name,
Fields.published_num_children: len(published_children),
Fields.published_content: {
"child_usage_keys": [
str(child.usage_key)
for child in published_children
],
Fields.child_usage_keys: get_child_keys(published_children),
},
}

View File

@@ -47,7 +47,8 @@ class TestSearchApi(ModuleStoreTestCase):
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self):
def setUp(self) -> None:
# pylint: disable=too-many-statements
super().setUp()
self.user = UserFactory.create()
self.user_id = self.user.id
@@ -219,7 +220,7 @@ class TestSearchApi(ModuleStoreTestCase):
"breadcrumbs": [{"display_name": "Library"}],
}
# Create a unit:
# Create a container:
with freeze_time(self.created_date):
self.unit = library_api.create_container(
library_key=self.library.key,
@@ -229,6 +230,23 @@ class TestSearchApi(ModuleStoreTestCase):
user_id=None,
)
self.unit_key = "lct:org1:lib:unit:unit-1"
self.subsection = library_api.create_container(
self.library.key,
container_type=library_api.ContainerType.Subsection,
slug="subsection-1",
title="Subsection 1",
user_id=None,
)
self.subsection_key = "lct:org1:lib:subsection:subsection-1"
self.section = library_api.create_container(
self.library.key,
container_type=library_api.ContainerType.Section,
slug="section-1",
title="Section 1",
user_id=None,
)
self.section_key = "lct:org1:lib:section:section-1"
self.unit_dict = {
"id": "lctorg1libunitunit-1-e4527f7c",
"block_id": "unit-1",
@@ -249,16 +267,56 @@ class TestSearchApi(ModuleStoreTestCase):
"breadcrumbs": [{"display_name": "Library"}],
# "published" is not set since we haven't published it yet
}
self.subsection_dict = {
"id": "lctorg1libsubsectionsubsection-1-cf808309",
"block_id": "subsection-1",
"block_type": "subsection",
"usage_key": self.subsection_key,
"type": "library_container",
"display_name": "Subsection 1",
# description is not set for containers
"num_children": 0,
"content": {"child_usage_keys": []},
"publish_status": "never",
"context_key": "lib:org1:lib",
"org": "org1",
"created": self.created_date.timestamp(),
"modified": self.created_date.timestamp(),
"last_published": None,
"access_id": lib_access.id,
"breadcrumbs": [{"display_name": "Library"}],
# "published" is not set since we haven't published it yet
}
self.section_dict = {
"id": "lctorg1libsectionsection-1-dc4791a4",
"block_id": "section-1",
"block_type": "section",
"usage_key": self.section_key,
"type": "library_container",
"display_name": "Section 1",
# description is not set for containers
"num_children": 0,
"content": {"child_usage_keys": []},
"publish_status": "never",
"context_key": "lib:org1:lib",
"org": "org1",
"created": self.created_date.timestamp(),
"modified": self.created_date.timestamp(),
"last_published": None,
"access_id": lib_access.id,
"breadcrumbs": [{"display_name": "Library"}],
# "published" is not set since we haven't published it yet
}
@override_settings(MEILISEARCH_ENABLED=False)
def test_reindex_meilisearch_disabled(self, mock_meilisearch):
def test_reindex_meilisearch_disabled(self, mock_meilisearch) -> None:
with self.assertRaises(RuntimeError):
api.rebuild_index()
mock_meilisearch.return_value.swap_indexes.assert_not_called()
@override_settings(MEILISEARCH_ENABLED=True)
def test_reindex_meilisearch(self, mock_meilisearch):
def test_reindex_meilisearch(self, mock_meilisearch) -> None:
# Add tags field to doc, since reindex calls includes tags
doc_sequential = copy.deepcopy(self.doc_sequential)
@@ -278,6 +336,12 @@ class TestSearchApi(ModuleStoreTestCase):
doc_unit = copy.deepcopy(self.unit_dict)
doc_unit["tags"] = {}
doc_unit["collections"] = {'display_name': [], 'key': []}
doc_subsection = copy.deepcopy(self.subsection_dict)
doc_subsection["tags"] = {}
doc_subsection["collections"] = {'display_name': [], 'key': []}
doc_section = copy.deepcopy(self.section_dict)
doc_section["tags"] = {}
doc_section["collections"] = {'display_name': [], 'key': []}
api.rebuild_index()
assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4
@@ -286,13 +350,13 @@ class TestSearchApi(ModuleStoreTestCase):
call([doc_sequential, doc_vertical]),
call([doc_problem1, doc_problem2]),
call([doc_collection]),
call([doc_unit]),
call([doc_unit, doc_subsection, doc_section]),
],
any_order=True,
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_reindex_meilisearch_incremental(self, mock_meilisearch):
def test_reindex_meilisearch_incremental(self, mock_meilisearch) -> None:
# Add tags field to doc, since reindex calls includes tags
doc_sequential = copy.deepcopy(self.doc_sequential)
@@ -312,6 +376,12 @@ class TestSearchApi(ModuleStoreTestCase):
doc_unit = copy.deepcopy(self.unit_dict)
doc_unit["tags"] = {}
doc_unit["collections"] = {"display_name": [], "key": []}
doc_subsection = copy.deepcopy(self.subsection_dict)
doc_subsection["tags"] = {}
doc_subsection["collections"] = {'display_name': [], 'key': []}
doc_section = copy.deepcopy(self.section_dict)
doc_section["tags"] = {}
doc_section["collections"] = {'display_name': [], 'key': []}
api.rebuild_index(incremental=True)
assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4
@@ -320,7 +390,7 @@ class TestSearchApi(ModuleStoreTestCase):
call([doc_sequential, doc_vertical]),
call([doc_problem1, doc_problem2]),
call([doc_collection]),
call([doc_unit]),
call([doc_unit, doc_subsection, doc_section]),
],
any_order=True,
)
@@ -343,7 +413,7 @@ class TestSearchApi(ModuleStoreTestCase):
assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 8
@override_settings(MEILISEARCH_ENABLED=True)
def test_reset_meilisearch_index(self, mock_meilisearch):
def test_reset_meilisearch_index(self, mock_meilisearch) -> None:
api.reset_index()
mock_meilisearch.return_value.swap_indexes.assert_called_once()
mock_meilisearch.return_value.create_index.assert_called_once()
@@ -352,7 +422,7 @@ class TestSearchApi(ModuleStoreTestCase):
mock_meilisearch.return_value.delete_index.call_count = 4
@override_settings(MEILISEARCH_ENABLED=True)
def test_init_meilisearch_index(self, mock_meilisearch):
def test_init_meilisearch_index(self, mock_meilisearch) -> None:
# Test index already exists
api.init_index()
mock_meilisearch.return_value.swap_indexes.assert_not_called()
@@ -383,7 +453,7 @@ class TestSearchApi(ModuleStoreTestCase):
"openedx.core.djangoapps.content.search.api.searchable_doc_for_collection",
Mock(side_effect=Exception("Failed to generate document")),
)
def test_reindex_meilisearch_collection_error(self, mock_meilisearch):
def test_reindex_meilisearch_collection_error(self, mock_meilisearch) -> None:
mock_logger = Mock()
api.rebuild_index(mock_logger)
@@ -399,7 +469,7 @@ class TestSearchApi(ModuleStoreTestCase):
"openedx.core.djangoapps.content.search.api.searchable_doc_for_container",
Mock(side_effect=Exception("Failed to generate document")),
)
def test_reindex_meilisearch_container_error(self, mock_meilisearch):
def test_reindex_meilisearch_container_error(self, mock_meilisearch) -> None:
mock_logger = Mock()
api.rebuild_index(mock_logger)
@@ -411,7 +481,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_reindex_meilisearch_library_block_error(self, mock_meilisearch):
def test_reindex_meilisearch_library_block_error(self, mock_meilisearch) -> None:
# Add tags field to doc, since reindex calls includes tags
doc_sequential = copy.deepcopy(self.doc_sequential)
@@ -469,7 +539,7 @@ class TestSearchApi(ModuleStoreTestCase):
False
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_xblock_metadata(self, recursive, mock_meilisearch):
def test_index_xblock_metadata(self, recursive, mock_meilisearch) -> None:
"""
Test indexing an XBlock.
"""
@@ -483,13 +553,13 @@ class TestSearchApi(ModuleStoreTestCase):
mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with(expected_docs)
@override_settings(MEILISEARCH_ENABLED=True)
def test_no_index_excluded_xblocks(self, mock_meilisearch):
def test_no_index_excluded_xblocks(self, mock_meilisearch) -> None:
api.upsert_xblock_index_doc(UsageKey.from_string(self.course_block_key))
mock_meilisearch.return_value.index.return_value.update_document.assert_not_called()
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_xblock_tags(self, mock_meilisearch):
def test_index_xblock_tags(self, mock_meilisearch) -> None:
"""
Test indexing an XBlock with tags.
"""
@@ -523,7 +593,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_delete_index_xblock(self, mock_meilisearch):
def test_delete_index_xblock(self, mock_meilisearch) -> None:
"""
Test deleting an XBlock doc from the index.
"""
@@ -534,7 +604,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_library_block_metadata(self, mock_meilisearch):
def test_index_library_block_metadata(self, mock_meilisearch) -> None:
"""
Test indexing a Library Block.
"""
@@ -543,7 +613,7 @@ class TestSearchApi(ModuleStoreTestCase):
mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.doc_problem1])
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_library_block_tags(self, mock_meilisearch):
def test_index_library_block_tags(self, mock_meilisearch) -> None:
"""
Test indexing an Library Block with tags.
"""
@@ -578,7 +648,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_library_block_and_collections(self, mock_meilisearch):
def test_index_library_block_and_collections(self, mock_meilisearch) -> None:
"""
Test indexing an Library Block and the Collections it's in.
"""
@@ -718,7 +788,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_delete_index_library_block(self, mock_meilisearch):
def test_delete_index_library_block(self, mock_meilisearch) -> None:
"""
Test deleting a Library Block doc from the index.
"""
@@ -729,7 +799,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_content_library_metadata(self, mock_meilisearch):
def test_index_content_library_metadata(self, mock_meilisearch) -> None:
"""
Test indexing a whole content library.
"""
@@ -740,7 +810,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_tags_in_collections(self, mock_meilisearch):
def test_index_tags_in_collections(self, mock_meilisearch) -> None:
# Tag collection
tagging_api.tag_object(str(self.collection_key), self.taxonomyA, ["one", "two"])
tagging_api.tag_object(str(self.collection_key), self.taxonomyB, ["three", "four"])
@@ -771,7 +841,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_delete_collection(self, mock_meilisearch):
def test_delete_collection(self, mock_meilisearch) -> None:
"""
Test soft-deleting, restoring, and hard-deleting a collection.
"""
@@ -894,19 +964,27 @@ class TestSearchApi(ModuleStoreTestCase):
any_order=True,
)
@ddt.data(
"unit",
"subsection",
"section",
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_delete_index_container(self, mock_meilisearch):
def test_delete_index_container(self, container_type, mock_meilisearch) -> None:
"""
Test delete a container index.
"""
library_api.delete_container(self.unit.container_key)
container = getattr(self, container_type)
container_dict = getattr(self, f"{container_type}_dict")
library_api.delete_container(container.container_key)
mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with(
self.unit_dict["id"],
container_dict["id"],
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_library_container_metadata(self, mock_meilisearch):
def test_index_library_container_metadata(self, mock_meilisearch) -> None:
"""
Test indexing a Library Container.
"""
@@ -914,22 +992,30 @@ class TestSearchApi(ModuleStoreTestCase):
mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.unit_dict])
@ddt.data(
("unit", "lctorg1libunitunit-1-e4527f7c"),
("subsection", "lctorg1libsubsectionsubsection-1-cf808309"),
("section", "lctorg1libsectionsection-1-dc4791a4"),
)
@ddt.unpack
@override_settings(MEILISEARCH_ENABLED=True)
def test_index_tags_in_containers(self, mock_meilisearch):
# Tag collection
tagging_api.tag_object(self.unit_key, self.taxonomyA, ["one", "two"])
tagging_api.tag_object(self.unit_key, self.taxonomyB, ["three", "four"])
def test_index_tags_in_containers(self, container_type, container_id, mock_meilisearch) -> None:
container_key = getattr(self, f"{container_type}_key")
# Tag container
tagging_api.tag_object(container_key, self.taxonomyA, ["one", "two"])
tagging_api.tag_object(container_key, self.taxonomyB, ["three", "four"])
# Build expected docs with tags at each stage
doc_unit_with_tags1 = {
"id": "lctorg1libunitunit-1-e4527f7c",
"id": container_id,
"tags": {
'taxonomy': ['A'],
'level0': ['A > one', 'A > two']
}
}
doc_unit_with_tags2 = {
"id": "lctorg1libunitunit-1-e4527f7c",
"id": container_id,
"tags": {
'taxonomy': ['A', 'B'],
'level0': ['A > one', 'A > two', 'B > four', 'B > three']
@@ -946,7 +1032,7 @@ class TestSearchApi(ModuleStoreTestCase):
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_block_in_units(self, mock_meilisearch):
def test_block_in_units(self, mock_meilisearch) -> None:
with freeze_time(self.created_date):
library_api.update_container_children(
LibraryContainerLocator.from_string(self.unit_key),
@@ -975,3 +1061,51 @@ class TestSearchApi(ModuleStoreTestCase):
],
any_order=True,
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_units_in_subsection(self, mock_meilisearch) -> None:
with freeze_time(self.created_date):
library_api.update_container_children(
LibraryContainerLocator.from_string(self.subsection_key),
[LibraryContainerLocator.from_string(self.unit_key)],
None,
)
# TODO verify subsections in units
new_subsection_dict = {
**self.subsection_dict,
"num_children": 1,
'content': {'child_usage_keys': [self.unit_key]}
}
assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 1
mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls(
[
call([new_subsection_dict]),
],
any_order=True,
)
@override_settings(MEILISEARCH_ENABLED=True)
def test_section_in_usbsections(self, mock_meilisearch) -> None:
with freeze_time(self.created_date):
library_api.update_container_children(
LibraryContainerLocator.from_string(self.section_key),
[LibraryContainerLocator.from_string(self.subsection_key)],
None,
)
# TODO verify section in subsections
new_section_dict = {
**self.section_dict,
"num_children": 1,
'content': {'child_usage_keys': [self.subsection_key]}
}
assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 1
mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls(
[
call([new_section_dict]),
],
any_order=True,
)

View File

@@ -1,6 +1,7 @@
"""
Tests for the Studio content search documents (what gets stored in the index)
"""
import ddt
from dataclasses import replace
from datetime import datetime, timezone
@@ -42,6 +43,7 @@ STUDIO_SEARCH_ENDPOINT_URL = "/api/content_search/v2/studio/"
@skip_unless_cms
@ddt.ddt
class StudioDocumentsTest(SharedModuleStoreTestCase):
"""
Tests for the Studio content search documents (what gets stored in the
@@ -100,6 +102,26 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
cls.container_key = LibraryContainerLocator.from_string(
"lct:edX:2012_Fall:unit:unit1",
)
cls.subsection = library_api.create_container(
cls.library.key,
container_type=library_api.ContainerType.Subsection,
slug="subsection1",
title="A Subsection in the Search Index",
user_id=None,
)
cls.subsection_key = LibraryContainerLocator.from_string(
"lct:edX:2012_Fall:subsection:subsection1",
)
cls.section = library_api.create_container(
cls.library.key,
container_type=library_api.ContainerType.Section,
slug="section1",
title="A Section in the Search Index",
user_id=None,
)
cls.section_key = LibraryContainerLocator.from_string(
"lct:edX:2012_Fall:section:section1",
)
# Add the problem block to the collection
library_api.update_library_collection_items(
@@ -130,6 +152,8 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
tagging_api.tag_object(str(cls.library_block.usage_key), cls.difficulty_tags, tags=["Normal"])
tagging_api.tag_object(str(cls.collection_key), cls.difficulty_tags, tags=["Normal"])
tagging_api.tag_object(str(cls.container_key), cls.difficulty_tags, tags=["Normal"])
tagging_api.tag_object(str(cls.subsection_key), cls.difficulty_tags, tags=["Normal"])
tagging_api.tag_object(str(cls.section_key), cls.difficulty_tags, tags=["Normal"])
@property
def toy_course_access_id(self):
@@ -514,21 +538,28 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
}
}
def test_draft_container(self):
@ddt.data(
("container", "unit1", "unit", "edd13a0c"),
("subsection", "subsection1", "subsection", "c6c172be"),
("section", "section1", "section", "79ee8fa2"),
)
@ddt.unpack
def test_draft_container(self, container_name, container_slug, container_type, doc_id):
"""
Test creating a search document for a draft-only container
"""
doc = searchable_doc_for_container(self.container.container_key)
doc.update(searchable_doc_tags(self.container.container_key))
container = getattr(self, container_name)
doc = searchable_doc_for_container(container.container_key)
doc.update(searchable_doc_tags(container.container_key))
assert doc == {
"id": "lctedx2012_fallunitunit1-edd13a0c",
"block_id": "unit1",
"block_type": "unit",
"usage_key": "lct:edX:2012_Fall:unit:unit1",
"id": f"lctedx2012_fall{container_type}{container_slug}-{doc_id}",
"block_id": container_slug,
"block_type": container_type,
"usage_key": f"lct:edX:2012_Fall:{container_type}:{container_slug}",
"type": "library_container",
"org": "edX",
"display_name": "A Unit in the Search Index",
"display_name": container.display_name,
# description is not set for containers
"num_children": 0,
"content": {

View File

@@ -493,7 +493,7 @@ def _import_staged_block_as_container(
title=title,
user_id=user.id,
)
new_child_keys: list[UsageKeyV2] = []
new_child_keys: list[LibraryUsageLocatorV2] = []
for child_node in olx_node:
try:
child_metadata = _import_staged_block(

View File

@@ -10,7 +10,6 @@ import logging
from uuid import uuid4
from django.utils.text import slugify
from opaque_keys.edx.keys import UsageKeyV2
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
from openedx_events.content_authoring.data import (
ContentObjectChangedData,
@@ -25,7 +24,7 @@ from openedx_events.content_authoring.signals import (
LIBRARY_CONTAINER_UPDATED,
)
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, ContainerVersion
from openedx.core.djangoapps.content_libraries.api.collections import library_collection_locator
from openedx.core.djangoapps.xblock.api import get_component_from_usage_key
@@ -159,11 +158,15 @@ def library_container_locator(
) -> LibraryContainerLocator:
"""
Returns a LibraryContainerLocator for the given library + container.
Currently only supports Unit-type containers; will support other container types in future.
"""
assert container.unit is not None
container_type = ContainerType.Unit
if hasattr(container, 'unit'):
container_type = ContainerType.Unit
elif hasattr(container, 'subsection'):
container_type = ContainerType.Subsection
elif hasattr(container, 'section'):
container_type = ContainerType.Section
assert container_type is not None
return LibraryContainerLocator(
library_key,
@@ -225,7 +228,7 @@ def create_container(
created: datetime | None = None,
) -> ContainerMetadata:
"""
Create a container (e.g. a Unit) in the specified content library.
Create a container (a Section, Subsection, or Unit) in the specified content library.
It will initially be empty.
"""
@@ -241,6 +244,13 @@ def create_container(
container_type=container_type.value,
container_id=slug,
)
if not created:
created = datetime.now(tz=timezone.utc)
container: Container
_initial_version: ContainerVersion
# Then try creating the actual container:
match container_type:
case ContainerType.Unit:
@@ -248,11 +258,27 @@ def create_container(
content_library.learning_package_id,
key=slug,
title=title,
created=created or datetime.now(tz=timezone.utc),
created=created,
created_by=user_id,
)
case ContainerType.Subsection:
container, _initial_version = authoring_api.create_subsection_and_version(
content_library.learning_package_id,
key=slug,
title=title,
created=created,
created_by=user_id,
)
case ContainerType.Section:
container, _initial_version = authoring_api.create_section_and_version(
content_library.learning_package_id,
key=slug,
title=title,
created=created,
created_by=user_id,
)
case _:
raise NotImplementedError(f"Library support for {container_type} is in progress")
raise NotImplementedError(f"Library does not support {container_type} yet")
LIBRARY_CONTAINER_CREATED.send_event(
library_container=LibraryContainerData(
@@ -269,18 +295,40 @@ def update_container(
user_id: int | None,
) -> ContainerMetadata:
"""
Update a container (e.g. a Unit) title.
Update a container (a Section, Subsection, or Unit) title.
"""
container = _get_container_from_key(container_key)
library_key = container_key.lib_key
created = datetime.now(tz=timezone.utc)
assert container.unit
unit_version = authoring_api.create_next_unit_version(
container.unit,
title=display_name,
created=datetime.now(tz=timezone.utc),
created_by=user_id,
)
container_type = ContainerType(container_key.container_type)
version: ContainerVersion
match container_type:
case ContainerType.Unit:
version = authoring_api.create_next_unit_version(
container.unit,
title=display_name,
created=created,
created_by=user_id,
)
case ContainerType.Subsection:
version = authoring_api.create_next_subsection_version(
container.subsection,
title=display_name,
created=created,
created_by=user_id,
)
case ContainerType.Section:
version = authoring_api.create_next_section_version(
container.section,
title=display_name,
created=created,
created_by=user_id,
)
case _:
raise NotImplementedError(f"Library does not support {container_type} yet")
LIBRARY_CONTAINER_UPDATED.send_event(
library_container=LibraryContainerData(
@@ -288,14 +336,14 @@ def update_container(
)
)
return ContainerMetadata.from_container(library_key, unit_version.container)
return ContainerMetadata.from_container(library_key, version.container)
def delete_container(
container_key: LibraryContainerLocator,
) -> None:
"""
Delete a container (e.g. a Unit) (soft delete).
Delete a container (a Section, Subsection, or Unit) (soft delete).
No-op if container doesn't exist or has already been soft-deleted.
"""
@@ -379,21 +427,37 @@ def get_container_children(
published=False,
) -> list[LibraryXBlockMetadata | ContainerMetadata]:
"""
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, units in a subsection, subsections in a section)
"""
container = _get_container_from_key(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.lib_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.lib_key,
entry.entity
) for entry in child_entities]
container_type = ContainerType(container_key.container_type)
match container_type:
case ContainerType.Unit:
child_components = authoring_api.get_components_in_unit(container.unit, published=published)
return [LibraryXBlockMetadata.from_component(
container_key.lib_key,
entry.component
) for entry in child_components]
case ContainerType.Subsection:
child_units = authoring_api.get_units_in_subsection(container.subsection, published=published)
return [ContainerMetadata.from_container(
container_key.lib_key,
entry.unit
) for entry in child_units]
case ContainerType.Section:
child_subsections = authoring_api.get_subsections_in_section(container.section, published=published)
return [ContainerMetadata.from_container(
container_key.lib_key,
entry.subsection,
) for entry in child_subsections]
case _:
child_entities = authoring_api.get_entities_in_container(container, published=published)
return [ContainerMetadata.from_container(
container_key.lib_key,
entry.entity
) for entry in child_entities]
def get_container_children_count(
@@ -409,7 +473,7 @@ def get_container_children_count(
def update_container_children(
container_key: LibraryContainerLocator,
children_ids: list[UsageKeyV2] | list[LibraryContainerLocator],
children_ids: list[LibraryUsageLocatorV2] | list[LibraryContainerLocator],
user_id: int | None,
entities_action: authoring_api.ChildrenEntitiesAction = authoring_api.ChildrenEntitiesAction.REPLACE,
):
@@ -417,15 +481,17 @@ def update_container_children(
Adds children components or containers to given container.
"""
library_key = container_key.lib_key
container_type = container_key.container_type
container_type = ContainerType(container_key.container_type)
container = _get_container_from_key(container_key)
created = datetime.now(tz=timezone.utc)
new_version: ContainerVersion
match container_type:
case ContainerType.Unit.value:
case ContainerType.Unit:
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(tz=timezone.utc),
created=created,
created_by=user_id,
entities_action=entities_action,
)
@@ -437,6 +503,28 @@ def update_container_children(
changes=["units"],
),
)
case ContainerType.Subsection:
units = [_get_container_from_key(key).unit for key in children_ids] # type: ignore[arg-type]
new_version = authoring_api.create_next_subsection_version(
container.subsection,
units=units, # type: ignore[arg-type]
created=created,
created_by=user_id,
entities_action=entities_action,
)
# TODO add CONTENT_OBJECT_ASSOCIATIONS_CHANGED for subsections
case ContainerType.Section:
subsections = [_get_container_from_key(key).subsection for key in children_ids] # type: ignore[arg-type]
new_version = authoring_api.create_next_section_version(
container.section,
subsections=subsections, # type: ignore[arg-type]
created=created,
created_by=user_id,
entities_action=entities_action,
)
# TODO add CONTENT_OBJECT_ASSOCIATIONS_CHANGED for sections
case _:
raise ValueError(f"Invalid container type: {container_type}")

View File

@@ -64,7 +64,7 @@ class LibraryContainersView(GenericAPIView):
@view_auth_classes()
class LibraryContainerView(GenericAPIView):
"""
View to retrieve or update data about a specific container (a section, subsection, or unit)
View to retrieve, delete or update data about a specific container (a section, subsection, or unit)
"""
serializer_class = serializers.LibraryContainerMetadataSerializer
@@ -141,7 +141,7 @@ class LibraryContainerChildrenView(GenericAPIView):
)
def get(self, request, container_key: LibraryContainerLocator):
"""
Get children components of given container
Get children of given container
Example:
GET /api/libraries/v2/containers/<container_key>/children/
Result:
@@ -205,10 +205,8 @@ class LibraryContainerChildrenView(GenericAPIView):
request.user,
permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
)
serializer = serializers.ContentLibraryComponentKeysSerializer(data=request.data)
serializer = serializers.ContentLibraryItemContainerKeysSerializer(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,
@@ -220,12 +218,12 @@ class LibraryContainerChildrenView(GenericAPIView):
@convert_exceptions
@swagger_auto_schema(
request_body=serializers.ContentLibraryComponentKeysSerializer,
request_body=serializers.ContentLibraryItemContainerKeysSerializer,
responses={200: serializers.LibraryContainerMetadataSerializer}
)
def post(self, request, container_key: LibraryContainerLocator):
"""
Add components to unit
Add items to container
Example:
POST /api/libraries/v2/containers/<container_key>/children/
Request body:
@@ -239,12 +237,12 @@ class LibraryContainerChildrenView(GenericAPIView):
@convert_exceptions
@swagger_auto_schema(
request_body=serializers.ContentLibraryComponentKeysSerializer,
request_body=serializers.ContentLibraryItemContainerKeysSerializer,
responses={200: serializers.LibraryContainerMetadataSerializer}
)
def delete(self, request, container_key: LibraryContainerLocator):
"""
Remove components from unit
Remove items from container
Example:
DELETE /api/libraries/v2/containers/<container_key>/children/
Request body:
@@ -258,12 +256,12 @@ class LibraryContainerChildrenView(GenericAPIView):
@convert_exceptions
@swagger_auto_schema(
request_body=serializers.ContentLibraryComponentKeysSerializer,
request_body=serializers.ContentLibraryItemContainerKeysSerializer,
responses={200: serializers.LibraryContainerMetadataSerializer}
)
def patch(self, request, container_key: LibraryContainerLocator):
"""
Replace components in unit, can be used to reorder components as well.
Replace items in container, can be used to reorder items as well.
Example:
PATCH /api/libraries/v2/containers/<container_key>/children/
Request body:

View File

@@ -339,14 +339,6 @@ class UsageKeyV2Serializer(serializers.BaseSerializer):
raise ValidationError from err
class ContentLibraryComponentKeysSerializer(serializers.Serializer):
"""
Serializer for adding/removing Components to/from a Collection.
"""
usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False)
class OpaqueKeySerializer(serializers.BaseSerializer):
"""
Serializes a OpaqueKey with the correct class.
@@ -372,6 +364,14 @@ class OpaqueKeySerializer(serializers.BaseSerializer):
raise ValidationError from err
class ContentLibraryItemContainerKeysSerializer(serializers.Serializer):
"""
Serializer for adding/removing items to/from a Container.
"""
usage_keys = serializers.ListField(child=OpaqueKeySerializer(), allow_empty=False)
class ContentLibraryItemKeysSerializer(serializers.Serializer):
"""
Serializer for adding/removing items to/from a Collection.

View File

@@ -38,7 +38,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_CONTAINER_CHILDREN = URL_LIB_CONTAINER + 'children/' # Get, add or delete a component in this container
URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container
URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections
URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children
@@ -396,25 +396,25 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
""" Restore a deleted a container (unit etc.) """
return self._api('post', URL_LIB_CONTAINER_RESTORE.format(container_key=container_key), None, expect_response)
def _get_container_components(self, container_key: ContainerKey | str, expect_response=200):
""" Get container components"""
def _get_container_children(self, container_key: ContainerKey | str, expect_response=200):
""" Get container children"""
return self._api(
'get',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key),
None,
expect_response
)
def _add_container_components(
def _add_container_children(
self,
container_key: ContainerKey | str,
children_ids: list[str],
expect_response=200,
):
""" Add container components"""
""" Add container children"""
return self._api(
'post',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key),
{'usage_keys': children_ids},
expect_response
)
@@ -428,7 +428,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
""" Remove container components"""
return self._api(
'delete',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key),
{'usage_keys': children_ids},
expect_response
)
@@ -442,7 +442,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
""" Update container components"""
return self._api(
'patch',
URL_LIB_CONTAINER_COMPONENTS.format(container_key=container_key),
URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key),
{'usage_keys': children_ids},
expect_response
)

View File

@@ -11,8 +11,9 @@ from django.test import TestCase
from opaque_keys.edx.keys import (
CourseKey,
UsageKey,
UsageKeyV2,
)
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2
from openedx_events.content_authoring.data import (
ContentObjectChangedData,
LibraryCollectionData,
@@ -265,7 +266,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
Same guidelines as ContentLibrariesTestCase.
"""
def setUp(self):
def setUp(self) -> None:
super().setUp()
# Create Content Libraries
@@ -317,7 +318,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
self.lib2.library_key, "problem", "problem2",
)
def test_create_library_collection(self):
def test_create_library_collection(self) -> None:
event_receiver = mock.Mock()
LIBRARY_COLLECTION_CREATED.connect(event_receiver)
@@ -348,7 +349,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
event_receiver.call_args_list[0].kwargs,
)
def test_create_library_collection_invalid_library(self):
def test_create_library_collection_invalid_library(self) -> None:
library_key = LibraryLocatorV2.from_string("lib:INVALID:test-lib-does-not-exist")
with self.assertRaises(api.ContentLibraryNotFound) as exc:
api.create_library_collection(
@@ -357,7 +358,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
title="Collection 3",
)
def test_update_library_collection(self):
def test_update_library_collection(self) -> None:
event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(event_receiver)
@@ -386,17 +387,18 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
event_receiver.call_args_list[0].kwargs,
)
def test_update_library_collection_wrong_library(self):
def test_update_library_collection_wrong_library(self) -> None:
with self.assertRaises(api.ContentLibraryCollectionNotFound) as exc:
api.update_library_collection(
self.lib1.library_key,
self.col2.key,
)
def test_delete_library_collection(self):
def test_delete_library_collection(self) -> None:
event_receiver = mock.Mock()
LIBRARY_COLLECTION_DELETED.connect(event_receiver)
assert self.lib1.learning_package_id is not None
authoring_api.delete_collection(
self.lib1.learning_package_id,
self.col1.key,
@@ -418,15 +420,15 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
event_receiver.call_args_list[0].kwargs,
)
def test_update_library_collection_items(self):
def test_update_library_collection_items(self) -> None:
assert not list(self.col1.entities.all())
self.col1 = api.update_library_collection_items(
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
LibraryContainerLocator.from_string(self.unit1["id"]),
],
)
@@ -436,13 +438,13 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
],
remove=True,
)
assert len(self.col1.entities.all()) == 2
def test_update_library_collection_components_event(self):
def test_update_library_collection_components_event(self) -> None:
"""
Check that a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event is raised for each added/removed component.
"""
@@ -454,8 +456,8 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
LibraryContainerLocator.from_string(self.unit1["id"]),
],
)
@@ -508,32 +510,33 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
event_receiver.call_args_list[3].kwargs,
)
def test_update_collection_components_from_wrong_library(self):
def test_update_collection_components_from_wrong_library(self) -> None:
with self.assertRaises(api.ContentLibraryBlockNotFound) as exc:
api.update_library_collection_items(
self.lib2.library_key,
self.col2.key,
opaque_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
LibraryContainerLocator.from_string(self.unit1["id"]),
],
)
assert self.lib1_problem_block["id"] in str(exc.exception)
def test_set_library_component_collections(self):
def test_set_library_component_collections(self) -> None:
event_receiver = mock.Mock()
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver)
collection_update_event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver)
assert not list(self.col2.entities.all())
component = api.get_component_from_usage_key(UsageKey.from_string(self.lib2_problem_block["id"]))
component = api.get_component_from_usage_key(UsageKeyV2.from_string(self.lib2_problem_block["id"]))
api.set_library_item_collections(
library_key=self.lib2.library_key,
entity_key=component.publishable_entity.key,
collection_keys=[self.col2.key, self.col3.key],
)
assert self.lib2.learning_package_id is not None
assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col2.key).entities.all()) == 1
assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col3.key).entities.all()) == 1
assert {
@@ -559,20 +562,20 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
)
}
def test_delete_library_block(self):
def test_delete_library_block(self) -> None:
api.update_library_collection_items(
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
],
)
event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(event_receiver)
api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"]))
api.delete_library_block(LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]))
assert event_receiver.call_count == 1
self.assertDictContainsSubset(
@@ -590,13 +593,13 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
event_receiver.call_args_list[0].kwargs,
)
def test_delete_library_container(self):
def test_delete_library_container(self) -> None:
api.update_library_collection_items(
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
LibraryContainerLocator.from_string(self.unit1["id"]),
],
)
@@ -622,20 +625,20 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
event_receiver.call_args_list[0].kwargs,
)
def test_restore_library_block(self):
def test_restore_library_block(self) -> None:
api.update_library_collection_items(
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
],
)
event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(event_receiver)
api.restore_library_block(UsageKey.from_string(self.lib1_problem_block["id"]))
api.restore_library_block(LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]))
assert event_receiver.call_count == 1
self.assertDictContainsSubset(
@@ -653,7 +656,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
event_receiver.call_args_list[0].kwargs,
)
def test_add_component_and_revert(self):
def test_add_component_and_revert(self) -> None:
# Publish changes
api.publish_changes(self.lib1.library_key)
@@ -667,8 +670,8 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_html_block["id"]),
UsageKey.from_string(new_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]),
LibraryUsageLocatorV2.from_string(new_problem_block["id"]),
],
)
@@ -689,7 +692,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
),
}.items() <= collection_update_event_receiver.call_args_list[0].kwargs.items()
def test_delete_component_and_revert(self):
def test_delete_component_and_revert(self) -> None:
"""
When a component is deleted and then the delete is reverted, signals
will be emitted to update any containing collections.
@@ -699,14 +702,14 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
self.lib1.library_key,
self.col1.key,
opaque_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"])
LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"])
],
)
api.publish_changes(self.lib1.library_key)
# Delete component and revert
api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"]))
api.delete_library_block(LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]))
collection_update_event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver)
@@ -731,7 +734,7 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
Tests for Content Library API containers methods.
"""
def setUp(self):
def setUp(self) -> None:
super().setUp()
# Create Content Libraries
@@ -749,11 +752,11 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
self.problem_block = self._add_block_to_library(
self.lib1.library_key, "problem", "problem1",
)
self.problem_block_usage_key = UsageKey.from_string(self.problem_block["id"])
self.problem_block_usage_key = LibraryUsageLocatorV2.from_string(self.problem_block["id"])
self.html_block = self._add_block_to_library(
self.lib1.library_key, "html", "html1",
)
self.html_block_usage_key = UsageKey.from_string(self.html_block["id"])
self.html_block_usage_key = LibraryUsageLocatorV2.from_string(self.html_block["id"])
# Add content to units
api.update_container_children(
@@ -767,7 +770,7 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
None,
)
def test_get_containers_contains_component(self):
def test_get_containers_contains_component(self) -> None:
problem_block_containers = api.get_containers_contains_component(self.problem_block_usage_key)
html_block_containers = api.get_containers_contains_component(self.html_block_usage_key)
@@ -778,7 +781,7 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
assert html_block_containers[0].container_key == self.unit1.container_key
assert html_block_containers[1].container_key == self.unit2.container_key
def _validate_calls_of_html_block(self, event_mock):
def _validate_calls_of_html_block(self, event_mock) -> None:
"""
Validate that the `event_mock` has been called twice
using the `LIBRARY_CONTAINER_UPDATED` signal.
@@ -807,14 +810,14 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
event_mock.call_args_list[1].kwargs,
)
def test_call_container_update_signal_when_delete_component(self):
def test_call_container_update_signal_when_delete_component(self) -> None:
container_update_event_receiver = mock.Mock()
LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver)
api.delete_library_block(self.html_block_usage_key)
self._validate_calls_of_html_block(container_update_event_receiver)
def test_call_container_update_signal_when_restore_component(self):
def test_call_container_update_signal_when_restore_component(self) -> None:
api.delete_library_block(self.html_block_usage_key)
container_update_event_receiver = mock.Mock()
@@ -823,7 +826,7 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
self._validate_calls_of_html_block(container_update_event_receiver)
def test_call_container_update_signal_when_update_olx(self):
def test_call_container_update_signal_when_update_olx(self) -> None:
block_olx = "<html><b>Hello world!</b></html>"
container_update_event_receiver = mock.Mock()
LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver)
@@ -831,7 +834,7 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
self._set_library_block_olx(self.html_block_usage_key, block_olx)
self._validate_calls_of_html_block(container_update_event_receiver)
def test_call_container_update_signal_when_update_component(self):
def test_call_container_update_signal_when_update_component(self) -> None:
block_olx = "<html><b>Hello world!</b></html>"
container_update_event_receiver = mock.Mock()
LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver)
@@ -839,13 +842,13 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}})
self._validate_calls_of_html_block(container_update_event_receiver)
def test_call_object_changed_signal_when_remove_component(self):
def test_call_object_changed_signal_when_remove_component(self) -> None:
html_block_1 = self._add_block_to_library(
self.lib1.library_key, "html", "html3",
)
api.update_container_children(
self.unit2.container_key,
[UsageKey.from_string(html_block_1["id"])],
[LibraryUsageLocatorV2.from_string(html_block_1["id"])],
None,
entities_action=authoring_api.ChildrenEntitiesAction.APPEND,
)
@@ -854,7 +857,7 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver)
api.update_container_children(
self.unit2.container_key,
[UsageKey.from_string(html_block_1["id"])],
[LibraryUsageLocatorV2.from_string(html_block_1["id"])],
None,
entities_action=authoring_api.ChildrenEntitiesAction.REMOVE,
)
@@ -872,7 +875,7 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
event_reciver.call_args_list[0].kwargs,
)
def test_call_object_changed_signal_when_add_component(self):
def test_call_object_changed_signal_when_add_component(self) -> None:
event_reciver = mock.Mock()
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver)
html_block_1 = self._add_block_to_library(
@@ -885,8 +888,8 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
api.update_container_children(
self.unit2.container_key,
[
UsageKey.from_string(html_block_1["id"]),
UsageKey.from_string(html_block_2["id"])
LibraryUsageLocatorV2.from_string(html_block_1["id"]),
LibraryUsageLocatorV2.from_string(html_block_2["id"])
],
None,
entities_action=authoring_api.ChildrenEntitiesAction.APPEND,
@@ -916,19 +919,19 @@ class ContentLibraryContainersTest(ContentLibrariesRestApiTest):
event_reciver.call_args_list[1].kwargs,
)
def test_delete_component_and_revert(self):
def test_delete_component_and_revert(self) -> None:
"""
When a component is deleted and then the delete is reverted, signals
will be emitted to update any containing containers.
"""
# Add components and publish
api.update_container_children(self.unit1.container_key, [
UsageKey.from_string(self.problem_block["id"]),
LibraryUsageLocatorV2.from_string(self.problem_block["id"]),
], user_id=None)
api.publish_changes(self.lib1.library_key)
# Delete component and revert
api.delete_library_block(UsageKey.from_string(self.problem_block["id"]))
api.delete_library_block(LibraryUsageLocatorV2.from_string(self.problem_block["id"]))
container_event_receiver = mock.Mock()
LIBRARY_CONTAINER_UPDATED.connect(container_event_receiver)

View File

@@ -35,21 +35,125 @@ class ContainersTestCase(ContentLibrariesRestApiTest):
break any tests, but backwards-incompatible API changes will.
"""
def test_unit_crud(self):
"""
Test Create, Read, Update, and Delete of a Unit
"""
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
lib_key = LibraryLocatorV2.from_string(lib["id"])
def setUp(self) -> None:
super().setUp()
self.create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc)
self.lib = self._create_library(
slug="containers",
title="Container Test Library",
description="Units and more",
)
self.lib_key = LibraryLocatorV2.from_string(self.lib["id"])
# Create a unit:
# Create containers
with freeze_time(self.create_date):
# Unit
self.unit = self._create_container(self.lib["id"], "unit", display_name="Alpha Bravo", slug=None)
self.unit_with_components = self._create_container(
self.lib["id"],
"unit",
display_name="Alpha Charly",
slug=None,
)
self.unit_2 = self._create_container(self.lib["id"], "unit", display_name="Test Unit 2", slug=None)
self.unit_3 = self._create_container(self.lib["id"], "unit", display_name="Test Unit 3", slug=None)
# Subsection
self.subsection = self._create_container(
self.lib["id"],
"subsection",
display_name="Subsection Alpha",
slug=None,
)
self.subsection_with_units = self._create_container(
self.lib["id"],
"subsection",
display_name="Subsection with units",
slug=None,
)
self.subsection_2 = self._create_container(
self.lib["id"],
"subsection",
display_name="Test Subsection 2",
slug=None,
)
self.subsection_3 = self._create_container(
self.lib["id"],
"subsection",
display_name="Test Subsection 3",
slug=None,
)
# Section
self.section = self._create_container(self.lib["id"], "section", display_name="Section Alpha", slug=None)
self.section_with_subsections = self._create_container(
self.lib["id"],
"section",
display_name="Section with subsections",
slug=None,
)
# Create blocks
self.problem_block = self._add_block_to_library(self.lib["id"], "problem", "Problem1", can_stand_alone=False)
self.html_block = self._add_block_to_library(self.lib["id"], "html", "Html1", can_stand_alone=False)
self.problem_block_2 = self._add_block_to_library(self.lib["id"], "problem", "Problem2", can_stand_alone=False)
self.html_block_2 = self._add_block_to_library(self.lib["id"], "html", "Html2")
# Add components to `unit_with_components`
self._add_container_children(
self.unit_with_components["id"],
children_ids=[
self.problem_block["id"],
self.html_block["id"],
self.problem_block_2["id"],
self.html_block_2["id"],
],
)
# Add units to `subsection_with_units`
self._add_container_children(
self.subsection_with_units["id"],
children_ids=[
self.unit["id"],
self.unit_with_components["id"],
self.unit_2["id"],
self.unit_3["id"],
],
)
# Add subsections to `section_with_subsections`
self._add_container_children(
self.section_with_subsections["id"],
children_ids=[
self.subsection["id"],
self.subsection_with_units["id"],
self.subsection_2["id"],
self.subsection_3["id"],
],
)
@ddt.data(
("unit", "u1", "Test Unit"),
("subsection", "subs1", "Test Subsection"),
("section", "s1", "Test Section"),
)
@ddt.unpack
def test_container_crud(self, container_type, slug, display_name) -> None:
"""
Test Create, Read, Update, and Delete of a Containers
"""
# Create container:
create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc)
with freeze_time(create_date):
container_data = self._create_container(lib["id"], "unit", slug="u1", display_name="Test Unit")
container_data = self._create_container(
self.lib["id"],
container_type,
slug=slug,
display_name=display_name
)
container_id = f"lct:CL-TEST:containers:{container_type}:{slug}"
expected_data = {
"id": "lct:CL-TEST:containers:unit:u1",
"container_type": "unit",
"display_name": "Test Unit",
"id": container_id,
"container_type": container_type,
"display_name": display_name,
"last_published": None,
"published_by": "",
"last_draft_created": "2024-09-08T07:06:05Z",
@@ -62,94 +166,108 @@ class ContainersTestCase(ContentLibrariesRestApiTest):
self.assertDictContainsEntries(container_data, expected_data)
# Fetch the unit:
unit_as_read = self._get_container(container_data["id"])
# Fetch the container:
container_as_read = self._get_container(container_data["id"])
# make sure it contains the same data when we read it back:
self.assertDictContainsEntries(unit_as_read, expected_data)
self.assertDictContainsEntries(container_as_read, expected_data)
# Update the unit:
# Update the container:
modified_date = datetime(2024, 10, 9, 8, 7, 6, tzinfo=timezone.utc)
with freeze_time(modified_date):
container_data = self._update_container("lct:CL-TEST:containers:unit:u1", display_name="Unit ABC")
expected_data['last_draft_created'] = expected_data['modified'] = '2024-10-09T08:07:06Z'
expected_data['display_name'] = 'Unit ABC'
container_data = self._update_container(container_id, display_name=f"New Display Name for {container_type}")
expected_data["last_draft_created"] = expected_data["modified"] = "2024-10-09T08:07:06Z"
expected_data["display_name"] = f"New Display Name for {container_type}"
self.assertDictContainsEntries(container_data, expected_data)
# Re-fetch the unit
unit_as_re_read = self._get_container(container_data["id"])
# Re-fetch the container
container_as_re_read = self._get_container(container_data["id"])
# make sure it contains the same data when we read it back:
self.assertDictContainsEntries(unit_as_re_read, expected_data)
self.assertDictContainsEntries(container_as_re_read, expected_data)
# Delete the unit
# Delete the container
self._delete_container(container_data["id"])
self._get_container(container_data["id"], expect_response=404)
def test_unit_permissions(self):
@ddt.data(
("unit", "u2", "Test Unit"),
("subsection", "subs2", "Test Subsection"),
("section", "s2", "Test Section"),
)
@ddt.unpack
def test_container_permissions(self, container_type, slug, display_name) -> None:
"""
Test that a regular user with read-only permissions on the library cannot create, update, or delete units.
Test that a regular user with read-only permissions on the library cannot create, update, or delete containers.
"""
lib = self._create_library(slug="containers2", title="Container Test Library 2", description="Unit permissions")
container_data = self._create_container(lib["id"], "unit", slug="u2", display_name="Test Unit")
container_data = self._create_container(self.lib["id"], container_type, slug=slug, display_name=display_name)
random_user = UserFactory.create(username="Random", email="random@example.com")
with self.as_user(random_user):
self._create_container(lib["id"], "unit", slug="u3", display_name="Test Unit", expect_response=403)
self._create_container(
self.lib["id"],
container_type,
slug="new_slug",
display_name=display_name,
expect_response=403,
)
self._get_container(container_data["id"], expect_response=403)
self._update_container(container_data["id"], display_name="Unit ABC", expect_response=403)
self._update_container(container_data["id"], display_name="New Display Name", expect_response=403)
self._delete_container(container_data["id"], expect_response=403)
# Granting read-only permissions on the library should only allow retrieval, nothing else.
self._add_user_by_email(lib["id"], random_user.email, access_level="read")
self._add_user_by_email(self.lib["id"], random_user.email, access_level="read")
with self.as_user(random_user):
self._create_container(lib["id"], "unit", slug="u2", display_name="Test Unit", expect_response=403)
self._create_container(
self.lib["id"],
container_type,
slug=slug,
display_name=display_name,
expect_response=403,
)
self._get_container(container_data["id"], expect_response=200)
self._update_container(container_data["id"], display_name="Unit ABC", expect_response=403)
self._update_container(container_data["id"], display_name="New Display Name", expect_response=403)
self._delete_container(container_data["id"], expect_response=403)
def test_unit_gets_auto_slugs(self):
@ddt.data(
("unit", "Alpha Bravo", "lct:CL-TEST:containers:unit:alpha-bravo-"),
("subsection", "Subsection Alpha", "lct:CL-TEST:containers:subsection:subsection-alpha-"),
("section", "Section Alpha", "lct:CL-TEST:containers:section:section-alpha-"),
)
@ddt.unpack
def test_containers_gets_auto_slugs(self, container_type, display_name, expected_id) -> None:
"""
Test that we can create units by specifying only a title, and they get
Test that we can create containers by specifying only a title, and they get
unique slugs assigned automatically.
"""
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
container_1 = getattr(self, container_type)
container_2 = self._create_container(self.lib["id"], container_type, display_name=display_name, slug=None)
# Create two units, specifying their titles but not their slugs/keys:
container1_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None)
container2_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None)
# Notice the container IDs below are slugified from the title: "alpha-bravo-NNNNN"
assert container1_data["id"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-")
assert container2_data["id"].startswith("lct:CL-TEST:containers:unit:alpha-bravo-")
assert container1_data["id"] != container2_data["id"]
assert container_1["id"].startswith(expected_id)
assert container_2["id"].startswith(expected_id)
assert container_1["id"] != container_2["id"]
def test_unit_add_children(self):
def test_unit_add_children(self) -> None:
"""
Test that we can add and get unit children components
"""
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["id"],
children_ids=[problem_block["id"], html_block["id"]]
# Add some components
self._add_container_children(
self.unit["id"],
children_ids=[self.problem_block["id"], self.html_block["id"]]
)
data = self._get_container_components(container_data["id"])
data = self._get_container_children(self.unit["id"])
assert len(data) == 2
assert data[0]['id'] == problem_block['id']
assert data[0]['id'] == self.problem_block['id']
assert not data[0]['can_stand_alone']
assert data[1]['id'] == html_block['id']
assert data[1]['id'] == self.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")
problem_block_2 = self._add_block_to_library(self.lib["id"], "problem", "Problem_2", can_stand_alone=False)
html_block_2 = self._add_block_to_library(self.lib["id"], "html", "Html_2")
# Add two more components
self._add_container_components(
container_data["id"],
self._add_container_children(
self.unit["id"],
children_ids=[problem_block_2["id"], html_block_2["id"]]
)
data = self._get_container_components(container_data["id"])
data = self._get_container_children(self.unit["id"])
# Verify total number of components to be 2 + 2 = 4
assert len(data) == 4
assert data[2]['id'] == problem_block_2['id']
@@ -157,105 +275,262 @@ class ContainersTestCase(ContentLibrariesRestApiTest):
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
"""
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
lib_key = LibraryLocatorV2.from_string(lib["id"])
def test_subsection_add_children(self) -> None:
# Create units
child_unit_1 = self._create_container(self.lib["id"], "unit", display_name="Child unit 1", slug=None)
child_unit_2 = self._create_container(self.lib["id"], "unit", display_name="Child unit 2", slug=None)
# 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["id"],
children_ids=[problem_block["id"], html_block["id"], problem_block_2["id"], html_block_2["id"]]
# Add the units to subsection
self._add_container_children(
self.subsection["id"],
children_ids=[child_unit_1["id"], child_unit_2["id"]]
)
data = self._get_container_components(container_data["id"])
assert len(data) == 4
# Remove both problem blocks.
self._remove_container_components(
container_data["id"],
children_ids=[problem_block_2["id"], problem_block["id"]]
)
data = self._get_container_components(container_data["id"])
data = self._get_container_children(self.subsection["id"])
assert len(data) == 2
assert data[0]['id'] == html_block['id']
assert data[1]['id'] == html_block_2['id']
assert data[0]['id'] == child_unit_1['id']
assert data[1]['id'] == child_unit_2['id']
def test_unit_replace_children(self):
child_unit_3 = self._create_container(self.lib["id"], "unit", display_name="Child unit 3", slug=None)
child_unit_4 = self._create_container(self.lib["id"], "unit", display_name="Child unit 4", slug=None)
# Add two more units to subsection
self._add_container_children(
self.subsection["id"],
children_ids=[child_unit_3["id"], child_unit_4["id"]]
)
data = self._get_container_children(self.subsection["id"])
# Verify total number of units to be 2 + 2 = 4
assert len(data) == 4
assert data[2]['id'] == child_unit_3['id']
assert data[3]['id'] == child_unit_4['id']
def test_section_add_children(self) -> None:
# Create Subsections
child_subsection_1 = self._create_container(
self.lib["id"],
"subsection",
display_name="Child Subsection 1",
slug=None,
)
child_subsection_2 = self._create_container(
self.lib["id"],
"subsection",
display_name="Child Subsection 2",
slug=None,
)
# Add the subsections to section
self._add_container_children(
self.section["id"],
children_ids=[child_subsection_1["id"], child_subsection_2["id"]]
)
data = self._get_container_children(self.section["id"])
assert len(data) == 2
assert data[0]['id'] == child_subsection_1['id']
assert data[1]['id'] == child_subsection_2['id']
child_subsection_3 = self._create_container(
self.lib["id"],
"subsection",
display_name="Child Subsection 3",
slug=None,
)
child_subsection_4 = self._create_container(
self.lib["id"],
"subsection",
display_name="Child Subsection 4",
slug=None,
)
# Add two more subsections to section
self._add_container_children(
self.section["id"],
children_ids=[child_subsection_3["id"], child_subsection_4["id"]]
)
data = self._get_container_children(self.section["id"])
# Verify total number of subsections to be 2 + 2 = 4
assert len(data) == 4
assert data[2]['id'] == child_subsection_3['id']
assert data[3]['id'] == child_subsection_4['id']
@ddt.data(
("unit_with_components", ["problem_block_2", "problem_block"], ["html_block", "html_block_2"]),
("subsection_with_units", ["unit", "unit_with_components"], ["unit_2", "unit_3"]),
("section_with_subsections", ["subsection", "subsection_with_units"], ["subsection_2", "subsection_3"]),
)
@ddt.unpack
def test_container_remove_children(self, container_name, items_to_remove, expected_items) -> None:
"""
Test that we can remove container children
"""
container = getattr(self, container_name)
item_to_remove_1 = getattr(self, items_to_remove[0])
item_to_remove_2 = getattr(self, items_to_remove[1])
expected_item_1 = getattr(self, expected_items[0])
expected_item_2 = getattr(self, expected_items[1])
data = self._get_container_children(container["id"])
assert len(data) == 4
# Remove items.
self._remove_container_components(
container["id"],
children_ids=[item_to_remove_1["id"], item_to_remove_2["id"]]
)
data = self._get_container_children(container["id"])
assert len(data) == 2
assert data[0]['id'] == expected_item_1['id']
assert data[1]['id'] == expected_item_2['id']
def test_unit_replace_children(self) -> None:
"""
Test that we can completely replace/reorder unit children components.
"""
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["id"],
children_ids=[problem_block["id"], html_block["id"], problem_block_2["id"], html_block_2["id"]]
)
data = self._get_container_components(container_data["id"])
data = self._get_container_children(self.unit_with_components["id"])
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']
assert data[0]['id'] == self.problem_block['id']
assert data[1]['id'] == self.html_block['id']
assert data[2]['id'] == self.problem_block_2['id']
assert data[3]['id'] == self.html_block_2['id']
# Reorder the components
self._patch_container_components(
container_data["id"],
children_ids=[problem_block["id"], problem_block_2["id"], html_block["id"], html_block_2["id"]]
self.unit_with_components["id"],
children_ids=[
self.problem_block["id"],
self.problem_block_2["id"],
self.html_block["id"],
self.html_block_2["id"],
]
)
data = self._get_container_components(container_data["id"])
data = self._get_container_children(self.unit_with_components["id"])
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']
assert data[0]['id'] == self.problem_block['id']
assert data[1]['id'] == self.problem_block_2['id']
assert data[2]['id'] == self.html_block['id']
assert data[3]['id'] == self.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)
new_problem_block = self._add_block_to_library(self.lib["id"], "problem", "New_Problem", can_stand_alone=False)
new_html_block = self._add_block_to_library(self.lib["id"], "html", "New_Html", can_stand_alone=False)
self._patch_container_components(
container_data["id"],
self.unit_with_components["id"],
children_ids=[new_problem_block["id"], new_html_block["id"]],
)
data = self._get_container_components(container_data["id"])
data = self._get_container_children(self.unit_with_components["id"])
assert len(data) == 2
assert data[0]['id'] == new_problem_block['id']
assert data[1]['id'] == new_html_block['id']
def test_restore_unit(self):
def test_subsection_replace_children(self) -> None:
"""
Test restore a deleted unit.
Test that we can completely replace/reorder subsection children.
"""
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
lib_key = LibraryLocatorV2.from_string(lib["id"])
data = self._get_container_children(self.subsection_with_units["id"])
assert len(data) == 4
assert data[0]['id'] == self.unit['id']
assert data[1]['id'] == self.unit_with_components['id']
assert data[2]['id'] == self.unit_2['id']
assert data[3]['id'] == self.unit_3['id']
# Create a unit:
create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc)
with freeze_time(create_date):
container_data = self._create_container(lib["id"], "unit", slug="u1", display_name="Test Unit")
# Reorder the units
self._patch_container_components(
self.subsection_with_units["id"],
children_ids=[
self.unit_2["id"],
self.unit["id"],
self.unit_3["id"],
self.unit_with_components["id"],
]
)
data = self._get_container_children(self.subsection_with_units["id"])
assert len(data) == 4
assert data[0]['id'] == self.unit_2['id']
assert data[1]['id'] == self.unit['id']
assert data[2]['id'] == self.unit_3['id']
assert data[3]['id'] == self.unit_with_components['id']
# Delete the unit
self._delete_container(container_data["id"])
# Replace with new units
new_unit_1 = self._create_container(self.lib["id"], "unit", display_name="New Unit 1", slug=None)
new_unit_2 = self._create_container(self.lib["id"], "unit", display_name="New Unit 2", slug=None)
self._patch_container_components(
self.subsection_with_units["id"],
children_ids=[new_unit_1["id"], new_unit_2["id"]],
)
data = self._get_container_children(self.subsection_with_units["id"])
assert len(data) == 2
assert data[0]['id'] == new_unit_1['id']
assert data[1]['id'] == new_unit_2['id']
def test_section_replace_children(self) -> None:
"""
Test that we can completely replace/reorder section children.
"""
data = self._get_container_children(self.section_with_subsections["id"])
assert len(data) == 4
assert data[0]['id'] == self.subsection['id']
assert data[1]['id'] == self.subsection_with_units['id']
assert data[2]['id'] == self.subsection_2['id']
assert data[3]['id'] == self.subsection_3['id']
# Reorder the subsections
self._patch_container_components(
self.section_with_subsections["id"],
children_ids=[
self.subsection_2["id"],
self.subsection["id"],
self.subsection_3["id"],
self.subsection_with_units["id"],
]
)
data = self._get_container_children(self.section_with_subsections["id"])
assert len(data) == 4
assert data[0]['id'] == self.subsection_2['id']
assert data[1]['id'] == self.subsection['id']
assert data[2]['id'] == self.subsection_3['id']
assert data[3]['id'] == self.subsection_with_units['id']
# Replace with new subsections
new_subsection_1 = self._create_container(
self.lib["id"],
"subsection",
display_name="New Subsection 1",
slug=None,
)
new_subsection_2 = self._create_container(
self.lib["id"],
"subsection",
display_name="New Subsection 2",
slug=None,
)
self._patch_container_components(
self.section_with_subsections["id"],
children_ids=[new_subsection_1["id"], new_subsection_2["id"]],
)
data = self._get_container_children(self.section_with_subsections["id"])
assert len(data) == 2
assert data[0]['id'] == new_subsection_1['id']
assert data[1]['id'] == new_subsection_2['id']
@ddt.data(
"unit",
"subsection",
"section",
)
def test_restore_containers(self, container_type) -> None:
"""
Test restore a deleted container.
"""
container = getattr(self, container_type)
# Delete container
self._delete_container(container["id"])
# Restore container
self._restore_container(container_data["id"])
new_container_data = self._get_container(container_data["id"])
self._restore_container(container["id"])
new_container_data = self._get_container(container["id"])
expected_data = {
"id": "lct:CL-TEST:containers:unit:u1",
"container_type": "unit",
"display_name": "Test Unit",
"id": container["id"],
"container_type": container_type,
"display_name": container["display_name"],
"last_published": None,
"published_by": "",
"last_draft_created": "2024-09-08T07:06:05Z",
@@ -266,17 +541,12 @@ class ContainersTestCase(ContentLibrariesRestApiTest):
'collections': [],
}
def test_container_collections(self):
# Create a library
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
lib_key = LibraryLocatorV2.from_string(lib["id"])
# Create a unit
container_data = self._create_container(lib["id"], "unit", display_name="Alpha Bravo", slug=None)
self.assertDictContainsEntries(new_container_data, expected_data)
def test_container_collections(self) -> None:
# Create a collection
col1 = api.create_library_collection(
lib_key,
self.lib_key,
"COL1",
title="Collection 1",
created_by=self.user.id,
@@ -284,69 +554,80 @@ class ContainersTestCase(ContentLibrariesRestApiTest):
)
result = self._patch_container_collections(
container_data["id"],
self.unit["id"],
collection_keys=[col1.key],
)
assert result['count'] == 1
# Fetch the unit
unit_as_read = self._get_container(container_data["id"])
unit_as_read = self._get_container(self.unit["id"])
# Verify the collections
assert unit_as_read['collections'] == [{"title": col1.title, "key": col1.key}]
def test_publish_container(self): # pylint: disable=too-many-statements
def test_publish_container(self) -> None: # pylint: disable=too-many-statements
"""
Test that we can publish the changes to a specific container
"""
lib = self._create_library(slug="containers", title="Container Test Library", description="Units and more")
html_block_3 = self._add_block_to_library(self.lib["id"], "html", "Html3")
self._add_container_children(
self.unit["id"],
children_ids=[
self.html_block["id"],
html_block_3["id"],
]
)
# Create two containers and add some components
container1 = self._create_container(lib["id"], "unit", display_name="Alpha Unit", slug=None)
container2 = self._create_container(lib["id"], "unit", display_name="Bravo Unit", 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)
html_block2 = self._add_block_to_library(lib["id"], "html", "Html2", can_stand_alone=False)
self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]])
self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]])
# At first everything is unpublished:
c1_before = self._get_container(container1["id"])
c1_before = self._get_container(self.unit_with_components["id"])
assert c1_before["has_unpublished_changes"]
c1_components_before = self._get_container_components(container1["id"])
assert len(c1_components_before) == 2
assert c1_components_before[0]["id"] == problem_block["id"]
c1_components_before = self._get_container_children(self.unit_with_components["id"])
assert len(c1_components_before) == 4
assert c1_components_before[0]["id"] == self.problem_block["id"]
assert c1_components_before[0]["has_unpublished_changes"]
assert c1_components_before[0]["published_by"] is None
assert c1_components_before[1]["id"] == html_block["id"]
assert c1_components_before[1]["id"] == self.html_block["id"]
assert c1_components_before[1]["has_unpublished_changes"]
assert c1_components_before[1]["published_by"] is None
c2_before = self._get_container(container2["id"])
assert c1_components_before[2]["id"] == self.problem_block_2["id"]
assert c1_components_before[2]["has_unpublished_changes"]
assert c1_components_before[2]["published_by"] is None
assert c1_components_before[3]["id"] == self.html_block_2["id"]
assert c1_components_before[3]["has_unpublished_changes"]
assert c1_components_before[3]["published_by"] is None
c2_before = self._get_container(self.unit["id"])
assert c2_before["has_unpublished_changes"]
# Now publish only Container 1
self._publish_container(container1["id"])
self._publish_container(self.unit_with_components["id"])
# Now it is published:
c1_after = self._get_container(container1["id"])
c1_after = self._get_container(self.unit_with_components["id"])
assert c1_after["has_unpublished_changes"] is False
c1_components_after = self._get_container_components(container1["id"])
assert len(c1_components_after) == 2
assert c1_components_after[0]["id"] == problem_block["id"]
c1_components_after = self._get_container_children(self.unit_with_components["id"])
assert len(c1_components_after) == 4
assert c1_components_after[0]["id"] == self.problem_block["id"]
assert c1_components_after[0]["has_unpublished_changes"] is False
assert c1_components_after[0]["published_by"] == self.user.username
assert c1_components_after[1]["id"] == html_block["id"]
assert c1_components_after[1]["id"] == self.html_block["id"]
assert c1_components_after[1]["has_unpublished_changes"] is False
assert c1_components_after[1]["published_by"] == self.user.username
assert c1_components_after[2]["id"] == self.problem_block_2["id"]
assert c1_components_after[2]["has_unpublished_changes"] is False
assert c1_components_after[2]["published_by"] == self.user.username
assert c1_components_after[3]["id"] == self.html_block_2["id"]
assert c1_components_after[3]["has_unpublished_changes"] is False
assert c1_components_after[3]["published_by"] == self.user.username
# and container 2 is still unpublished, except for the shared HTML block that is also in container 1:
c2_after = self._get_container(container2["id"])
c2_after = self._get_container(self.unit["id"])
assert c2_after["has_unpublished_changes"]
c2_components_after = self._get_container_components(container2["id"])
c2_components_after = self._get_container_children(self.unit["id"])
assert len(c2_components_after) == 2
assert c2_components_after[0]["id"] == html_block["id"]
assert c2_components_after[0]["id"] == self.html_block["id"]
assert c2_components_after[0]["has_unpublished_changes"] is False # published since it's also in container 1
assert c2_components_after[0]["published_by"] == self.user.username
assert c2_components_after[1]["id"] == html_block2["id"]
assert c2_components_after[1]["id"] == html_block_3["id"]
assert c2_components_after[1]["has_unpublished_changes"] # unaffected
assert c2_components_after[1]["published_by"] is None

View File

@@ -58,7 +58,7 @@ class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
"can_stand_alone": True,
})
children = self._get_container_components(paste_data["id"])
children = self._get_container_children(paste_data["id"])
assert len(children) == 4
self.assertDictContainsEntries(children[0], {"display_name": "default", "block_type": "video"})

View File

@@ -308,8 +308,8 @@ class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest):
problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False)
html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False)
html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False)
self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]])
self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]])
self._add_container_children(container1["id"], children_ids=[problem_block["id"], html_block["id"]])
self._add_container_children(container2["id"], children_ids=[html_block["id"], html_block2["id"]])
# Now publish only Container 2 (which will auto-publish both HTML blocks since they're children)
self._publish_container(container2["id"])
@@ -351,7 +351,7 @@ class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest):
# Create a container and a block
container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None)
problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False)
self._add_container_components(container1["id"], children_ids=[problem_block["id"]])
self._add_container_children(container1["id"], children_ids=[problem_block["id"]])
# Publish all changes
self._commit_library_changes(self.lib1_key)
assert self._get_container(container1["id"])["has_unpublished_changes"] is False
@@ -396,8 +396,8 @@ class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest):
problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False)
html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False)
html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False)
self._add_container_components(container1["id"], children_ids=[problem_block["id"], html_block["id"]])
self._add_container_components(container2["id"], children_ids=[html_block["id"], html_block2["id"]])
self._add_container_children(container1["id"], children_ids=[problem_block["id"], html_block["id"]])
self._add_container_children(container2["id"], children_ids=[html_block["id"], html_block2["id"]])
# At first everything is unpublished:
c1_before = self._get_container(container1["id"])
assert c1_before["has_unpublished_changes"]

View File

@@ -74,6 +74,14 @@ class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest):
self.lib2_html_block = self._add_block_to_library(
self.lib2.library_key, "html", "html2",
)
self.unit = self._create_container(self.lib1.library_key, "unit", display_name="Unit 1", slug=None)
self.subsection = self._create_container(
self.lib1.library_key,
"subsection",
display_name="Subsection 1",
slug=None,
)
self.section = self._create_container(self.lib1.library_key, "section", display_name="Section 1", slug=None)
def test_get_library_collection(self):
"""
@@ -407,6 +415,43 @@ class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest):
assert resp.status_code == 200
assert resp.data == {"count": 1}
def test_update_containers(self):
"""
Test adding and removing containers from a collection.
"""
# Add containers to col1
resp = self.client.patch(
URL_LIB_COLLECTION_COMPONENTS.format(
lib_key=self.lib1.library_key,
collection_key=self.col1.key,
),
data={
"usage_keys": [
self.unit["id"],
self.subsection["id"],
self.section["id"],
]
}
)
assert resp.status_code == 200
assert resp.data == {"count": 3}
# Remove one of the added containers from col1
resp = self.client.delete(
URL_LIB_COLLECTION_COMPONENTS.format(
lib_key=self.lib1.library_key,
collection_key=self.col1.key,
),
data={
"usage_keys": [
self.unit["id"],
self.subsection["id"],
]
}
)
assert resp.status_code == 200
assert resp.data == {"count": 2}
@ddt.data("patch", "delete")
def test_update_components_wrong_collection(self, method):
"""