fix: keep library collection card component count in sync (#35734)

Fixed component counter synchronization in these cases:

* When deleting a component inside a collection.
* With the library published, when adding a new component in a collection and reverting library changes.
* With the library published, when deleting a component inside a collection and reverting library changes.

Also adds a published > num_counts field in collections in the search index.
This commit is contained in:
Chris Chávez
2024-11-19 15:45:34 -05:00
committed by GitHub
parent 49330a222a
commit d5850c812c
10 changed files with 276 additions and 8 deletions

View File

@@ -80,6 +80,7 @@ class Fields:
published = "published"
published_display_name = "display_name"
published_description = "description"
published_num_children = "num_children"
# Note: new fields or values can be added at any time, but if they need to be indexed for filtering or keyword
# search, the index configuration will need to be changed, which is only done as part of the 'reindex_studio'
@@ -488,6 +489,15 @@ def searchable_doc_for_collection(
if collection:
assert collection.key == collection_key
draft_num_children = authoring_api.filter_publishable_entities(
collection.entities,
has_draft=True,
).count()
published_num_children = authoring_api.filter_publishable_entities(
collection.entities,
has_published=True,
).count()
doc.update({
Fields.context_key: str(library_key),
Fields.org: str(library_key.org),
@@ -498,7 +508,10 @@ def searchable_doc_for_collection(
Fields.description: collection.description,
Fields.created: collection.created.timestamp(),
Fields.modified: collection.modified.timestamp(),
Fields.num_children: collection.entities.count(),
Fields.num_children: draft_num_children,
Fields.published: {
Fields.published_num_children: published_num_children,
},
Fields.access_id: _meili_access_id_from_context_key(library_key),
Fields.breadcrumbs: [{"display_name": collection.learning_package.title}],
})

View File

@@ -198,6 +198,9 @@ class TestSearchApi(ModuleStoreTestCase):
"created": created_date.timestamp(),
"modified": created_date.timestamp(),
"access_id": lib_access.id,
"published": {
"num_children": 0
},
"breadcrumbs": [{"display_name": "Library"}],
}
@@ -472,6 +475,9 @@ class TestSearchApi(ModuleStoreTestCase):
"created": created_date.timestamp(),
"modified": created_date.timestamp(),
"access_id": lib_access.id,
"published": {
"num_children": 0
},
"breadcrumbs": [{"display_name": "Library"}],
}
doc_collection2_created = {
@@ -487,6 +493,9 @@ class TestSearchApi(ModuleStoreTestCase):
"created": created_date.timestamp(),
"modified": created_date.timestamp(),
"access_id": lib_access.id,
"published": {
"num_children": 0
},
"breadcrumbs": [{"display_name": "Library"}],
}
doc_collection2_updated = {
@@ -502,6 +511,9 @@ class TestSearchApi(ModuleStoreTestCase):
"created": created_date.timestamp(),
"modified": updated_date.timestamp(),
"access_id": lib_access.id,
"published": {
"num_children": 0
},
"breadcrumbs": [{"display_name": "Library"}],
}
doc_collection1_updated = {
@@ -517,6 +529,9 @@ class TestSearchApi(ModuleStoreTestCase):
"created": created_date.timestamp(),
"modified": updated_date.timestamp(),
"access_id": lib_access.id,
"published": {
"num_children": 0
},
"breadcrumbs": [{"display_name": "Library"}],
}
doc_problem_with_collection1 = {

View File

@@ -443,5 +443,37 @@ class StudioDocumentsTest(SharedModuleStoreTestCase):
'tags': {
'taxonomy': ['Difficulty'],
'level0': ['Difficulty > Normal']
},
"published": {
"num_children": 0
}
}
def test_collection_with_published_library(self):
library_api.publish_changes(self.library.key)
doc = searchable_doc_for_collection(self.library.key, self.collection.key)
doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection.key))
assert doc == {
"id": "lib-collectionedx2012_falltoy_collection-d1d907a4",
"block_id": self.collection.key,
"usage_key": self.collection_usage_key,
"type": "collection",
"org": "edX",
"display_name": "Toy Collection",
"description": "my toy collection description",
"num_children": 1,
"context_key": "lib:edX:2012_Fall",
"access_id": self.library_access_id,
"breadcrumbs": [{"display_name": "some content_library"}],
"created": 1680674828.0,
"modified": 1680674828.0,
'tags': {
'taxonomy': ['Difficulty'],
'level0': ['Difficulty > Normal']
},
"published": {
"num_children": 1
}
}

