Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3f936dc
docs: unrelated fix to outdated comment
bradenmacdonald Apr 15, 2026
f20a26b
chore: unrelated pylint fix
bradenmacdonald Apr 16, 2026
0f39f27
feat: emit event signals when publishable entities are changed.
bradenmacdonald Apr 15, 2026
296b74c
feat: emit change log details, don't emit if txn is rolled back
bradenmacdonald Apr 15, 2026
88f3791
feat: emit a bulk change event when multiple entities are edited at once
bradenmacdonald Apr 16, 2026
dc93d73
test: update bulk test to verify user_id
bradenmacdonald Apr 16, 2026
160a649
test: verify that bulk event is not emitted on transaction rollback
bradenmacdonald Apr 16, 2026
72a9890
docs: clarify event details
bradenmacdonald Apr 16, 2026
eb7b160
feat: emit a bulk publish event when entities are published
bradenmacdonald Apr 16, 2026
09562bc
test: check that signals are correctly emitted for dependencies
bradenmacdonald Apr 16, 2026
68ebddb
chore: update with main
bradenmacdonald Apr 16, 2026
15aba54
feat: indicate when things are directly published or not
bradenmacdonald Apr 17, 2026
5e11a12
feat: events for Collections
bradenmacdonald Apr 17, 2026
7ad1e79
chore: update with main
bradenmacdonald Apr 18, 2026
f2e3eba
fix: test inconsistency on MySQL vs SQLite
bradenmacdonald Apr 20, 2026
c9ed092
fix: domain of the collections event
bradenmacdonald Apr 20, 2026
df3adc3
refactor: Collection modified -> metadata_modified in event data
bradenmacdonald Apr 21, 2026
4544647
docs: explain lack of CONTENT_OBJECT_ASSOCIATIONS_CHANGED event
bradenmacdonald Apr 21, 2026
bc66b61
feat: add Collections events to the openedx_content public API
bradenmacdonald Apr 21, 2026
9afae55
docs: note a TODO
bradenmacdonald Apr 21, 2026
fa81324
feat: emit event that entities_removed=... from collection when entit…
bradenmacdonald Apr 21, 2026
140e1b3
feat: emit event that entities_added=... from collection when entitie…
bradenmacdonald Apr 22, 2026
d370852
docs: mention transaction commit
bradenmacdonald Apr 22, 2026
f17cec5
feat: emit version IDs in events as well as version numbers
bradenmacdonald Apr 22, 2026
627c574
test: disable celery for all publishing tests, not just collections
bradenmacdonald Apr 22, 2026
7b62c3e
feat: LP created/deleted events
bradenmacdonald Apr 23, 2026
d3537e4
feat: LP updated event
bradenmacdonald Apr 23, 2026
ff15eb2
chore: update with main
bradenmacdonald Apr 23, 2026
3902b85
fix: unsorted event payload causing flaky test
bradenmacdonald Apr 23, 2026
a53166d
fix: address some feedback from review (collections)
bradenmacdonald Apr 23, 2026
e42144b
refactor: api_signals.py -> signals.py
bradenmacdonald Apr 23, 2026
26683ff
refactor: rename the new signals for more clarity
bradenmacdonald Apr 23, 2026
5ff0496
fix: clarify collection event behavior around soft/hard deletion
bradenmacdonald Apr 24, 2026
1fb4854
chore: update with main
bradenmacdonald Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/openedx_content/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
"""

# These wildcard imports are okay because these api modules declare __all__.
# pylint: disable=wildcard-import
# pylint: disable=wildcard-import,unused-import

# Signals are kept in a separate namespace, for two reasons:
# (1) so they can easily be imported/used as `api.signals` (e.g. `from openedx_content import api`, use `api.signals.x`)
# (2) to avoid confusion between event data structures and other API symbols with similar names (e.g.
# `DraftChangeLogEventData` vs `DraftChangeLogRecord` is clearer if the former is `signals.DraftChangeLogEventData`)
from . import signals
# The rest of the public API (other than models):
from .applets.backup_restore.api import *
from .applets.collections.api import *
from .applets.components.api import *
Expand Down
106 changes: 84 additions & 22 deletions src/openedx_content/applets/collections/api.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""
Collections API (warning: UNSTABLE, in progress API)
"""

