Skip to content

Commit a0d9931

Browse files
authored
feat: container support for tags [FC-0083] (#36484)
* feat: container tag support * fix: fixes from review * docs: fix typo
1 parent 1e6b40a commit a0d9931

7 files changed

Lines changed: 183 additions & 79 deletions

File tree

openedx/core/djangoapps/content/search/api.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from meilisearch import Client as MeilisearchClient
1818
from meilisearch.errors import MeilisearchApiError, MeilisearchError
1919
from meilisearch.models.task import TaskInfo
20+
from opaque_keys import OpaqueKey
2021
from opaque_keys.edx.keys import UsageKey
2122
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryCollectionLocator
2223
from openedx_learning.api import authoring as authoring_api
@@ -42,7 +43,7 @@
4243
searchable_doc_for_collection,
4344
searchable_doc_for_container,
4445
searchable_doc_for_library_block,
45-
searchable_doc_for_usage_key,
46+
searchable_doc_for_key,
4647
searchable_doc_collections,
4748
searchable_doc_tags,
4849
searchable_doc_tags_for_collection,
@@ -486,8 +487,7 @@ def index_container_batch(batch, num_done, library_key) -> int:
486487
container,
487488
)
488489
doc = searchable_doc_for_container(container_key)
489-
# TODO: when we add container tags
490-
# doc.update(searchable_doc_tags_for_container(container_key))
490+
doc.update(searchable_doc_tags(container_key))
491491
docs.append(doc)
492492
except Exception as err: # pylint: disable=broad-except
493493
status_cb(f"Error indexing container {container.key}: {err}")
@@ -511,7 +511,7 @@ def index_container_batch(batch, num_done, library_key) -> int:
511511
collections = authoring_api.get_collections(library.learning_package_id, enabled=True)
512512
num_collections = collections.count()
513513
num_collections_done = 0
514-
status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}")
514+
status_cb(f"{num_collections_done}/{num_collections}. Now indexing collections in library {lib_key}")
515515
paginator = Paginator(collections, 100)
516516
for p in paginator.page_range:
517517
num_collections_done = index_collection_batch(
@@ -620,7 +620,7 @@ def delete_index_doc(usage_key: UsageKey) -> None:
620620
Args:
621621
usage_key (UsageKey): The usage key of the XBlock to be removed from the index
622622
"""
623-
doc = searchable_doc_for_usage_key(usage_key)
623+
doc = searchable_doc_for_key(usage_key)
624624
_delete_index_doc(doc[Fields.id])
625625

626626

@@ -811,12 +811,12 @@ def upsert_content_library_index_docs(library_key: LibraryLocatorV2) -> None:
811811
_update_index_docs(docs)
812812

813813

814-
def upsert_block_tags_index_docs(usage_key: UsageKey):
814+
def upsert_content_object_tags_index_doc(key: OpaqueKey):
815815
"""
816-
Updates the tags data in documents for the given Course/Library block
816+
Updates the tags data in document for the given Course/Library item
817817
"""
818-
doc = {Fields.id: meili_id_from_opaque_key(usage_key)}
819-
doc.update(searchable_doc_tags(usage_key))
818+
doc = {Fields.id: meili_id_from_opaque_key(key)}
819+
doc.update(searchable_doc_tags(key))
820820
_update_index_docs([doc])
821821

822822

openedx/core/djangoapps/content/search/documents.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from django.core.exceptions import ObjectDoesNotExist
1010
from django.utils.text import slugify
11+
from opaque_keys import OpaqueKey
1112
from opaque_keys.edx.keys import LearningContextKey, UsageKey
1213
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2
1314
from openedx_learning.api import authoring as authoring_api
@@ -113,7 +114,7 @@ class PublishStatus:
113114
modified = "modified"
114115

115116

116-
def meili_id_from_opaque_key(usage_key: UsageKey) -> str:
117+
def meili_id_from_opaque_key(key: OpaqueKey) -> str:
117118
"""
118119
Meilisearch requires each document to have a primary key that's either an
119120
integer or a string composed of alphanumeric characters (a-z A-Z 0-9),
@@ -124,7 +125,7 @@ def meili_id_from_opaque_key(usage_key: UsageKey) -> str:
124125
we could use PublishableEntity's primary key / UUID instead.
125126
"""
126127
# The slugified key _may_ not be unique so we append a hashed string to make it unique:
127-
key_str = str(usage_key)
128+
key_str = str(key)
128129
key_bin = key_str.encode()
129130

130131
suffix = blake2b(key_bin, digest_size=4, usedforsecurity=False).hexdigest()
@@ -140,12 +141,12 @@ def _meili_access_id_from_context_key(context_key: LearningContextKey) -> int:
140141
return access.id
141142

142143

143-
def searchable_doc_for_usage_key(usage_key: UsageKey) -> dict:
144+
def searchable_doc_for_key(key: OpaqueKey) -> dict:
144145
"""
145-
Generates a base document identified by its usage key.
146+
Generates a base document identified by its opaque key.
146147
"""
147148
return {
148-
Fields.id: meili_id_from_opaque_key(usage_key),
149+
Fields.id: meili_id_from_opaque_key(key),
149150
}
150151

151152

@@ -244,7 +245,7 @@ class implementation returns only:
244245
return block_data
245246

246247

247-
def _tags_for_content_object(object_id: UsageKey | LearningContextKey) -> dict:
248+
def _tags_for_content_object(object_id: OpaqueKey) -> dict:
248249
"""
249250
Given an XBlock, course, library, etc., get the tag data for its index doc.
250251
@@ -406,7 +407,7 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad
406407
block_published = None
407408
publish_status = PublishStatus.never
408409

409-
doc = searchable_doc_for_usage_key(xblock_metadata.usage_key)
410+
doc = searchable_doc_for_key(xblock_metadata.usage_key)
410411
doc.update({
411412
Fields.type: DocType.library_block,
412413
Fields.breadcrumbs: [],
@@ -427,13 +428,13 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad
427428
return doc
428429

429430

430-
def searchable_doc_tags(usage_key: UsageKey) -> dict:
431+
def searchable_doc_tags(key: OpaqueKey) -> dict:
431432
"""
432433
Generate a dictionary document suitable for ingestion into a search engine
433434
like Meilisearch or Elasticsearch, with the tags data for the given content object.
434435
"""
435-
doc = searchable_doc_for_usage_key(usage_key)
436-
doc.update(_tags_for_content_object(usage_key))
436+
doc = searchable_doc_for_key(key)
437+
doc.update(_tags_for_content_object(key))
437438

438439
return doc
439440

@@ -443,7 +444,7 @@ def searchable_doc_collections(usage_key: UsageKey) -> dict:
443444
Generate a dictionary document suitable for ingestion into a search engine
444445
like Meilisearch or Elasticsearch, with the collections data for the given content object.
445446
"""
446-
doc = searchable_doc_for_usage_key(usage_key)
447+
doc = searchable_doc_for_key(usage_key)
447448
doc.update(_collections_for_content_object(usage_key))
448449

449450
return doc
@@ -461,7 +462,7 @@ def searchable_doc_tags_for_collection(
461462
library_key,
462463
collection_key,
463464
)
464-
doc = searchable_doc_for_usage_key(collection_usage_key)
465+
doc = searchable_doc_for_key(collection_usage_key)
465466
doc.update(_tags_for_content_object(collection_usage_key))
466467

467468
return doc
@@ -473,7 +474,7 @@ def searchable_doc_for_course_block(block) -> dict:
473474
like Meilisearch or Elasticsearch, so that the given course block can be
474475
found using faceted search.
475476
"""
476-
doc = searchable_doc_for_usage_key(block.usage_key)
477+
doc = searchable_doc_for_key(block.usage_key)
477478
doc.update({
478479
Fields.type: DocType.course_block,
479480
})
@@ -503,7 +504,7 @@ def searchable_doc_for_collection(
503504
collection_key,
504505
)
505506

506-
doc = searchable_doc_for_usage_key(collection_usage_key)
507+
doc = searchable_doc_for_key(collection_usage_key)
507508

508509
try:
509510
collection = collection or lib_api.get_library_collection_from_usage_key(collection_usage_key)

openedx/core/djangoapps/content/search/handlers.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.dispatch import receiver
99
from opaque_keys import InvalidKeyError
1010
from opaque_keys.edx.keys import UsageKey
11-
from opaque_keys.edx.locator import LibraryCollectionLocator
11+
from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator
1212
from openedx_events.content_authoring.data import (
1313
ContentLibraryData,
1414
ContentObjectChangedData,
@@ -41,7 +41,7 @@
4141
from .api import (
4242
only_if_meilisearch_enabled,
4343
upsert_block_collections_index_docs,
44-
upsert_block_tags_index_docs,
44+
upsert_content_object_tags_index_doc,
4545
upsert_collection_tags_index_docs,
4646
)
4747
from .tasks import (
@@ -211,23 +211,28 @@ def content_object_associations_changed_handler(**kwargs) -> None:
211211
return
212212

213213
try:
214-
# Check if valid if course or library block
214+
# Check if valid course or library block
215215
usage_key = UsageKey.from_string(str(content_object.object_id))
216216
except InvalidKeyError:
217217
try:
218-
# Check if valid if library collection
218+
# Check if valid library collection
219219
usage_key = LibraryCollectionLocator.from_string(str(content_object.object_id))
220220
except InvalidKeyError:
221-
log.error("Received invalid content object id")
222-
return
221+
try:
222+
# Check if valid library container
223+
usage_key = LibraryContainerLocator.from_string(str(content_object.object_id))
224+
except InvalidKeyError:
225+
# Invalid content object id
226+
log.error("Received invalid content object id")
227+
return
223228

224229
# This event's changes may contain both "tags" and "collections", but this will happen rarely, if ever.
225230
# So we allow a potential double "upsert" here.
226231
if not content_object.changes or "tags" in content_object.changes:
227232
if isinstance(usage_key, LibraryCollectionLocator):
228233
upsert_collection_tags_index_docs(usage_key)
229234
else:
230-
upsert_block_tags_index_docs(usage_key)
235+
upsert_content_object_tags_index_doc(usage_key)
231236
if not content_object.changes or "collections" in content_object.changes:
232237
upsert_block_collections_index_docs(usage_key)
233238

openedx/core/djangoapps/content/search/tests/test_api.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,6 @@ def setUp(self):
234234
"modified": created_date.timestamp(),
235235
"access_id": lib_access.id,
236236
"breadcrumbs": [{"display_name": "Library"}],
237-
# "tags" should be here but we haven't implemented them yet
238237
# "published" is not set since we haven't published it yet
239238
}
240239

@@ -262,6 +261,7 @@ def test_reindex_meilisearch(self, mock_meilisearch):
262261
doc_collection = copy.deepcopy(self.collection_dict)
263262
doc_collection["tags"] = {}
264263
doc_unit = copy.deepcopy(self.unit_dict)
264+
doc_unit["tags"] = {}
265265

266266
api.rebuild_index()
267267
assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4
@@ -292,6 +292,7 @@ def test_reindex_meilisearch_incremental(self, mock_meilisearch):
292292
doc_collection = copy.deepcopy(self.collection_dict)
293293
doc_collection["tags"] = {}
294294
doc_unit = copy.deepcopy(self.unit_dict)
295+
doc_unit["tags"] = {}
295296

296297
api.rebuild_index(incremental=True)
297298
assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4
@@ -472,8 +473,7 @@ def test_index_xblock_tags(self, mock_meilisearch):
472473
"""
473474
Test indexing an XBlock with tags.
474475
"""
475-
476-
# Tag XBlock (these internally call `upsert_block_tags_index_docs`)
476+
# Tag XBlock (these internally call `upsert_content_object_tags_index_doc`)
477477
tagging_api.tag_object(str(self.sequential.usage_key), self.taxonomyA, ["one", "two"])
478478
tagging_api.tag_object(str(self.sequential.usage_key), self.taxonomyB, ["three", "four"])
479479

@@ -866,3 +866,43 @@ def test_delete_collection(self, mock_meilisearch):
866866
mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([
867867
doc_problem_without_collection,
868868
])
869+
870+
@override_settings(MEILISEARCH_ENABLED=True)
871+
def test_index_library_container_metadata(self, mock_meilisearch):
872+
"""
873+
Test indexing a Library Container.
874+
"""
875+
api.upsert_library_container_index_doc(self.unit.container_key)
876+
877+
mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.unit_dict])
878+
879+
@override_settings(MEILISEARCH_ENABLED=True)
880+
def test_index_tags_in_containers(self, mock_meilisearch):
881+
# Tag collection
882+
tagging_api.tag_object(self.unit_key, self.taxonomyA, ["one", "two"])
883+
tagging_api.tag_object(self.unit_key, self.taxonomyB, ["three", "four"])
884+
885+
# Build expected docs with tags at each stage
886+
doc_unit_with_tags1 = {
887+
"id": "lctorg1libunitunit-1-e4527f7c",
888+
"tags": {
889+
'taxonomy': ['A'],
890+
'level0': ['A > one', 'A > two']
891+
}
892+
}
893+
doc_unit_with_tags2 = {
894+
"id": "lctorg1libunitunit-1-e4527f7c",
895+
"tags": {
896+
'taxonomy': ['A', 'B'],
897+
'level0': ['A > one', 'A > two', 'B > four', 'B > three']
898+
}
899+
}
900+
901+
assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2
902+
mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls(
903+
[
904+
call([doc_unit_with_tags1]),
905+
call([doc_unit_with_tags2]),
906+
],
907+
any_order=True,
908+
)

0 commit comments

Comments
 (0)