View File

@@ -83,6 +83,7 @@ from openedx_events.content_authoring.data import (
ContentLibraryData,
LibraryBlockData,
LibraryCollectionData,
ContentObjectChangedData,
)
from openedx_events.content_authoring.signals import (
CONTENT_LIBRARY_CREATED,
@@ -92,6 +93,7 @@ from openedx_events.content_authoring.signals import (
LIBRARY_BLOCK_DELETED,
LIBRARY_BLOCK_UPDATED,
LIBRARY_COLLECTION_UPDATED,
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import (
@@ -684,7 +686,11 @@ def _get_library_component_tags_count(library_key) -> dict:
return get_object_tag_counts(library_key_pattern, count_implicit=True)
def get_library_components(library_key, text_search=None, block_types=None) -> QuerySet[Component]:
def get_library_components(
library_key,
text_search=None,
block_types=None,
) -> QuerySet[Component]:
"""
Get the library components and filter.
@@ -700,6 +706,7 @@ def get_library_components(library_key, text_search=None, block_types=None) -> Q
type_names=block_types,
draft_title=text_search,
)
return components
@@ -1093,15 +1100,31 @@ def delete_library_block(usage_key, remove_from_parent=True):
Delete the specified block from this library (soft delete).
"""
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)
authoring_api.soft_delete_draft(component.pk)
LIBRARY_BLOCK_DELETED.send_event(
library_block=LibraryBlockData(
library_key=usage_key.context_key,
library_key=library_key,
usage_key=usage_key
)
)
# For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
# collection indexing asynchronously.
#
# To delete the component on collections
for collection in affected_collections:
LIBRARY_COLLECTION_UPDATED.send_event(
library_collection=LibraryCollectionData(
library_key=library_key,
collection_key=collection.key,
background=True,
)
)
def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticFile]:
"""
@@ -1318,6 +1341,39 @@ def revert_changes(library_key):
)
)
# For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
# collection indexing asynchronously.
#
# This is to update component counts in all library collections,
# because there may be components that have been discarded in the revert.
for collection in authoring_api.get_collections(learning_package.id):
LIBRARY_COLLECTION_UPDATED.send_event(
library_collection=LibraryCollectionData(
library_key=library_key,
collection_key=collection.key,
background=True,
)
)
# Reindex components that are in collections
#
# Use case: When a component that was within a collection has been deleted
# and the changes are reverted, the component should appear in the
# collection again.
components_in_collections = authoring_api.get_components(
learning_package.id, draft=True, namespace='xblock.v1',
).filter(publishable_entity__collections__isnull=False)
for component in components_in_collections:
usage_key = library_component_usage_key(library_key, component)
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
content_object=ContentObjectChangedData(
object_id=str(usage_key),
changes=["collections"],
),
)
def create_library_collection(
library_key: LibraryLocatorV2,

View File

@@ -561,3 +561,155 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
},
collection_update_event_receiver.call_args_list[1].kwargs,
)
def test_delete_library_block(self):
api.update_library_collection_components(
self.lib1.library_key,
self.col1.key,
usage_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"]),
],
)
event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(event_receiver)
api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"]))
assert event_receiver.call_count == 1
self.assertDictContainsSubset(
{
"signal": LIBRARY_COLLECTION_UPDATED,
"sender": None,
"library_collection": LibraryCollectionData(
self.lib1.library_key,
collection_key=self.col1.key,
background=True,
),
},
event_receiver.call_args_list[0].kwargs,
)
def test_add_component_and_revert(self):
# Add component and publish
api.update_library_collection_components(
self.lib1.library_key,
self.col1.key,
usage_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
],
)
api.publish_changes(self.lib1.library_key)
# Add component and revert
api.update_library_collection_components(
self.lib1.library_key,
self.col1.key,
usage_keys=[
UsageKey.from_string(self.lib1_html_block["id"]),
],
)
event_receiver = mock.Mock()
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver)
collection_update_event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver)
api.revert_changes(self.lib1.library_key)
assert collection_update_event_receiver.call_count == 1
assert event_receiver.call_count == 2
self.assertDictContainsSubset(
{
"signal": LIBRARY_COLLECTION_UPDATED,
"sender": None,
"library_collection": LibraryCollectionData(
self.lib1.library_key,
collection_key=self.col1.key,
background=True,
),
},
collection_update_event_receiver.call_args_list[0].kwargs,
)
self.assertDictContainsSubset(
{
"signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
"sender": None,
"content_object": ContentObjectChangedData(
object_id=str(self.lib1_problem_block["id"]),
changes=["collections"],
),
},
event_receiver.call_args_list[0].kwargs,
)
self.assertDictContainsSubset(
{
"signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
"sender": None,
"content_object": ContentObjectChangedData(
object_id=str(self.lib1_html_block["id"]),
changes=["collections"],
),
},
event_receiver.call_args_list[1].kwargs,
)
def test_delete_component_and_revert(self):
# Add components and publish
api.update_library_collection_components(
self.lib1.library_key,
self.col1.key,
usage_keys=[
UsageKey.from_string(self.lib1_problem_block["id"]),
UsageKey.from_string(self.lib1_html_block["id"])
],
)
api.publish_changes(self.lib1.library_key)
# Delete component and revert
api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"]))
event_receiver = mock.Mock()
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver)
collection_update_event_receiver = mock.Mock()
LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver)
api.revert_changes(self.lib1.library_key)
assert collection_update_event_receiver.call_count == 1
assert event_receiver.call_count == 2
self.assertDictContainsSubset(
{
"signal": LIBRARY_COLLECTION_UPDATED,
"sender": None,
"library_collection": LibraryCollectionData(
self.lib1.library_key,
collection_key=self.col1.key,
background=True,
),
},
collection_update_event_receiver.call_args_list[0].kwargs,
)
self.assertDictContainsSubset(
{
"signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
"sender": None,
"content_object": ContentObjectChangedData(
object_id=str(self.lib1_problem_block["id"]),
changes=["collections"],
),
},
event_receiver.call_args_list[0].kwargs,
)
self.assertDictContainsSubset(
{
"signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
"sender": None,
"content_object": ContentObjectChangedData(
object_id=str(self.lib1_html_block["id"]),
changes=["collections"],
),
},
event_receiver.call_args_list[1].kwargs,
)

View File

@@ -135,7 +135,7 @@ optimizely-sdk<5.0
# Date: 2023-09-18
# pinning this version to avoid updates while the library is being developed
# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269
openedx-learning==0.17.0
openedx-learning==0.18.0
# Date: 2023-11-29
# Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.

View File

@@ -827,7 +827,7 @@ openedx-filters==1.11.0
# -r requirements/edx/kernel.in
# lti-consumer-xblock
# ora2
openedx-learning==0.17.0
openedx-learning==0.18.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in

View File

@@ -1386,7 +1386,7 @@ openedx-filters==1.11.0
# -r requirements/edx/testing.txt
# lti-consumer-xblock
# ora2
openedx-learning==0.17.0
openedx-learning==0.18.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt

View File

@@ -997,7 +997,7 @@ openedx-filters==1.11.0
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
openedx-learning==0.17.0
openedx-learning==0.18.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt

View File

@@ -1042,7 +1042,7 @@ openedx-filters==1.11.0
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
openedx-learning==0.17.0
openedx-learning==0.18.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt