Skip to content

Commit 2ec615d

Browse files
rpenidoUsamaSadiq
authored andcommitted
feat: collections support for containers [FC-0083] (#36504)
Adds support for adding Containers to Collections.
1 parent e15e29e commit 2ec615d

File tree

24 files changed

+538
-223
lines changed

24 files changed

+538
-223
lines changed

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

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,39 @@
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.edx.keys import UsageKey, OpaqueKey
20+
from opaque_keys import OpaqueKey
21+
from opaque_keys.edx.keys import UsageKey
2122
from opaque_keys.edx.locator import (
2223
LibraryCollectionLocator,
2324
LibraryContainerLocator,
2425
LibraryLocatorV2,
2526
)
2627
from openedx_learning.api import authoring as authoring_api
27-
from common.djangoapps.student.roles import GlobalStaff
2828
from rest_framework.request import Request
29+
2930
from common.djangoapps.student.role_helpers import get_course_roles
31+
from common.djangoapps.student.roles import GlobalStaff
3032
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
31-
from openedx.core.djangoapps.content.search.models import get_access_ids_for_request, IncrementalIndexCompleted
3233
from openedx.core.djangoapps.content.search.index_config import (
3334
INDEX_DISTINCT_ATTRIBUTE,
3435
INDEX_FILTERABLE_ATTRIBUTES,
35-
INDEX_SEARCHABLE_ATTRIBUTES,
36-
INDEX_SORTABLE_ATTRIBUTES,
3736
INDEX_RANKING_RULES,
37+
INDEX_SEARCHABLE_ATTRIBUTES,
38+
INDEX_SORTABLE_ATTRIBUTES
3839
)
40+
from openedx.core.djangoapps.content.search.models import IncrementalIndexCompleted, get_access_ids_for_request
3941
from openedx.core.djangoapps.content_libraries import api as lib_api
4042
from xmodule.modulestore.django import modulestore
4143

4244
from .documents import (
4345
Fields,
4446
meili_id_from_opaque_key,
45-
searchable_doc_for_course_block,
47+
searchable_doc_collections,
4648
searchable_doc_for_collection,
4749
searchable_doc_for_container,
50+
searchable_doc_for_course_block,
4851
searchable_doc_for_library_block,
4952
searchable_doc_for_key,
50-
searchable_doc_collections,
5153
searchable_doc_tags,
5254
searchable_doc_tags_for_collection,
5355
)
@@ -492,6 +494,7 @@ def index_container_batch(batch, num_done, library_key) -> int:
492494
)
493495
doc = searchable_doc_for_container(container_key)
494496
doc.update(searchable_doc_tags(container_key))
497+
doc.update(searchable_doc_collections(container_key))
495498
docs.append(doc)
496499
except Exception as err: # pylint: disable=broad-except
497500
status_cb(f"Error indexing container {container.key}: {err}")
@@ -722,7 +725,7 @@ def upsert_library_collection_index_doc(collection_key: LibraryCollectionLocator
722725

723726
_delete_index_doc(doc[Fields.id])
724727

725-
update_components = True
728+
update_items = True
726729

727730
# Hard-deleted collections are also deleted from the index,
728731
# but their components are automatically updated as part of the deletion process, so we don't have to.
@@ -735,15 +738,17 @@ def upsert_library_collection_index_doc(collection_key: LibraryCollectionLocator
735738
else:
736739
already_indexed = _get_document_from_index(doc[Fields.id])
737740
if not already_indexed:
738-
update_components = True
741+
update_items = True
739742

740743
_update_index_docs([doc])
741744

742745
# Asynchronously update the collection's components "collections" field
743-
if update_components:
744-
from .tasks import update_library_components_collections as update_task
746+
if update_items:
747+
from .tasks import update_library_components_collections as update_components_task
748+
from .tasks import update_library_containers_collections as update_containers_task
745749

746-
update_task.delay(str(collection_key))
750+
update_components_task.delay(str(collection_key))
751+
update_containers_task.delay(str(collection_key))
747752

748753

749754
def update_library_components_collections(
@@ -781,6 +786,41 @@ def update_library_components_collections(
781786
_update_index_docs(docs)
782787

783788

789+
def update_library_containers_collections(
790+
collection_key: LibraryCollectionLocator,
791+
batch_size: int = 1000,
792+
) -> None:
793+
"""
794+
Updates the "collections" field for all containers associated with a given Library Collection.
795+
796+
Because there may be a lot of containers, we send these updates to Meilisearch in batches.
797+
"""
798+
library_key = collection_key.library_key
799+
library = lib_api.get_library(library_key)
800+
containers = authoring_api.get_collection_containers(
801+
library.learning_package_id,
802+
collection_key.collection_id,
803+
)
804+
805+
paginator = Paginator(containers, batch_size)
806+
for page in paginator.page_range:
807+
docs = []
808+
809+
for container in paginator.page(page).object_list:
810+
container_key = lib_api.library_container_locator(
811+
library_key,
812+
container,
813+
)
814+
doc = searchable_doc_collections(container_key)
815+
docs.append(doc)
816+
817+
log.info(
818+
f"Updating document.collections for library {library_key} containers"
819+
f" page {page} / {paginator.num_pages}"
820+
)
821+
_update_index_docs(docs)
822+
823+
784824
def upsert_library_container_index_doc(container_key: LibraryContainerLocator) -> None:
785825
"""
786826
Creates, updates, or deletes the document for the given Library Container in the search index.
@@ -827,12 +867,12 @@ def upsert_content_object_tags_index_doc(key: OpaqueKey):
827867
_update_index_docs([doc])
828868

829869

830-
def upsert_block_collections_index_docs(usage_key: UsageKey):
870+
def upsert_item_collections_index_docs(opaque_key: OpaqueKey):
831871
"""
832-
Updates the collections data in documents for the given Course/Library block
872+
Updates the collections data in documents for the given Course/Library block, or Container
833873
"""
834-
doc = {Fields.id: meili_id_from_opaque_key(usage_key)}
835-
doc.update(searchable_doc_collections(usage_key))
874+
doc = {Fields.id: meili_id_from_opaque_key(opaque_key)}
875+
doc.update(searchable_doc_collections(opaque_key))
836876
_update_index_docs([doc])
837877

838878

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

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ def _tags_for_content_object(object_id: OpaqueKey) -> dict:
309309
return {Fields.tags: result}
310310

311311

312-
def _collections_for_content_object(object_id: UsageKey | LearningContextKey) -> dict:
312+
def _collections_for_content_object(object_id: OpaqueKey) -> dict:
313313
"""
314314
Given an XBlock, course, library, etc., get the collections for its index doc.
315315
@@ -340,13 +340,23 @@ def _collections_for_content_object(object_id: UsageKey | LearningContextKey) ->
340340
# Gather the collections associated with this object
341341
collections = None
342342
try:
343-
component = lib_api.get_component_from_usage_key(object_id)
344-
collections = authoring_api.get_entity_collections(
345-
component.learning_package_id,
346-
component.key,
347-
)
343+
if isinstance(object_id, UsageKey):
344+
component = lib_api.get_component_from_usage_key(object_id)
345+
collections = authoring_api.get_entity_collections(
346+
component.learning_package_id,
347+
component.key,
348+
)
349+
elif isinstance(object_id, LibraryContainerLocator):
350+
container = lib_api.get_container_from_key(object_id)
351+
collections = authoring_api.get_entity_collections(
352+
container.publishable_entity.learning_package_id,
353+
container.key,
354+
)
355+
else:
356+
log.warning(f"Unexpected key type for {object_id}")
357+
348358
except ObjectDoesNotExist:
349-
log.warning(f"No component found for {object_id}")
359+
log.warning(f"No library item found for {object_id}")
350360

351361
if not collections:
352362
return result
@@ -438,13 +448,13 @@ def searchable_doc_tags(key: OpaqueKey) -> dict:
438448
return doc
439449

440450

441-
def searchable_doc_collections(usage_key: UsageKey) -> dict:
451+
def searchable_doc_collections(opaque_key: OpaqueKey) -> dict:
442452
"""
443453
Generate a dictionary document suitable for ingestion into a search engine
444454
like Meilisearch or Elasticsearch, with the collections data for the given content object.
445455
"""
446-
doc = searchable_doc_for_key(usage_key)
447-
doc.update(_collections_for_content_object(usage_key))
456+
doc = searchable_doc_for_key(opaque_key)
457+
doc.update(_collections_for_content_object(opaque_key))
448458

449459
return doc
450460

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040

4141
from .api import (
4242
only_if_meilisearch_enabled,
43-
upsert_block_collections_index_docs,
4443
upsert_content_object_tags_index_doc,
4544
upsert_collection_tags_index_docs,
45+
upsert_item_collections_index_docs,
4646
)
4747
from .tasks import (
4848
delete_library_block_index_doc,
@@ -211,15 +211,15 @@ def content_object_associations_changed_handler(**kwargs) -> None:
211211

212212
try:
213213
# Check if valid course or library block
214-
usage_key = UsageKey.from_string(str(content_object.object_id))
214+
opaque_key = UsageKey.from_string(str(content_object.object_id))
215215
except InvalidKeyError:
216216
try:
217217
# Check if valid library collection
218-
usage_key = LibraryCollectionLocator.from_string(str(content_object.object_id))
218+
opaque_key = LibraryCollectionLocator.from_string(str(content_object.object_id))
219219
except InvalidKeyError:
220220
try:
221221
# Check if valid library container
222-
usage_key = LibraryContainerLocator.from_string(str(content_object.object_id))
222+
opaque_key = LibraryContainerLocator.from_string(str(content_object.object_id))
223223
except InvalidKeyError:
224224
# Invalid content object id
225225
log.error("Received invalid content object id")
@@ -228,12 +228,12 @@ def content_object_associations_changed_handler(**kwargs) -> None:
228228
# This event's changes may contain both "tags" and "collections", but this will happen rarely, if ever.
229229
# So we allow a potential double "upsert" here.
230230
if not content_object.changes or "tags" in content_object.changes:
231-
if isinstance(usage_key, LibraryCollectionLocator):
232-
upsert_collection_tags_index_docs(usage_key)
231+
if isinstance(opaque_key, LibraryCollectionLocator):
232+
upsert_collection_tags_index_docs(opaque_key)
233233
else:
234-
upsert_content_object_tags_index_doc(usage_key)
234+
upsert_content_object_tags_index_doc(opaque_key)
235235
if not content_object.changes or "collections" in content_object.changes:
236-
upsert_block_collections_index_docs(usage_key)
236+
upsert_item_collections_index_docs(opaque_key)
237237

238238

239239
@receiver(LIBRARY_CONTAINER_CREATED)

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ def update_library_components_collections(collection_key_str: str) -> None:
119119
api.update_library_components_collections(collection_key)
120120

121121

122+
@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError))
123+
@set_code_owner_attribute
124+
def update_library_containers_collections(collection_key_str: str) -> None:
125+
"""
126+
Celery task to update the "collections" field for containers in the given content library collection.
127+
"""
128+
collection_key = LibraryCollectionLocator.from_string(collection_key_str)
129+
library_key = collection_key.library_key
130+
131+
log.info("Updating document.collections for library %s collection %s containers", library_key, collection_key)
132+
133+
api.update_library_containers_collections(collection_key)
134+
135+
122136
@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError))
123137
@set_code_owner_attribute
124138
def update_library_container_index_doc(container_key_str: str) -> None:

0 commit comments

Comments
 (0)