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