Skip to content

Commit b5f54fa

Browse files
feat: LP created/deleted events
1 parent 627c574 commit b5f54fa

5 files changed

Lines changed: 203 additions & 3 deletions

File tree

src/openedx_content/applets/publishing/api.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ def create_learning_package(
111111
)
112112
package.full_clean()
113113
package.save()
114+
new_id = package.id
115+
116+
def send_event():
117+
signals.LEARNING_PACKAGE_CREATED.send_event(
118+
learning_package=signals.LearningPackageEventData(id=new_id, title=title),
119+
)
120+
121+
on_commit(send_event)
114122

115123
return package
116124

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""
2+
Django signal handlers for the publishing applet.
3+
"""
4+
5+
from django.db import transaction
6+
from django.db.models.signals import post_delete
7+
from django.dispatch import receiver
8+
9+
from .models.learning_package import LearningPackage
10+
from .signals import LEARNING_PACKAGE_DELETED, LearningPackageEventData
11+
12+
13+
@receiver(post_delete, sender=LearningPackage)
14+
def _emit_learning_package_deleted(sender, instance, **kwargs): # pylint: disable=unused-argument
15+
"""
16+
Emit ``LEARNING_PACKAGE_DELETED`` after a ``LearningPackage`` is deleted.
17+
18+
This fires for any deletion: single-object ``.delete()``, bulk
19+
``QuerySet.delete()`` (Django calls ``post_delete`` once per row), or
20+
deletions performed via the Django admin. There is currently no official API
21+
for deleting Learning Packages, but you can orhpan them by deleting any
22+
references to them such as ``ContentLibrary`` instances in openedx-platform.
23+
24+
The event is deferred via ``transaction.on_commit`` so that it is only
25+
emitted once the enclosing database transaction has been committed. If
26+
the transaction is rolled back, the row still exists and no event fires.
27+
28+
Note: by the time this handler runs, the ``LearningPackage`` row has
29+
already been removed from the database (Django preserves ``instance.pk``
30+
on the in-memory object, but the DB row is gone). We capture ``id`` and
31+
``title`` at handler-invocation time so that the event payload remains
32+
correct even though the underlying record is no longer retrievable.
33+
"""
34+
lp_id = instance.id
35+
lp_title = instance.title
36+
37+
def send_event():
38+
LEARNING_PACKAGE_DELETED.send_event(
39+
learning_package=LearningPackageEventData(id=lp_id, title=lp_title),
40+
)
41+
42+
transaction.on_commit(send_event)

src/openedx_content/applets/publishing/signals.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
"DraftChangeLogEventData",
1818
"PublishLogEventData",
1919
# All events:
20+
"LEARNING_PACKAGE_CREATED",
21+
"LEARNING_PACKAGE_DELETED",
2022
"LEARNING_PACKAGE_ENTITIES_CHANGED",
2123
"LEARNING_PACKAGE_ENTITIES_PUBLISHED",
2224
]
@@ -82,6 +84,59 @@ class PublishLogEventData:
8284
changes: list[ChangeLogRecordData]
8385

8486

87+
LEARNING_PACKAGE_CREATED = OpenEdxPublicSignal(
88+
event_type="org.openedx.content.publishing.lp_created.v1",
89+
data={
90+
"learning_package": LearningPackageEventData,
91+
},
92+
)
93+
"""
94+
A new ``LearningPackage`` has been created.
95+
96+
This is emitted exactly once per ``LearningPackage``, after the row is inserted
97+
in the database. This is a low-level event. It's most likely that the Learning
98+
Package is still being prepared/populated, and any necessary relationships,
99+
entities, metadata, or other data may not yet exist at the time this event is
100+
emitted.
101+
102+
💾 This event is only emitted after the enclosing database transaction has
103+
been committed. If the transaction is rolled back, no event is emitted.
104+
105+
⏳ This event is emitted synchronously.
106+
"""
107+
108+
109+
LEARNING_PACKAGE_DELETED = OpenEdxPublicSignal(
110+
event_type="org.openedx.content.publishing.lp_deleted.v1",
111+
data={
112+
"learning_package": LearningPackageEventData,
113+
},
114+
)
115+
"""
116+
A ``LearningPackage`` has been deleted.
117+
118+
This is emitted exactly once per ``LearningPackage``, after the row has been
119+
removed from the database. It is emitted regardless of how the row was deleted
120+
(via a direct ORM ``.delete()`` call, via the Django admin, or as part of a
121+
``QuerySet.delete()``), because it is fired by a Django ``post_delete`` signal
122+
on the ``LearningPackage`` model.
123+
124+
Note: at the time this event is emitted, the ``LearningPackage`` and all of
125+
its related content (entities, versions, drafts, publishes, etc.) have already
126+
been removed from the database. Handlers cannot look up the learning package
127+
by ID — they only get the ``id`` and ``title`` that are captured in the
128+
``LearningPackageEventData`` payload.
129+
130+
🗑️ Unlike other ``publishing`` events, the effects of this deletion are
131+
completely irreversible and the LearningPackage cannot be restored/un-deleted.
132+
133+
💾 This event is only emitted after the enclosing database transaction has
134+
been committed. If the transaction is rolled back, no event is emitted.
135+
136+
⏳ This event is emitted synchronously.
137+
"""
138+
139+
85140
LEARNING_PACKAGE_ENTITIES_CHANGED = OpenEdxPublicSignal(
86141
event_type="org.openedx.content.publishing.lp_entities_changed.v1",
87142
data={
@@ -108,7 +163,8 @@ class PublishLogEventData:
108163
Collections and tags are not `PublishableEntity`-based, so do not participate in
109164
this event.
110165
111-
💾 This event is only emitted after any transaction has been committed.
166+
💾 This event is only emitted after the enclosing database transaction has
167+
been committed. If the transaction is rolled back, no event is emitted.
112168
113169
⏳ This **batch** event is emitted **synchronously**. Handlers that do anything
114170
per-entity or that is possibly slow should dispatch an asynchronous task for
@@ -144,7 +200,8 @@ class PublishLogEventData:
144200
Collections and tags are not `PublishableEntity`-based, so do not participate in
145201
this event.
146202
147-
💾 This event is only emitted after any transaction has been committed.
203+
💾 This event is only emitted after the enclosing database transaction has
204+
been committed. If the transaction is rolled back, no event is emitted.
148205
149206
⏳ This **batch** event is emitted **synchronously**. Handlers that do anything
150207
per-entity or that is possibly slow should dispatch an asynchronous task for

src/openedx_content/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,4 @@ def ready(self):
5050
self.register_publishable_models()
5151
# Import signal handlers so Django registers all @receiver callbacks.
5252
from .applets.collections import signal_handlers # pylint: disable=unused-import
53+
from .applets.publishing import signal_handlers as _publishing_signal_handlers

tests/openedx_content/applets/publishing/test_signals.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99

1010
from openedx_content import api
11-
from openedx_content.models_api import PublishableEntity, PublishLog
11+
from openedx_content.models_api import LearningPackage, PublishableEntity, PublishLog
1212
from tests.utils import abort_transaction, capture_events
1313

1414
pytestmark = pytest.mark.django_db(transaction=True)
@@ -35,6 +35,98 @@ def change_record(obj: PublishableEntity, old_version: int | None, new_version:
3535
)
3636

3737

38+
# LEARNING_PACKAGE_CREATED
39+
40+
41+
def test_learning_package_created() -> None:
42+
"""
43+
Test that LEARNING_PACKAGE_CREATED is emitted when a new ``LearningPackage``
44+
is created.
45+
"""
46+
with capture_events(signals=[api.signals.LEARNING_PACKAGE_CREATED], expected_count=1) as captured:
47+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
48+
49+
event = captured[0]
50+
assert event.signal is api.signals.LEARNING_PACKAGE_CREATED
51+
assert event.kwargs["learning_package"].id == learning_package.id
52+
assert event.kwargs["learning_package"].title == "Test LP 📦"
53+
54+
55+
def test_learning_package_created_not_emitted_on_update() -> None:
56+
"""
57+
Test that updating an existing ``LearningPackage`` does NOT emit
58+
LEARNING_PACKAGE_CREATED. The event is only for new rows.
59+
"""
60+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
61+
62+
with capture_events(signals=[api.signals.LEARNING_PACKAGE_CREATED], expected_count=0):
63+
api.update_learning_package(learning_package.id, title="Updated Title")
64+
65+
66+
def test_learning_package_created_aborted() -> None:
67+
"""
68+
Test that LEARNING_PACKAGE_CREATED is NOT emitted when the transaction
69+
that created the ``LearningPackage`` is rolled back.
70+
"""
71+
with capture_events(signals=[api.signals.LEARNING_PACKAGE_CREATED], expected_count=0):
72+
with abort_transaction():
73+
api.create_learning_package(key="lp1", title="Test LP 📦")
74+
75+
76+
# LEARNING_PACKAGE_DELETED
77+
78+
79+
def test_learning_package_deleted() -> None:
80+
"""
81+
Test that LEARNING_PACKAGE_DELETED is emitted when a ``LearningPackage``
82+
is deleted.
83+
"""
84+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
85+
lp_id = learning_package.id
86+
87+
with capture_events(signals=[api.signals.LEARNING_PACKAGE_DELETED], expected_count=1) as captured:
88+
learning_package.delete()
89+
90+
event = captured[0]
91+
assert event.signal is api.signals.LEARNING_PACKAGE_DELETED
92+
assert event.kwargs["learning_package"].id == lp_id
93+
assert event.kwargs["learning_package"].title == "Test LP 📦"
94+
95+
96+
def test_learning_package_deleted_via_queryset() -> None:
97+
"""
98+
Test that LEARNING_PACKAGE_DELETED fires once per row when multiple
99+
``LearningPackage`` instances are deleted via a ``QuerySet.delete()``.
100+
"""
101+
lp1 = api.create_learning_package(key="lp1", title="LP 1")
102+
lp2 = api.create_learning_package(key="lp2", title="LP 2")
103+
104+
with capture_events(signals=[api.signals.LEARNING_PACKAGE_DELETED], expected_count=2) as captured:
105+
LearningPackage.objects.filter(id__in=[lp1.id, lp2.id]).delete()
106+
107+
deleted_ids = {event.kwargs["learning_package"].id for event in captured}
108+
assert deleted_ids == {lp1.id, lp2.id}
109+
110+
111+
def test_learning_package_deleted_aborted() -> None:
112+
"""
113+
Test that LEARNING_PACKAGE_DELETED is NOT emitted when the transaction
114+
that would have deleted the ``LearningPackage`` is rolled back.
115+
"""
116+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
117+
lp_id = learning_package.id
118+
119+
with capture_events(signals=[api.signals.LEARNING_PACKAGE_DELETED], expected_count=0):
120+
with abort_transaction():
121+
learning_package.delete()
122+
123+
# Confirm it's still in the database (the row survived the rollback).
124+
# Note: we can't use ``learning_package.id`` here because Django sets
125+
# ``instance.id = None`` after ``.delete()``, even if the transaction
126+
# ultimately rolls back; that's why we captured it beforehand.
127+
assert LearningPackage.objects.filter(id=lp_id).exists()
128+
129+
38130
# LEARNING_PACKAGE_ENTITIES_CHANGED
39131

40132

0 commit comments

Comments
 (0)