from __future__ import annotations

from datetime import datetime, timezone
from functools import partial

from django.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.db.transaction import on_commit

from ..publishing import api as publishing_api
from ..publishing.models import PublishableEntity
from . import signals
from .models import Collection, CollectionPublishableEntity, LearningPackage

# The public API that will be re-exported by openedx_content.api
Expand All @@ -32,6 +36,39 @@
]


def _queue_change_event(
collection: Collection,
*,
created: bool = False,
metadata_modified: bool = False,
deleted: bool = False,
entities_added: list[PublishableEntity.ID] | None = None,
entities_removed: list[PublishableEntity.ID] | None = None,
user_id: int | None = None,
) -> None:
"""Helper for emitting the event when a collection has changed."""
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper would not be necessary if openedx/openedx-events#570 is accepted, and then we could just use that.


learning_package_id = collection.learning_package.id
learning_package_title = collection.learning_package.title

# Send out an event immediately after this database transaction commits.
on_commit(partial(
signals.COLLECTION_CHANGED.send_event,
time=collection.modified,
learning_package=signals.LearningPackageEventData(id=learning_package_id, title=learning_package_title),
changed_by=signals.UserAttributionEventData(user_id=user_id),
change=signals.CollectionChangeData(
collection_id=collection.id,
collection_code=collection.collection_code,
created=created,
metadata_modified=metadata_modified,
deleted=deleted,
entities_added=entities_added or [],
entities_removed=entities_removed or [],
),
))


