Skip to content

Commit 999c392

Browse files
feat: update content libraries API to use upstream collection events
1 parent 5935899 commit 999c392

3 files changed

Lines changed: 112 additions & 157 deletions

File tree

openedx/core/djangoapps/content_libraries/signal_handlers.py

Lines changed: 49 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -4,179 +4,74 @@
44

55
import logging
66

7-
from django.db.models.signals import m2m_changed, post_delete, post_save
87
from django.dispatch import receiver
9-
from opaque_keys import OpaqueKey
10-
from opaque_keys.edx.locator import LibraryLocatorV2
11-
from openedx_content.api import get_components, get_containers
12-
from openedx_content.models_api import Collection, CollectionPublishableEntity, PublishableEntity
13-
from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData
8+
from openedx_content.api import signals as content_signals
9+
from openedx_events.content_authoring.data import LibraryCollectionData
1410
from openedx_events.content_authoring.signals import (
15-
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
1611
LIBRARY_COLLECTION_CREATED,
1712
LIBRARY_COLLECTION_DELETED,
1813
LIBRARY_COLLECTION_UPDATED,
1914
)
2015

21-
from .api import library_collection_locator, library_component_usage_key, library_container_locator
16+
from . import tasks
17+
from .api import library_collection_locator
2218
from .models import ContentLibrary
2319

2420
log = logging.getLogger(__name__)
2521

2622

27-
@receiver(post_save, sender=Collection, dispatch_uid="library_collection_saved")
28-
def library_collection_saved(sender, instance, created, **kwargs):
23+
@receiver(content_signals.LEARNING_PACKAGE_COLLECTION_CHANGED)
24+
def collection_updated(
25+
learning_package: content_signals.LearningPackageEventData,
26+
change: content_signals.CollectionChangeData,
27+
**kwargs,
28+
):
2929
"""
30-
Raises LIBRARY_COLLECTION_CREATED if the Collection is new,
31-
or LIBRARY_COLLECTION_UPDATED if updated an existing Collection.
30+
A Collection has been updated - handle that as needed.
31+
32+
We receive this low-level event from `openedx_content`, and check if it
33+
happened in a library. If so, we emit more detailed library-specific events.
34+
35+
⏳ This event is emitted synchronously and this handler is called
36+
synchronously. If a lot of entities were changed, we need to dispatch an
37+
asynchronous handler to deal with them to avoid slowdowns.
3238
"""
3339
try:
34-
library = ContentLibrary.objects.get(learning_package_id=instance.learning_package_id)
40+
library = ContentLibrary.objects.get(learning_package_id=learning_package.id)
3541
except ContentLibrary.DoesNotExist:
36-
log.error("{instance} is not associated with a content library.")
37-
return
42+
return # We don't care about non-library events.
43+
44+
collection_key = library_collection_locator(library_key=library.library_key, collection_key=change.collection_code)
45+
entities_changed = change.entities_added + change.entities_removed
3846

