Skip to content

Commit fa6bc82

Browse files
committed
feat: collections support for containers
1 parent b955621 commit fa6bc82

File tree

21 files changed

+282
-153
lines changed

21 files changed

+282
-153
lines changed

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

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,35 @@
1919
from meilisearch.models.task import TaskInfo
2020
from opaque_keys import OpaqueKey
2121
from opaque_keys.edx.keys import UsageKey
22-
from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryCollectionLocator
22+
from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator, LibraryLocatorV2
2323
from openedx_learning.api import authoring as authoring_api
24-
from common.djangoapps.student.roles import GlobalStaff
2524
from rest_framework.request import Request
25+
2626
from common.djangoapps.student.role_helpers import get_course_roles
27+
from common.djangoapps.student.roles import GlobalStaff
2728
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
28-
from openedx.core.djangoapps.content.search.models import get_access_ids_for_request, IncrementalIndexCompleted
2929
from openedx.core.djangoapps.content.search.index_config import (
3030
INDEX_DISTINCT_ATTRIBUTE,
3131
INDEX_FILTERABLE_ATTRIBUTES,
32-
INDEX_SEARCHABLE_ATTRIBUTES,
33-
INDEX_SORTABLE_ATTRIBUTES,
3432
INDEX_RANKING_RULES,
33+
INDEX_SEARCHABLE_ATTRIBUTES,
34+
INDEX_SORTABLE_ATTRIBUTES
3535
)
36+
from openedx.core.djangoapps.content.search.models import IncrementalIndexCompleted, get_access_ids_for_request
3637
from openedx.core.djangoapps.content_libraries import api as lib_api
3738
from xmodule.modulestore.django import modulestore
3839

3940
from .documents import (
4041
Fields,
4142
meili_id_from_opaque_key,
42-
searchable_doc_for_course_block,
43+
searchable_doc_collections,
4344
searchable_doc_for_collection,
4445
searchable_doc_for_container,
46+
searchable_doc_for_course_block,
4547
searchable_doc_for_library_block,
4648
searchable_doc_for_key,
47-
searchable_doc_collections,
4849
searchable_doc_tags,
49-
searchable_doc_tags_for_collection,
50+
searchable_doc_tags_for_collection
5051
)
5152

5253
log = logging.getLogger(__name__)
@@ -488,6 +489,7 @@ def index_container_batch(batch, num_done, library_key) -> int:
488489
)
489490
doc = searchable_doc_for_container(container_key)
490491
doc.update(searchable_doc_tags(container_key))
492+
doc.update(searchable_doc_collections(container_key))
491493
docs.append(doc)
492494
except Exception as err: # pylint: disable=broad-except
493495
status_cb(f"Error indexing container {container.key}: {err}")
@@ -710,15 +712,15 @@ def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collectio
710712
If the Collection is not found or disabled (i.e. soft-deleted), then delete it from the search index.
711713
"""
712714
doc = searchable_doc_for_collection(library_key, collection_key)
713-
update_components = False
715+
update_items = False
714716

715717
# Soft-deleted/disabled collections are removed from the index
716718
# and their components updated.
717719
if doc.get('_disabled'):
718720

719721
_delete_index_doc(doc[Fields.id])
720722

721-
update_components = True
723+
update_items = True
722724

723725
# Hard-deleted collections are also deleted from the index,
724726
# but their components are automatically updated as part of the deletion process, so we don't have to.
@@ -736,10 +738,12 @@ def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collectio
736738
_update_index_docs([doc])
737739

738740
# Asynchronously update the collection's components "collections" field
739-
if update_components:
740-
from .tasks import update_library_components_collections as update_task
741+
if update_items:
742+
from .tasks import update_library_components_collections as update_components_task
743+
from .tasks import update_library_components_collections as update_containers_task
741744

742-
update_task.delay(str(library_key), collection_key)
745+
update_components_task.delay(str(library_key), collection_key)
746+
update_containers_task.delay(str(library_key), collection_key)
743747

744748

745749
def update_library_components_collections(
@@ -774,6 +778,38 @@ def update_library_components_collections(
774778
_update_index_docs(docs)
775779

776780

781+
def update_library_containers_collections(
782+
library_key: LibraryLocatorV2,
783+
collection_key: str,
784+
batch_size: int = 1000,
785+
) -> None:
786+
"""
787+
Updates the "collections" field for all containers associated with a given Library Collection.
788+
789+
Because there may be a lot of containers, we send these updates to Meilisearch in batches.
790+
"""
791+
library = lib_api.get_library(library_key)
792+
containers = authoring_api.get_collection_containers(library.learning_package_id, collection_key)
793+
794+
paginator = Paginator(containers, batch_size)
795+
for page in paginator.page_range:
796+
docs = []
797+
798+
for container in paginator.page(page).object_list:
799+
container_key = lib_api.library_container_locator(
800+
library_key,
801+
container,
802+
)
803+
doc = searchable_doc_collections(container_key)
804+
docs.append(doc)
805+
806+
log.info(
807+
f"Updating document.collections for library {library_key} containers"
808+
f" page {page} / {paginator.num_pages}"
809+
)
810+
_update_index_docs(docs)
811+
812+
777813
def upsert_library_container_index_doc(container_key: LibraryContainerLocator) -> None:
778814
"""
779815
Creates, updates, or deletes the document for the given Library Container in the search index.
@@ -820,12 +856,12 @@ def upsert_content_object_tags_index_doc(key: OpaqueKey):
820856
_update_index_docs([doc])
821857