def create_collection(
learning_package_id: LearningPackage.ID,
collection_code: str,
Expand All @@ -54,6 +91,8 @@ def create_collection(
)
collection.full_clean()
collection.save()
if enabled:
_queue_change_event(collection, created=True, user_id=created_by)
return collection


Expand Down Expand Up @@ -87,6 +126,7 @@ def update_collection(
collection.description = description

collection.save()
_queue_change_event(collection, metadata_modified=True)
return collection


Expand All @@ -103,12 +143,20 @@ def delete_collection(
Soft-deleted collections can be re-enabled using restore_collection.
"""
collection = get_collection(learning_package_id, collection_code)
entities_removed = list(collection.entities.order_by("id").values_list("id", flat=True))
was_already_soft_deleted = not collection.enabled

if hard_delete:
collection.modified = datetime.now(tz=timezone.utc) # For the event timestamp; won't get saved to the DB
if not was_already_soft_deleted: # Send the deleted event unless this was already soft deleted.
_queue_change_event(collection, deleted=True, entities_removed=entities_removed)
# Delete after enqueing the event:
collection.delete()
else:
elif not was_already_soft_deleted:
# Soft delete:
collection.enabled = False
collection.save()
_queue_change_event(collection, deleted=True, entities_removed=entities_removed)
return collection


Expand All @@ -120,9 +168,11 @@ def restore_collection(
Undo a "soft delete" by re-enabling a Collection.
"""
collection = get_collection(learning_package_id, collection_code)
entities_added = list(collection.entities.order_by("id").values_list("id", flat=True))

collection.enabled = True
collection.save()
_queue_change_event(collection, created=True, entities_added=entities_added)
return collection


Expand Down Expand Up @@ -152,12 +202,12 @@ def add_to_collection(
)

collection = get_collection(learning_package_id, collection_code)
collection.entities.add(
*entities_qset.all(),
through_defaults={"created_by_id": created_by},
)
existing_ids = set(collection.entities.values_list("id", flat=True))
ids_to_add = entities_qset.values_list("id", flat=True)
collection.entities.add(*ids_to_add, through_defaults={"created_by_id": created_by})
collection.modified = datetime.now(tz=timezone.utc)
collection.save()
_queue_change_event(collection, entities_added=sorted(list(set(ids_to_add) - existing_ids)), user_id=created_by)

return collection

Expand All @@ -178,9 +228,12 @@ def remove_from_collection(
"""
collection = get_collection(learning_package_id, collection_code)

collection.entities.remove(*entities_qset.all())
ids_to_remove = list(entities_qset.values_list("id", flat=True))
entities_removed = sorted(list(collection.entities.filter(id__in=ids_to_remove).values_list("id", flat=True)))
collection.entities.remove(*ids_to_remove)
collection.modified = datetime.now(tz=timezone.utc)
collection.save()
_queue_change_event(collection, entities_removed=entities_removed)

return collection

Expand Down Expand Up @@ -222,7 +275,7 @@ def get_collections(learning_package_id: LearningPackage.ID, enabled: bool | Non
qs = Collection.objects.filter(learning_package_id=learning_package_id)
if enabled is not None:
qs = qs.filter(enabled=enabled)
return qs.select_related("learning_package").order_by('pk')
return qs.select_related("learning_package").order_by("pk")


def set_collections(
Expand All @@ -245,25 +298,34 @@ def set_collections(
raise ValidationError(
"Collection entities must be from the same learning package as the collection.",
)
current_relations = CollectionPublishableEntity.objects.filter(
entity=publishable_entity
).select_related('collection')
# Clear other collections for given entity and add only new collections from collection_qset
removed_collections = set(
r.collection for r in current_relations.exclude(collection__in=collection_qset)
current_relations = CollectionPublishableEntity.objects.filter(entity=publishable_entity).select_related(
"collection"
)
new_collections = set(collection_qset.exclude(
id__in=current_relations.values_list('collection', flat=True)
))
# Clear other collections for given entity and add only new collections from collection_qset
removed_collections = set(r.collection for r in current_relations.exclude(collection__in=collection_qset))
new_collections = set(collection_qset.exclude(id__in=current_relations.values_list("collection", flat=True)))
# Triggers a m2m_changed signal
publishable_entity.collections.set(
objs=collection_qset,
through_defaults={"created_by_id": created_by},
)
# Update modified date via update to avoid triggering post_save signal for all collections, which can be very slow.
affected_collection = removed_collections | new_collections
Collection.objects.filter(
id__in=[collection.id for collection in affected_collection]
).update(modified=datetime.now(tz=timezone.utc))
# Update modified date:
affected_collections = removed_collections | new_collections
Collection.objects.filter(id__in=[collection.id for collection in affected_collections]).update(
modified=datetime.now(tz=timezone.utc)
)

return affected_collection
# Emit one event per affected collection. Re-fetch with select_related so _queue_change_event
# can read collection.learning_package without extra queries; the re-fetch also picks up the
# updated modified timestamp from the bulk update above.
removed_ids = {c.id for c in removed_collections}
for collection in Collection.objects.filter(id__in=[c.id for c in affected_collections]).select_related(
"learning_package"
):
# TODO: test performance of this and potentially send these async if > 1 affected collection.
if collection.id in removed_ids:
_queue_change_event(collection, entities_removed=[publishable_entity.id], user_id=created_by)
else:
_queue_change_event(collection, entities_added=[publishable_entity.id], user_id=created_by)

return affected_collections
46 changes: 46 additions & 0 deletions src/openedx_content/applets/collections/signal_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Signal handlers for collections-related updates."""

from functools import partial

from django.db import transaction
from django.dispatch import receiver

from ..publishing.signals import ENTITIES_DRAFT_CHANGED, DraftChangeLogEventData, UserAttributionEventData
from .tasks import emit_collections_changed_for_entity_changes_task


@receiver(ENTITIES_DRAFT_CHANGED)
def on_entities_changed(
change_log: DraftChangeLogEventData,
changed_by: UserAttributionEventData,
**kwargs,
):
"""
When entity drafts are deleted or restored, notify affected collections.

Dispatches a task to emit COLLECTION_CHANGED for any
collections that contain the changed entities.
"""
removed_entity_ids = [record.entity_id for record in change_log.changes if record.new_version_id is None]
# old_version_id=None covers both brand-new entities and restored soft-deletes; we can't distinguish
# them here without a DB query. The task is a no-op for new entities (not yet in any collection).
# TODO: if ChangeLogRecordData gains a 'restored' flag, filter to only restored entities here.
# (Newly-created entities cannot be part of collections yet, so we only care about entities that
# were previously in collections, then deleted and then restored.)
added_entity_ids = [
record.entity_id
for record in change_log.changes
if record.old_version_id is None and record.new_version_id is not None
]

if not removed_entity_ids and not added_entity_ids:
return

transaction.on_commit(
partial(
emit_collections_changed_for_entity_changes_task.delay,
removed_entity_ids=removed_entity_ids,
added_entity_ids=added_entity_ids,
user_id=changed_by.user_id,
)
)
73 changes: 73 additions & 0 deletions src/openedx_content/applets/collections/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Low-level events/signals emitted by openedx_content
"""

from attrs import define, field
from openedx_events.tooling import OpenEdxPublicSignal # type: ignore[import-untyped]

from ..publishing.models.publishable_entity import PublishableEntity
from ..publishing.signals import LearningPackageEventData, UserAttributionEventData

# Public API available via openedx_content.api
__all__ = [
# All event data structures should end with "...Data":
"CollectionChangeData",
# All events:
"COLLECTION_CHANGED",
]


@define
class CollectionChangeData:
"""Summary of changes to a collection, for event purposes"""

collection_id: int
collection_code: str
created: bool = False
"""The collection is newly-created, or un-deleted. Some entities may be added simultaneously."""
metadata_modified: bool = False
Comment thread
ormsbee marked this conversation as resolved.
"""The collection's title/description has changed. Does not indicate whether or not entities were added/removed."""
deleted: bool = False
"""
The collection has been deleted. When this is true, the entities_removed list will have all entity IDs.
Does not distinguish between "soft" and "hard" deletion.
"""
entities_added: list[PublishableEntity.ID] = field(factory=list)
entities_removed: list[PublishableEntity.ID] = field(factory=list)


COLLECTION_CHANGED = OpenEdxPublicSignal(
event_type="org.openedx.content.collections.collection_changed.v1",
data={
"learning_package": LearningPackageEventData,
"changed_by": UserAttributionEventData,
"change": CollectionChangeData,
Comment on lines +42 to +44
Copy link
Copy Markdown
Contributor Author

@bradenmacdonald bradenmacdonald Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using multiple x: XData values in the data dict per event. I think this is a much cleaner and DRYer pattern than forcing all events to have a single key-value pair in their data dictionaries, but most (all?) event definitions in openedx-events have only a single key-value pair in the dict even when it doesn't make sense, and I don't know why.

Edit: based on the additional info I posted there, it should be completely fine. But any automatic parsing of the signal documentation may have incomplete type info (or rather, slightly more incomplete than it already is, since it already lacks the dict key info for all events).

},
)
"""
A ``Collection`` has been created, modified, or deleted, or its entities have
changed.

This is a low-level batch event. It does not have any course or library context
information available. It does not distinguish between Containers, Components,
or other entity types.

💾 This event is only emitted after any transaction has been committed.

⏳ This **batch** event is emitted **synchronously**. Handlers that do anything
per-entity or that is possibly slow should dispatch an asynchronous task for
processing the event.
"""

# Note: at present, the openedx_tagging code (in this repo) emits a
# CONTENT_OBJECT_ASSOCIATIONS_CHANGED event whenever an entity's tags change.
# But we do NOT emit the same event when an entity's collections change; rather
# we expect code in the platform to listen for COLLECTION_CHANGED and then
# re-emit '...ASSOCIATIONS_CHANGED' as needed.
# The reason we don't emit the '...ASSOCIATIONS_CHANGED' event here
# is simple: we know the entity IDs but not their opaque keys, and all of the
# code that listens for that event expects the entity's opaque keys.
# The tagging code can do it here because the `object_id` in the tagging models
# _is_ the opaque key ("lb:..."), but the collections code is too low-level to
# know about opaque keys of the entities. We don't even know which learning
# context (which content library) a given entity is in.
Loading