39-
if created:
47+
if change.created: # This is a newly-created collection, or was "un-deleted":
4048
# .. event_implemented_name: LIBRARY_COLLECTION_CREATED
4149
# .. event_type: org.openedx.content_authoring.content_library.collection.created.v1
42-
LIBRARY_COLLECTION_CREATED.send_event(
43-
library_collection=LibraryCollectionData(
44-
collection_key=library_collection_locator(
45-
library_key=library.library_key,
46-
collection_key=instance.key,
47-
),
48-
)
49-
)
50-
else:
50+
LIBRARY_COLLECTION_CREATED.send_event(library_collection=LibraryCollectionData(collection_key=collection_key))
51+
# As an example of what this event triggers, Collections are listed in the Meilisearch index as items in the
52+
# library. So the handler will add this Collection as an entry in the Meilisearch index.
53+
elif change.metadata_modified or entities_changed:
54+
# The collection was renamed or its items were changed.
55+
# This event is ambiguous but because the search index of the colleciton itself may have something like
56+
# "contains 15 items", we _do_ need to emit it even when only the items have changed and not the metadata.
5157
# .. event_implemented_name: LIBRARY_COLLECTION_UPDATED
5258
# .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1
53-
LIBRARY_COLLECTION_UPDATED.send_event(
54-
library_collection=LibraryCollectionData(
55-
collection_key=library_collection_locator(
56-
library_key=library.library_key,
57-
collection_key=instance.key,
58-
),
59-
)
60-
)
61-
62-
63-
@receiver(post_delete, sender=Collection, dispatch_uid="library_collection_deleted")
64-
def library_collection_deleted(sender, instance, **kwargs):
65-
"""
66-
Raises LIBRARY_COLLECTION_DELETED for the deleted Collection.
67-
"""
68-
try:
69-
library = ContentLibrary.objects.get(learning_package_id=instance.learning_package_id)
70-
except ContentLibrary.DoesNotExist:
71-
log.error("{instance} is not associated with a content library.")
72-
return
73-
74-
# .. event_implemented_name: LIBRARY_COLLECTION_DELETED
75-
# .. event_type: org.openedx.content_authoring.content_library.collection.deleted.v1
76-
LIBRARY_COLLECTION_DELETED.send_event(
77-
library_collection=LibraryCollectionData(
78-
collection_key=library_collection_locator(
79-
library_key=library.library_key,
80-
collection_key=instance.key,
81-
),
82-
)
83-
)
84-
85-
86-
def _library_collection_entity_changed(
87-
publishable_entity: PublishableEntity,
88-
library_key: LibraryLocatorV2 | None = None,
89-
) -> None:
90-
"""
91-
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for the entity.
92-
"""
93-
if not library_key:
94-
try:
95-
library = ContentLibrary.objects.get(
96-
learning_package_id=publishable_entity.learning_package_id,
97-
)
98-
except ContentLibrary.DoesNotExist:
99-
log.error("{publishable_entity} is not associated with a content library.")
100-
return
101-
102-
library_key = library.library_key
103-
104-
assert library_key
105-
106-
opaque_key: OpaqueKey
107-
108-
if hasattr(publishable_entity, 'component'):
109-
opaque_key = library_component_usage_key(
110-
library_key,
111-
publishable_entity.component,
59+
LIBRARY_COLLECTION_UPDATED.send_event(library_collection=LibraryCollectionData(collection_key=collection_key))
60+
elif change.deleted:
61+
# .. event_implemented_name: LIBRARY_COLLECTION_DELETED
62+
# .. event_type: org.openedx.content_authoring.content_library.collection.deleted.v1
63+
LIBRARY_COLLECTION_DELETED.send_event(library_collection=LibraryCollectionData(collection_key=collection_key))
64+
65+
# Now, what about the actual entities (containers/components) in the collection?
66+
if entities_changed:
67+
if len(entities_changed) == 1:
68+
# If there's only one changed entity, emit the event synchronously:
69+
fn = tasks.send_collections_changed_events
70+
else:
71+
# If there are more than one changed entities, emit the events asynchronously:
72+
fn = tasks.send_collections_changed_events.delay
73+
fn(
74+
publishable_entity_ids=entities_changed,
75+
learning_package_id=learning_package.id,
76+
library_key_str=str(library.library_key),
11277
)
113-
elif hasattr(publishable_entity, 'container'):
114-
opaque_key = library_container_locator(
115-
library_key,
116-
publishable_entity.container,
117-
)
118-
else:
119-
log.error("Unknown publishable entity type: %s", publishable_entity)
120-
return
121-
122-
# .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED
123-
# .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1
124-
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
125-
content_object=ContentObjectChangedData(
126-
object_id=str(opaque_key),
127-
changes=["collections"],
128-
),
129-
)
130-
131-
132-
@receiver(post_save, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_saved")
133-
def library_collection_entity_saved(sender, instance, created, **kwargs):
134-
"""
135-
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added to a collection.
136-
"""
137-
if created:
138-
_library_collection_entity_changed(instance.entity)
139-
140-
141-
@receiver(post_delete, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_deleted")
142-
def library_collection_entity_deleted(sender, instance, **kwargs):
143-
"""
144-
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components removed from a collection.
145-
"""
146-
# Only trigger component updates if CollectionPublishableEntity was cascade deleted due to deletion of a collection.
147-
if isinstance(kwargs.get('origin'), Collection):
148-
_library_collection_entity_changed(instance.entity)
149-
150-
151-
@receiver(m2m_changed, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entities_changed")
152-
def library_collection_entities_changed(sender, instance, action, pk_set, **kwargs):
153-
"""
154-
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added/removed/cleared from a collection.
155-
"""
156-
if action not in ["post_add", "post_remove", "post_clear"]:
157-
return
158-
159-
try:
160-
library = ContentLibrary.objects.get(
161-
learning_package_id=instance.learning_package_id,
162-
)
163-
except ContentLibrary.DoesNotExist:
164-
log.error("{instance} is not associated with a content library.")
165-
return
166-
167-
if isinstance(instance, PublishableEntity):
168-
_library_collection_entity_changed(instance, library.library_key)
169-
return
170-
171-
# When action=="post_clear", pk_set==None
172-
# Since the collection instance now has an empty entities set,
173-
# we don't know which ones were removed, so we need to update associations for all library
174-
# components and containers.
175-
components = get_components(instance.learning_package_id)
176-
containers = get_containers(instance.learning_package_id)
177-
if pk_set:
178-
components = components.filter(pk__in=pk_set)
179-
containers = containers.filter(pk__in=pk_set)
180-
181-
for entity in list(components.all()) + list(containers.all()):
182-
_library_collection_entity_changed(entity.publishable_entity, library.library_key)

openedx/core/djangoapps/content_libraries/tasks.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
A longer-term solution to this issue would be to move the content_libraries app to cms:
1515
https://github.com/openedx/edx-platform/issues/33428
1616
"""
17+
1718
from __future__ import annotations
1819

1920
import json
@@ -38,6 +39,7 @@
3839
set_code_owner_attribute_from_module,
3940
set_custom_attribute,
4041
)
42+
from opaque_keys import OpaqueKey
4143
from opaque_keys.edx.locator import (
4244
BlockUsageLocator,
4345
LibraryCollectionLocator,
@@ -46,9 +48,15 @@
4648
)
4749
from openedx_content import api as content_api
4850
from openedx_content.api import create_zip_file as create_lib_zip_file
49-
from openedx_content.models_api import DraftChangeLog, PublishLog
50-
from openedx_events.content_authoring.data import LibraryBlockData, LibraryCollectionData, LibraryContainerData
51+
from openedx_content.models_api import DraftChangeLog, LearningPackage, PublishableEntity, PublishLog
52+
from openedx_events.content_authoring.data import (
53+
ContentObjectChangedData,
54+
LibraryBlockData,
55+
LibraryCollectionData,
56+
LibraryContainerData,
57+
)
5158
from openedx_events.content_authoring.signals import (
59+
CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
5260
LIBRARY_BLOCK_CREATED,
5361
LIBRARY_BLOCK_DELETED,
5462
LIBRARY_BLOCK_PUBLISHED,
@@ -83,6 +91,48 @@
8391
DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ' # Should match serializer format. Redefined to avoid circular import.
8492

8593

94+
@shared_task(base=LoggedTask)
95+
@set_code_owner_attribute
96+
def send_collections_changed_events(
97+
publishable_entity_ids: list[PublishableEntity.ID],
98+
learning_package_id: LearningPackage.ID,
99+
library_key_str: str,
100+
):
101+
"""
102+
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for each modified library
103+
entity in the given list, because their associated collections have changed.
104+
105+
⏳ This task is designed to be run asynchronously so it can handle many
106+
entities, but you can also call it synchronously if you are only
107+
processing a single entity. Handlers should be synchronous and fast, to
108+
support the "update one item synchronously" use case, but can be async if
109+
needed.
110+
"""
111+
library_key = LibraryLocatorV2.from_string(library_key_str)
112+
entities = (
113+
content_api.get_publishable_entities(learning_package_id)
114+
.filter(id__in=publishable_entity_ids)
115+
.select_related("component", "container")
116+
)
117+
118+
for entity in entities:
119+
opaque_key: OpaqueKey
120+
121+
if hasattr(entity, "component"):
122+
opaque_key = api.library_component_usage_key(library_key, entity.component)
123+
elif hasattr(entity, "container"):
124+
opaque_key = api.library_container_locator(library_key, entity.container)
125+
else:
126+
log.error("Unknown publishable entity type: %s", entity)
127+
continue
128+
129+
# .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED
130+
# .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1
131+
CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event(
132+
content_object=ContentObjectChangedData(object_id=str(opaque_key), changes=["collections"]),
133+
)
134+
135+
86136
@shared_task(base=LoggedTask)
87137
@set_code_owner_attribute
88138
def send_events_after_publish(publish_log_pk: int, library_key_str: str) -> None:

openedx/core/djangoapps/content_libraries/tests/test_api.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
LIBRARY_CONTAINER_DELETED,
3131
LIBRARY_CONTAINER_UPDATED,
3232
)
33+
from openedx_events.testing import EventsIsolationMixin
34+
from openedx_events.tooling import OpenEdxPublicSignal
3335
from user_tasks.models import UserTaskStatus
3436

3537
from common.djangoapps.student.tests.factories import UserFactory
@@ -39,13 +41,21 @@
3941
from .base import ContentLibrariesRestApiTest
4042

4143

42-
class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest):
44+
class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, EventsIsolationMixin):
4345
"""
4446
Tests for Content Library API collections methods.
4547
4648
Same guidelines as ContentLibrariesTestCase.
4749
"""
4850

51+
@classmethod
52+
def setUpClass(cls):
53+
"""Test setup"""
54+
super().setUpClass()
55+
# By default, errors thrown in signal handlers get suppressed. We want to see them though!
56+
# https://github.com/openedx/openedx-events/issues/569
57+
cls.allow_send_events_failure(*(s.event_type for s in OpenEdxPublicSignal.all_events()))
58+
4959
def setUp(self) -> None:
5060
super().setUp()
5161

0 commit comments

Comments
 (0)