822858

823-
def upsert_block_collections_index_docs(usage_key: UsageKey):
859+
def upsert_item_collections_index_docs(opaque_key: OpaqueKey):
824860
"""
825-
Updates the collections data in documents for the given Course/Library block
861+
Updates the collections data in documents for the given Course/Library block, or Container
826862
"""
827-
doc = {Fields.id: meili_id_from_opaque_key(usage_key)}
828-
doc.update(searchable_doc_collections(usage_key))
863+
doc = {Fields.id: meili_id_from_opaque_key(opaque_key)}
864+
doc.update(searchable_doc_collections(opaque_key))
829865
_update_index_docs([doc])
830866

831867

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

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

312312

313-
def _collections_for_content_object(object_id: UsageKey | LearningContextKey) -> dict:
313+
def _collections_for_content_object(object_id: OpaqueKey) -> dict:
314314
"""
315315
Given an XBlock, course, library, etc., get the collections for its index doc.
316316
@@ -341,11 +341,21 @@ def _collections_for_content_object(object_id: UsageKey | LearningContextKey) ->
341341
# Gather the collections associated with this object
342342
collections = None
343343
try:
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-
)
344+
if isinstance(object_id, UsageKey):
345+
component = lib_api.get_component_from_usage_key(object_id)
346+
collections = authoring_api.get_entity_collections(
347+
component.learning_package_id,
348+
component.key,
349+
)
350+
elif isinstance(object_id, LibraryContainerLocator):
351+
container = lib_api.get_container_from_key(object_id)
352+
collections = authoring_api.get_entity_collections(
353+
container.publishable_entity.learning_package_id,
354+
container.key,
355+
)
356+
else:
357+
return result
358+
349359
except ObjectDoesNotExist:
350360
log.warning(f"No component found for {object_id}")
351361

@@ -439,13 +449,13 @@ def searchable_doc_tags(key: OpaqueKey) -> dict:
439449
return doc
440450

441451

442-
def searchable_doc_collections(usage_key: UsageKey) -> dict:
452+
def searchable_doc_collections(opaque_key: OpaqueKey) -> dict:
443453
"""
444454
Generate a dictionary document suitable for ingestion into a search engine
445455
like Meilisearch or Elasticsearch, with the collections data for the given content object.
446456
"""
447-
doc = searchable_doc_for_key(usage_key)
448-
doc.update(_collections_for_content_object(usage_key))
457+
doc = searchable_doc_for_key(opaque_key)
458+
doc.update(_collections_for_content_object(opaque_key))
449459

450460
return doc
451461

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,
@@ -212,15 +212,15 @@ def content_object_associations_changed_handler(**kwargs) -> None:
212212

213213
try:
214214
# Check if valid course or library block
215-
usage_key = UsageKey.from_string(str(content_object.object_id))
215+
opaque_key = UsageKey.from_string(str(content_object.object_id))
216216
except InvalidKeyError:
217217
try:
218218
# Check if valid library collection
219-
usage_key = LibraryCollectionLocator.from_string(str(content_object.object_id))
219+
opaque_key = LibraryCollectionLocator.from_string(str(content_object.object_id))
220220
except InvalidKeyError:
221221
try:
222222
# Check if valid library container
223-
usage_key = LibraryContainerLocator.from_string(str(content_object.object_id))
223+
opaque_key = LibraryContainerLocator.from_string(str(content_object.object_id))
224224
except InvalidKeyError:
225225
# Invalid content object id
226226
log.error("Received invalid content object id")
@@ -229,12 +229,12 @@ def content_object_associations_changed_handler(**kwargs) -> None:
229229
# This event's changes may contain both "tags" and "collections", but this will happen rarely, if ever.
230230
# So we allow a potential double "upsert" here.
231231
if not content_object.changes or "tags" in content_object.changes:
232-
if isinstance(usage_key, LibraryCollectionLocator):
233-
upsert_collection_tags_index_docs(usage_key)
232+
if isinstance(opaque_key, LibraryCollectionLocator):
233+
upsert_collection_tags_index_docs(opaque_key)
234234
else:
235-
upsert_content_object_tags_index_doc(usage_key)
235+
upsert_content_object_tags_index_doc(opaque_key)
236236
if not content_object.changes or "collections" in content_object.changes:
237-
upsert_block_collections_index_docs(usage_key)
237+
upsert_item_collections_index_docs(opaque_key)
238238

239239

240240
@receiver(LIBRARY_CONTAINER_CREATED)

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ def update_library_components_collections(library_key_str: str, collection_key:
112112
api.update_library_components_collections(library_key, collection_key)
113113

114114

115+
@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError))
116+
@set_code_owner_attribute
117+
def update_library_containers_collections(library_key_str: str, collection_key: str) -> None:
118+
"""
119+
Celery task to update the "collections" field for containers in the given content library collection.
120+
"""
121+
library_key = LibraryLocatorV2.from_string(library_key_str)
122+
123+
log.info("Updating document.collections for library %s collection %s containers", library_key, collection_key)
124+
125+
api.update_library_containers_collections(library_key, collection_key)
126+
127+
115128
@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError))
116129
@set_code_owner_attribute
117130
def update_library_container_index_doc(library_key_str: str, container_key_str: str) -> None:

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,7 @@ def test_index_library_block_and_collections(self, mock_meilisearch):
581581
description="Second Collection",
582582
)
583583

584-
# Add Problem1 to both Collections (these internally call `upsert_block_collections_index_docs` and
584+
# Add Problem1 to both Collections (these internally call `upsert_item_collections_index_docs` and
585585
# `upsert_library_collection_index_doc`)
586586
# (adding in reverse order to test sorting of collection tag)
587587
updated_date = datetime(2023, 6, 7, 8, 9, 10, tzinfo=timezone.utc)

openedx/core/djangoapps/content_libraries/api/blocks.py

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,66 +3,51 @@
33
44
These methods don't enforce permissions (only the REST APIs do).
55
"""
6-
from dataclasses import dataclass
7-
from datetime import datetime, timezone
86
import logging
97
import mimetypes
8+
from dataclasses import dataclass
9+
from datetime import datetime, timezone
1010

