diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index c6ca52098abc..061015751955 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -572,6 +572,7 @@ def searchable_doc_for_container( Fields.usage_key: str(container_key), # Field name isn't exact but this is the closest match Fields.block_id: container_key.container_id, # Field name isn't exact but this is the closest match Fields.access_id: _meili_access_id_from_context_key(container_key.library_key), + Fields.publish_status: PublishStatus.never, } try: diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py index 6719c07065e4..c03cee2b84d8 100644 --- a/openedx/core/djangoapps/content_libraries/api/containers.py +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -13,6 +13,7 @@ LibraryContainerLocator, LibraryLocatorV2, UsageKeyV2, + LibraryUsageLocatorV2, ) from openedx_events.content_authoring.data import LibraryContainerData from openedx_events.content_authoring.signals import ( @@ -42,6 +43,7 @@ "update_container", "delete_container", "update_container_children", + "get_containers_contains_component", ] @@ -316,3 +318,20 @@ def update_container_children( ) return ContainerMetadata.from_container(library_key, new_version.container) + + +def get_containers_contains_component( + usage_key: LibraryUsageLocatorV2 +) -> list[ContainerMetadata]: + """ + Get containers that contains the component. + """ + assert isinstance(usage_key, LibraryUsageLocatorV2) + component = get_component_from_usage_key(usage_key) + containers = authoring_api.get_containers_with_entity( + component.publishable_entity.pk, + ) + return [ + ContainerMetadata.from_container(usage_key.context_key, container) + for container in containers + ] diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py index a4b001cd9a47..3884614ae445 100644 --- a/openedx/core/djangoapps/content_libraries/api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -82,6 +82,7 @@ ContentLibraryData, LibraryBlockData, LibraryCollectionData, + LibraryContainerData, ContentObjectChangedData, ) from openedx_events.content_authoring.signals import ( @@ -92,6 +93,7 @@ LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_UPDATED, CONTENT_OBJECT_ASSOCIATIONS_CHANGED, ) from openedx_learning.api import authoring as authoring_api @@ -113,6 +115,7 @@ xblock_type_display_name, ) from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core +from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.types import User as UserType from xmodule.modulestore.django import modulestore @@ -901,6 +904,18 @@ def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) -> ) ) + # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger + # container indexing asynchronously. + affected_containers = lib_api.get_containers_contains_component(usage_key) + for container in affected_containers: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=usage_key.lib_key, + container_key=str(container.container_key), + background=True, + ) + ) + return new_component_version @@ -1205,6 +1220,7 @@ def delete_library_block(usage_key: LibraryUsageLocatorV2, remove_from_parent=Tr component = get_component_from_usage_key(usage_key) library_key = usage_key.context_key affected_collections = authoring_api.get_entity_collections(component.learning_package_id, component.key) + affected_containers = lib_api.get_containers_contains_component(usage_key) authoring_api.soft_delete_draft(component.pk) @@ -1228,6 +1244,19 @@ def delete_library_block(usage_key: LibraryUsageLocatorV2, remove_from_parent=Tr ) ) + # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger + # container indexing asynchronously. + # + # To update the components count in containers + for container in affected_containers: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=library_key, + container_key=str(container.container_key), + background=True, + ) + ) + def restore_library_block(usage_key: LibraryUsageLocatorV2) -> None: """ diff --git a/openedx/core/djangoapps/content_libraries/library_context.py b/openedx/core/djangoapps/content_libraries/library_context.py index 4bda10eb12fa..d5f121d8396e 100644 --- a/openedx/core/djangoapps/content_libraries/library_context.py +++ b/openedx/core/djangoapps/content_libraries/library_context.py @@ -6,8 +6,8 @@ from django.core.exceptions import PermissionDenied from rest_framework.exceptions import NotFound -from openedx_events.content_authoring.data import LibraryBlockData -from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED +from openedx_events.content_authoring.data import LibraryBlockData, LibraryContainerData +from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED, LIBRARY_CONTAINER_UPDATED from opaque_keys.edx.keys import UsageKeyV2 from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2 from openedx_learning.api import authoring as authoring_api @@ -114,3 +114,19 @@ def send_block_updated_event(self, usage_key: UsageKeyV2): usage_key=usage_key, ) ) + + def send_container_updated_events(self, usage_key: UsageKeyV2): + """ + Send "container updated" events for containers that contains the library block + with the given usage_key. + """ + assert isinstance(usage_key, LibraryUsageLocatorV2) + affected_containers = api.get_containers_contains_component(usage_key) + for container in affected_containers: + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + library_key=usage_key.lib_key, + container_key=str(container.container_key), + background=True, + ) + ) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index d4be6fdf6bd9..a1475aa1a039 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -16,12 +16,14 @@ from openedx_events.content_authoring.data import ( ContentObjectChangedData, LibraryCollectionData, + LibraryContainerData, ) from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_UPDATED, ) from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx_learning.api import authoring as authoring_api @@ -742,3 +744,109 @@ def test_delete_component_and_revert(self): }, event_receiver.call_args_list[1].kwargs, ) + + +class ContentLibraryContainersTest(ContentLibrariesRestApiTest, TestCase): + """ + Tests for Content Library API containers methods. + """ + def setUp(self): + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-cont-1", "Test Library 1") + + # Fetch the created ContentLibrare objects so we can access their learning_package.id + self.lib1 = ContentLibrary.objects.get(slug="test-lib-cont-1") + + # Create Units + self.unit1 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-1', 'Unit 1', None) + self.unit2 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-2', 'Unit 2', None) + + # Create XBlocks + # Create some library blocks in lib1 + 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.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"]) + + # Add content to units + api.update_container_children( + self.unit1.container_key, + [self.problem_block_usage_key, self.html_block_usage_key], + None, + ) + api.update_container_children( + self.unit2.container_key, + [self.html_block_usage_key], + None, + ) + + def test_get_containers_contains_component(self): + 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) + + assert len(problem_block_containers) == 1 + assert problem_block_containers[0].container_key == self.unit1.container_key + + assert len(html_block_containers) == 2 + 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): + """ + Validate that the `event_mock` has been called twice + using the `LIBRARY_CONTAINER_UPDATED` signal. + """ + assert event_mock.call_count == 2 + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + library_key=self.lib1.library_key, + container_key=str(self.unit1.container_key), + background=True, + ) + }, + event_mock.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + library_key=self.lib1.library_key, + container_key=str(self.unit2.container_key), + background=True, + ) + }, + event_mock.call_args_list[1].kwargs, + ) + + def test_call_container_update_signal_when_delete_component(self): + 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_update_olx(self): + block_olx = "Hello world!" + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + 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): + block_olx = "Hello world!" + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}}) + self._validate_calls_of_html_block(container_update_event_receiver) diff --git a/openedx/core/djangoapps/xblock/learning_context/learning_context.py b/openedx/core/djangoapps/xblock/learning_context/learning_context.py index b535e84ca7c1..dc7a21f1c397 100644 --- a/openedx/core/djangoapps/xblock/learning_context/learning_context.py +++ b/openedx/core/djangoapps/xblock/learning_context/learning_context.py @@ -76,3 +76,11 @@ def send_block_updated_event(self, usage_key): usage_key: the UsageKeyV2 subclass used for this learning context """ + + def send_container_updated_events(self, usage_key): + """ + Send "container updated" events for containers that contains the block with + the given usage_key in this context. + + usage_key: the UsageKeyV2 subclass used for this learning context + """ diff --git a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py index c3885fbf11cc..57582989f60d 100644 --- a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py @@ -317,6 +317,7 @@ def save_block(self, block): # Signal that we've modified this block learning_context = get_learning_context_impl(usage_key) learning_context.send_block_updated_event(usage_key) + learning_context.send_container_updated_events(usage_key) def _get_component_from_usage_key(self, usage_key): """