Skip to content

Commit 44ff4e5

Browse files
feat: improve/correct handling of events and collections (#593)
* fix: don't emit COLLECTION_CHANGED when entities restored into deleted collections * test: add two tests for problematic collections events * feat: distinguish between 'restored' and 'created' entities in entities changed event (Fixes a bug with duplicate events being emitted for collection changes.) * fix: don't allow adding entities to a deleted collection (likely mistake) * test: add an even more difficult edge case * feat!: COLLECTION_CHANGED ignores changes to entity draft state, like soft deletes * build: version bump to 1.1.0 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62c6979 commit 44ff4e5

14 files changed

Lines changed: 156 additions & 194 deletions

File tree

src/openedx_content/applets/collections/api.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ def add_to_collection(
202202
)
203203

204204
collection = get_collection(learning_package_id, collection_code)
205+
if not collection.enabled:
206+
raise ValidationError(
207+
"Cannot add entities to a disabled (soft deleted) collection "
208+
f"(collection {collection_code} in learning package {learning_package_id})."
209+
)
205210
existing_ids = set(collection.entities.values_list("id", flat=True))
206211
ids_to_add = entities_qset.values_list("id", flat=True)
207212
collection.entities.add(*ids_to_add, through_defaults={"created_by_id": created_by})

src/openedx_content/applets/collections/signal_handlers.py

Lines changed: 0 additions & 46 deletions
This file was deleted.

src/openedx_content/applets/collections/signals.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ class CollectionChangeData:
5252
information available. It does not distinguish between Containers, Components,
5353
or other entity types.
5454
55+
⚠️ Collections do NOT participate in draft-publish nor versioning. If an entity
56+
is added to a collection and then its draft is soft deleted, no
57+
``COLLECTION_CHANGED`` event will fire, as the entity is still associated with
58+
the collection regardless of whether its draft or published versions exist. If
59+
your app cares about this case, you'll also need to subscribe to the
60+
``ENTITIES_DRAFT_CHANGED`` event.
61+
5562
💾 This event is only emitted after any transaction has been committed.
5663
5764
⏳ This **batch** event is emitted **synchronously**. Handlers that do anything

src/openedx_content/applets/collections/tasks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ def emit_collections_changed_for_entity_changes_task(
3131
new_version_id is not None) that aren't in any collection result in a no-op.
3232
"""
3333
all_entity_ids: list[PublishableEntity.ID] = [
34-
PublishableEntity.PublishableEntityID(x)
35-
for x in set(removed_entity_ids) | set(added_entity_ids)
34+
PublishableEntity.PublishableEntityID(x) for x in set(removed_entity_ids) | set(added_entity_ids)
3635
]
3736
if not all_entity_ids:
3837
return 0
3938

4039
affected_cpes = (
4140
CollectionPublishableEntity.objects.filter(entity_id__in=all_entity_ids)
41+
.filter(collection__enabled=True) # Don't send events for soft-deleted collections
4242
.select_related("collection__learning_package")
4343
.order_by("collection_id", "entity_id")
4444
)

src/openedx_content/applets/publishing/api.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,27 @@ def _emit_event_for_change_log(
12681268

12691269
learning_package_id = change_log.learning_package.id
12701270
learning_package_title = change_log.learning_package.title
1271+
records: list[DraftChangeLogRecord | PublishLogRecord] = list(
1272+
change_log.records.order_by("id").select_related("old_version", "new_version").all()
1273+
)
1274+
1275+
# For draft change logs, distinguish "restored" entities (un-soft-delete) from brand-new entities.
1276+
# Both have old_version_id=None, but a brand-new entity has no DraftChangeLogRecord in any prior
1277+
# change log, while a restored entity does (its creation, soft-delete, and possibly more).
1278+
restored_entity_ids: set[int] = set()
1279+
if isinstance(change_log, DraftChangeLog):
1280+
candidate_entity_ids = [
1281+
r.entity_id for r in records if r.old_version_id is None and r.new_version_id is not None
1282+
]
1283+
if candidate_entity_ids:
1284+
restored_entity_ids = set(
1285+
DraftChangeLogRecord.objects
1286+
.filter(entity_id__in=candidate_entity_ids)
1287+
.exclude(draft_change_log_id=change_log.id)
1288+
.values_list("entity_id", flat=True)
1289+
.distinct()
1290+
)
1291+
12711292
changes = [
12721293
signals.ChangeLogRecordData(
12731294
entity_id=record.entity_id,
@@ -1276,8 +1297,9 @@ def _emit_event_for_change_log(
12761297
new_version=record.new_version.version_num if record.new_version else None,
12771298
new_version_id=record.new_version_id,
12781299
direct=record.direct if isinstance(record, PublishLogRecord) else None,
1300+
restored=record.entity_id in restored_entity_ids,
12791301
)
1280-
for record in change_log.records.order_by("id").select_related("old_version", "new_version").all()
1302+
for record in records
12811303
]
12821304

12831305
change_log_data: signals.DraftChangeLogEventData | signals.PublishLogEventData

src/openedx_content/applets/publishing/signals.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ class ChangeLogRecordData:
6868
(if applicable/known)
6969
"""
7070

71+
restored: bool = False
72+
"""
73+
True iff this is a draft change that goes from ``old_version=None`` to a non-None ``new_version`` for an
74+
entity that already existed in some prior state (i.e. an un-soft-delete). False for brand-new entities
75+
that have never had a Draft before, and for any change where ``old_version`` is not None.
76+
77+
Only populated for ``ENTITIES_DRAFT_CHANGED``; always False for ``ENTITIES_PUBLISHED``.
78+
"""
79+
7180

7281
@define
7382
class DraftChangeLogEventData:

src/openedx_content/apps.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,4 @@ def ready(self):
4949
"""
5050
self.register_publishable_models()
5151
# Import signal handlers so Django registers all @receiver callbacks.
52-
from .applets.collections import signal_handlers # pylint: disable=unused-import
5352
from .applets.publishing import signal_handlers as _publishing_signal_handlers

src/openedx_core/__init__.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,4 @@
66
"""
77

88
# The version for the entire repository
9-
# DEVELOPERS PLEASE NOTE:
10-
# - 1.0.x is reserved for `verawood-backports`
11-
# - Use 1.1.0 (if non-breaking) or 2.0.0 (if breaking) as the next `main` release,
12-
# and then delete this comment.
13-
__version__ = "1.0.1"
9+
__version__ = "1.1.0"

tests/openedx_content/applets/collections/test_api.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,6 @@ def setUpTestData(cls) -> None:
320320
cls.draft_unit.id,
321321
]),
322322
)
323-
cls.disabled_collection = api.add_to_collection(
324-
cls.learning_package.id,
325-
collection_code=cls.disabled_collection.collection_code,
326-
entities_qset=PublishableEntity.objects.filter(id__in=[
327-
cls.published_component.id,
328-
]),
329-
)
330323

331324

332325
class CollectionAddRemoveEntitiesTestCase(CollectionEntitiesTestCase):
@@ -410,6 +403,30 @@ def test_add_to_collection_wrong_learning_package(self):
410403

411404
assert not list(self.another_library_collection.entities.all())
412405

406+
def test_add_to_soft_deleted_collection(self):
407+
"""
408+
We cannot add entities to a soft-deleted (disabled) collection.
409+
"""
410+
api.delete_collection(
411+
self.learning_package.id,
412+
collection_code=self.collection1.collection_code,
413+
)
414+
415+
with self.assertRaises(ValidationError):
416+
api.add_to_collection(
417+
self.learning_package.id,
418+
self.collection1.collection_code,
419+
PublishableEntity.objects.filter(id__in=[
420+
self.draft_component.id,
421+
]),
422+
created_by=self.user.id,
423+
)
424+
425+
# The collection's entities are unchanged.
426+
assert list(self.collection1.entities.all()) == [
427+
self.published_component.publishable_entity,
428+
]
429+
413430
def test_remove_from_collection(self):
414431
"""
415432
Test removing entities from a collection.

0 commit comments

Comments
 (0)