1111
from django.conf import settings
1212
from django.core.exceptions import ObjectDoesNotExist
1313
from django.core.validators import validate_unicode_slug
14-
from django.db.models import QuerySet
1514
from django.db import transaction
16-
from django.utils.translation import gettext as _
15+
from django.db.models import QuerySet
1716
from django.urls import reverse
17+
from django.utils.translation import gettext as _
1818
from lxml import etree
19-
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
2019
from opaque_keys.edx.keys import UsageKeyV2
20+
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
2121
from openedx_events.content_authoring.data import (
22+
ContentObjectChangedData,
2223
LibraryBlockData,
2324
LibraryCollectionData,
24-
LibraryContainerData,
25-
ContentObjectChangedData,
25+
LibraryContainerData
2626
)
2727
from openedx_events.content_authoring.signals import (
28+
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
2829
LIBRARY_BLOCK_CREATED,
2930
LIBRARY_BLOCK_DELETED,
3031
LIBRARY_BLOCK_UPDATED,
3132
LIBRARY_COLLECTION_UPDATED,
32-
LIBRARY_CONTAINER_UPDATED,
33-
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
33+
LIBRARY_CONTAINER_UPDATED
3434
)
35-
from xblock.core import XBlock
36-
3735
from openedx_learning.api import authoring as authoring_api
38-
from openedx_learning.api.authoring_models import (
39-
Component,
40-
ComponentVersion,
41-
LearningPackage,
42-
MediaType,
43-
)
36+
from openedx_learning.api.authoring_models import Component, ComponentVersion, LearningPackage, MediaType
37+
from xblock.core import XBlock
4438

39+
from openedx.core.djangoapps.content_libraries import api as lib_api
4540
from openedx.core.djangoapps.xblock.api import (
4641
get_component_from_usage_key,
4742
get_xblock_app_config,
48-
xblock_type_display_name,
43+
xblock_type_display_name
4944
)
5045
from openedx.core.types import User as UserType
51-
from openedx.core.djangoapps.content_libraries import api as lib_api
5246

5347
from ..models import ContentLibrary
5448
from ..permissions import CAN_EDIT_THIS_CONTENT_LIBRARY
55-
from .exceptions import (
56-
BlockLimitReachedError,
57-
ContentLibraryBlockNotFound,
58-
InvalidNameError,
59-
LibraryBlockAlreadyExists,
60-
)
61-
from .libraries import (
62-
library_component_usage_key,
63-
require_permission_for_library_key,
64-
PublishableItem,
65-
)
49+
from .exceptions import BlockLimitReachedError, ContentLibraryBlockNotFound, InvalidNameError, LibraryBlockAlreadyExists
50+
from .libraries import PublishableItem, library_component_usage_key, require_permission_for_library_key
6651

6752
log = logging.getLogger(__name__)
6853

0 commit comments

Comments
 (0)