diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index a7660916702a..401c5018a4e9 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -354,19 +354,19 @@ def upsert_xblock_index_doc( """ current_rebuild_index_name = _get_running_rebuild_index_name() - course = modulestore().get_item(usage_key) + xblock = modulestore().get_item(usage_key) client = _get_meilisearch_client() docs = [] def add_with_children(block): """ Recursively index the given XBlock/component """ - doc = searchable_doc_for_course_block(block, metadata=update_metadata, tags=update_tags) + doc = searchable_doc_for_course_block(block, include_metadata=update_metadata, include_tags=update_tags) docs.append(doc) if recursive: _recurse_children(block, add_with_children) - add_with_children(course) + add_with_children(xblock) tasks = [] if current_rebuild_index_name: @@ -397,6 +397,38 @@ def delete_xblock_index_doc(usage_key: UsageKey) -> None: _wait_for_meili_tasks(tasks) +def upsert_library_block_index_doc( + usage_key: UsageKey, update_metadata: bool = True, update_tags: bool = True +) -> None: + """ + Creates or updates the document for the given Library Block in the search index + + + Args: + usage_key (UsageKey): The usage key of the Library Block to index + update_metadata (bool): If True, update the metadata of the Library Block + update_tags (bool): If True, update the tags of the Library Block + """ + current_rebuild_index_name = _get_running_rebuild_index_name() + + library_block = lib_api.get_component_from_usage_key(usage_key) + library_block_metadata = lib_api.LibraryXBlockMetadata.from_component(usage_key.context_key, library_block) + client = _get_meilisearch_client() + + docs = [ + searchable_doc_for_library_block( + library_block_metadata, include_metadata=update_metadata, include_tags=update_tags + ) + ] + + tasks = [] + if current_rebuild_index_name: + # If there is a rebuild in progress, the document will also be added to the new index. + tasks.append(client.index(current_rebuild_index_name).update_documents(docs)) + tasks.append(client.index(INDEX_NAME).update_documents(docs)) + + _wait_for_meili_tasks(tasks) + def generate_user_token(user): """ Returns a Meilisearch API key that only allows the user to search content that they have permission to view diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index e5dfe25592fe..f42bd5db9965 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -88,7 +88,6 @@ class implementation returns only: {"content": {"display_name": "..."}, "content_type": "..."} """ block_data = { - Fields.id: meili_id_from_opaque_key(block.usage_key), Fields.usage_key: str(block.usage_key), Fields.block_id: str(block.usage_key.block_id), Fields.display_name: xblock_api.get_block_display_name(block), @@ -196,27 +195,39 @@ def _tags_for_content_object(object_id: UsageKey | LearningContextKey) -> dict: return {Fields.tags: result} -def searchable_doc_for_library_block(metadata: lib_api.LibraryXBlockMetadata) -> dict: +def searchable_doc_for_library_block( + xblock_metadata: lib_api.LibraryXBlockMetadata, include_metadata: bool = True, include_tags: bool = True +) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, so that the given library block can be found using faceted search. + + Args: + xblock_metadata: The XBlock component metadata to index + include_metadata: If True, include the block's metadata in the doc + include_tags: If True, include the block's tags in the doc """ - library_name = lib_api.get_library(metadata.usage_key.context_key).title - doc = {} - try: - block = xblock_api.load_block(metadata.usage_key, user=None) - except Exception as err: # pylint: disable=broad-except - log.exception(f"Failed to load XBlock {metadata.usage_key}: {err}") - doc.update(_fields_from_block(block)) - doc.update(_tags_for_content_object(metadata.usage_key)) - doc[Fields.type] = DocType.library_block - # Add the breadcrumbs. In v2 libraries, the library itself is not a "parent" of the XBlocks so we add it here: - doc[Fields.breadcrumbs] = [{"display_name": library_name}] + library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title + block = xblock_api.load_block(xblock_metadata.usage_key, user=None) + + doc = { + Fields.id: meili_id_from_opaque_key(xblock_metadata.usage_key), + Fields.type: DocType.library_block, + } + + if include_metadata: + doc.update(_fields_from_block(block)) + # Add the breadcrumbs. In v2 libraries, the library itself is not a "parent" of the XBlocks so we add it here: + doc[Fields.breadcrumbs] = [{"display_name": library_name}] + + if include_tags: + doc.update(_tags_for_content_object(xblock_metadata.usage_key)) + return doc -def searchable_doc_for_course_block(block, metadata: bool = True, tags: bool = True) -> dict: +def searchable_doc_for_course_block(block, include_metadata: bool = True, include_tags: bool = True) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, so that the given course block can be @@ -224,18 +235,18 @@ def searchable_doc_for_course_block(block, metadata: bool = True, tags: bool = T Args: block: The XBlock instance to index - metadata: If True, include the block's metadata in the doc - tags: If True, include the block's tags in the doc + include_metadata: If True, include the block's metadata in the doc + include_tags: If True, include the block's tags in the doc """ doc = { Fields.id: meili_id_from_opaque_key(block.usage_key), Fields.type: DocType.course_block, } - if metadata: + if include_metadata: doc.update(_fields_from_block(block)) - if tags: + if include_tags: doc.update(_tags_for_content_object(block.usage_key)) return doc diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index ee7ef67243e5..0439f900d4c2 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -4,15 +4,15 @@ from __future__ import annotations from unittest.mock import MagicMock, call, patch -from organizations.tests.factories import OrganizationFactory import ddt from django.test import override_settings +from organizations.tests.factories import OrganizationFactory from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content_libraries import api as library_api from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase -from openedx.core.djangoapps.content_libraries import api as library_api from .. import api @@ -92,7 +92,7 @@ def setUp(self): title="Library", ) # Populate it with a problem: - self.problem_key = library_api.create_library_block(self.library.key, "problem", "p1").usage_key + self.problem = library_api.create_library_block(self.library.key, "problem", "p1") self.doc_problem = { "id": "lborg1libproblemp1-a698218e", "usage_key": "lb:org1:lib:problem:p1", @@ -158,3 +158,27 @@ def test_delete_index_xblock(self, mock_meilisearch): mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( self.doc_sequential['id'] ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_index_library_block_metadata(self, mock_meilisearch): + """ + Test indexing a Library Block. + """ + api.upsert_library_block_index_doc( + self.problem.usage_key, + update_metadata=True, + update_tags=False, + ) + + mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.doc_problem]) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_delete_index_library_block(self, mock_meilisearch): + """ + Test deleting a Library Block doc from the index. + """ + api.delete_xblock_index_doc(self.problem.usage_key) + + mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( + self.doc_problem['id'] + ) diff --git a/openedx/core/djangoapps/content/search/tests/test_views.py b/openedx/core/djangoapps/content/search/tests/test_views.py index 8116e5088219..ccd255f84e6d 100644 --- a/openedx/core/djangoapps/content/search/tests/test_views.py +++ b/openedx/core/djangoapps/content/search/tests/test_views.py @@ -3,19 +3,23 @@ """ from __future__ import annotations +from unittest.mock import MagicMock, patch + from django.test import override_settings from rest_framework.test import APIClient, APITestCase from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangolib.testing.utils import skip_unless_cms -from .test_api import MeilisearchTestMixin +from .. import api STUDIO_SEARCH_ENDPOINT_URL = "/api/content_search/v2/studio/" @skip_unless_cms -class StudioSearchViewTest(MeilisearchTestMixin, APITestCase): +@patch("openedx.core.djangoapps.content.search.api._wait_for_meili_task", new=MagicMock(return_value=None)) +@patch("openedx.core.djangoapps.content.search.api.MeilisearchClient") +class StudioSearchViewTest(APITestCase): """ General tests for the Studio search REST API. """ @@ -34,8 +38,12 @@ def setUp(self): super().setUp() self.client = APIClient() + # Clear the Meilisearch client to avoid side effects from other tests + api.clear_meilisearch_client() + + @override_settings(MEILISEARCH_ENABLED=False) - def test_studio_search_unathenticated_disabled(self): + def test_studio_search_unathenticated_disabled(self, _meilisearch_client): """ Whether or not Meilisearch is enabled, the API endpoint requires authentication. """ @@ -43,7 +51,7 @@ def test_studio_search_unathenticated_disabled(self): assert result.status_code == 401 @override_settings(MEILISEARCH_ENABLED=True) - def test_studio_search_unathenticated_enabled(self): + def test_studio_search_unathenticated_enabled(self, _meilisearch_client): """ Whether or not Meilisearch is enabled, the API endpoint requires authentication. """ @@ -51,7 +59,7 @@ def test_studio_search_unathenticated_enabled(self): assert result.status_code == 401 @override_settings(MEILISEARCH_ENABLED=False) - def test_studio_search_disabled(self): + def test_studio_search_disabled(self, _meilisearch_client): """ When Meilisearch is disabled, the Studio search endpoint gives a 404 """ @@ -60,7 +68,7 @@ def test_studio_search_disabled(self): assert result.status_code == 404 @override_settings(MEILISEARCH_ENABLED=True) - def test_studio_search_student_forbidden(self): + def test_studio_search_student_forbidden(self, _meilisearch_client): """ Until we implement fine-grained permissions, only global staff can use the Studio search endpoint. @@ -70,11 +78,12 @@ def test_studio_search_student_forbidden(self): assert result.status_code == 403 @override_settings(MEILISEARCH_ENABLED=True) - def test_studio_search_staff(self): + def test_studio_search_staff(self, _meilisearch_client): """ Global staff can get a restricted API key for Meilisearch using the REST API. """ + _meilisearch_client.return_value.generate_tenant_token.return_value = "api_key" self.client.login(username='staff', password='staff_pass') result = self.client.get(STUDIO_SEARCH_ENDPOINT_URL) assert result.status_code == 200