Skip to content

Commit eb7b160

Browse files
feat: emit a bulk publish event when entities are published
1 parent 72a9890 commit eb7b160

7 files changed

Lines changed: 167 additions & 19 deletions

File tree

src/openedx_content/applets/publishing/api.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,7 @@ def publish_from_drafts(
502502
published_draft_ids.add(draft.pk)
503503

504504
_create_side_effects_for_change_log(publish_log)
505+
_emit_event_for_change_log(publish_log, time_stamp=published_at, user_id=published_by)
505506

506507
return publish_log
507508

@@ -928,9 +929,10 @@ def _emit_event_for_change_log(
928929
change_log: PublishLog | DraftChangeLog, time_stamp: datetime, user_id: int | None
929930
) -> None:
930931
"""
931-
Construct and emit the _CHANGED event when a set of entities is changed or published.
932+
Construct and emit the _CHANGED / _PUBLISHED event when a set of entities is
933+
changed or published.
932934
933-
Works with either `DraftChangeLog` or `PublishLog`.
935+
Works with either ``DraftChangeLog`` or ``PublishLog``.
934936
"""
935937

936938
learning_package_id = change_log.learning_package.id
@@ -944,11 +946,14 @@ def _emit_event_for_change_log(
944946
for record in change_log.records.select_related("old_version", "new_version").all()
945947
]
946948

949+
change_log_data: signals.DraftChangeLogEventData | signals.PublishLogEventData
947950
if isinstance(change_log, DraftChangeLog):
948951
signal = signals.LEARNING_PACKAGE_ENTITIES_CHANGED
949952
change_log_data = signals.DraftChangeLogEventData(draft_change_log_id=change_log.id, changes=changes)
950953
else:
951-
raise NotImplementedError
954+
assert isinstance(change_log, PublishLog)
955+
signal = signals.LEARNING_PACKAGE_ENTITIES_PUBLISHED
956+
change_log_data = signals.PublishLogEventData(publish_log_id=change_log.id, changes=changes)
952957

953958
# Send out an event immediately after this database transaction commits.
954959
def send_change_event():

src/openedx_content/applets/publishing/signals.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"UserAttributionEventData",
1515
"ChangeLogRecordData",
1616
"DraftChangeLogEventData",
17+
"PublishLogEventData",
1718
"LEARNING_PACKAGE_ENTITIES_CHANGED",
19+
"LEARNING_PACKAGE_ENTITIES_PUBLISHED",
1820
]
1921

2022

@@ -56,12 +58,20 @@ class ChangeLogRecordData:
5658

5759
@define
5860
class DraftChangeLogEventData:
59-
"""Summary of a `DraftChangeLog`"""
61+
"""Summary of a `DraftChangeLog` for event purposes"""
6062

6163
draft_change_log_id: int
6264
changes: list[ChangeLogRecordData]
6365

6466

67+
@define
68+
class PublishLogEventData:
69+
"""Summary of a `PublishLog` for event purposes"""
70+
71+
publish_log_id: int
72+
changes: list[ChangeLogRecordData]
73+
74+
6575
LEARNING_PACKAGE_ENTITIES_CHANGED = OpenEdxPublicSignal(
6676
event_type="org.openedx.content.publishing.lp_entities_changed.v1",
6777
data={
@@ -73,11 +83,45 @@ class DraftChangeLogEventData:
7383
"""
7484
The draft version of one or more entities in a `LearningPackage` has changed.
7585
76-
This is emitted for when the first version of an entity is **created**, when a
77-
new version of an entity is created (i.e. an entity is **modified**), when an
78-
entity is **reverted** to an old version, or when an entity is **deleted**.
79-
(All referring to the draft version of the entity.) The ``old_version`` and
80-
``new_version`` fields can be used to distinguish among these cases.
86+
This is emitted when the first version of an entity is **created**, when a new
87+
version of an entity is created (i.e. an entity is **modified**), when an entity
88+
is **reverted** to an old version, or when an entity is **deleted**. (All
89+
referring to the draft version of the entity.)
90+
91+
The ``old_version`` and ``new_version`` fields can be used to distinguish among
92+
these cases (e.g. ``old_version`` is ``None`` for newly-created entities).
93+
94+
This is a low-level batch event. It does not have any course or library context
95+
information available. It does not distinguish between Containers, Components,
96+
or other entity types.
97+
98+
Collections and tags are not `PublishableEntity`-based, so do not participate in
99+
this event.
100+
101+
⏳ This **batch** event is emitted **synchronously**. Handlers that do anything
102+
per-entity or that is possibly slow should dispatch an asynchronous task for
103+
processing the event.
104+
"""
105+
106+
107+
LEARNING_PACKAGE_ENTITIES_PUBLISHED = OpenEdxPublicSignal(
108+
event_type="org.openedx.content.publishing.lp_entities_published.v1",
109+
data={
110+
"learning_package": LearningPackageEventData,
111+
"changed_by": UserAttributionEventData,
112+
"change_log": PublishLogEventData,
113+
},
114+
)
115+
"""
116+
The published version of one or more entities in a `LearningPackage` has
117+
changed.
118+
119+
This is emitted when **a newly-created entity is first published**, when
120+
**changes to an existing entity** are published, when a published entity is
121+
**reverted** to a previous version, or when **a "delete" is published**.
122+
123+
The ``old_version`` and ``new_version`` fields can be used to distinguish among
124+
these cases (e.g. ``old_version`` is ``None`` for newly-created entities).
81125
82126
This is a low-level batch event. It does not have any course or library context
83127
information available. It does not distinguish between Containers, Components,

tests/openedx_content/applets/containers/test_api.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,7 @@ def test_contains_unpublished_changes_queries(
925925
assert containers_api.contains_unpublished_changes(grandparent.id)
926926

927927
# Publish grandparent and all its descendants:
928-
with django_assert_num_queries(135): # TODO: investigate as this seems high!
928+
with django_assert_num_queries(137): # TODO: investigate as this seems high!
929929
publish_entity(grandparent)
930930

931931
# Tests:
@@ -1244,7 +1244,7 @@ def test_uninstalled_publish(
12441244
"""Simple test of publishing a container of uninstalled type, plus its child, and reviewing the publish log"""
12451245
# Publish container_of_uninstalled_type (and child_entity1). Should not affect anything else,
12461246
# but we should see "child_entity1" omitted from the subsequent publish.
1247-
with django_assert_num_queries(49):
1247+
with django_assert_num_queries(51):
12481248
publish_log = publish_entity(container_of_uninstalled_type)
12491249
# Nothing else should have been affected by the publish:
12501250
assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
@@ -1282,7 +1282,7 @@ def test_deep_publish_log(
12821282
)
12831283
# Publish container_of_uninstalled_type (and child_entity1). Should not affect anything else,
12841284
# but we should see "child_entity1" omitted from the subsequent publish.
1285-
with django_assert_num_queries(49):
1285+
with django_assert_num_queries(51):
12861286
publish_log = publish_entity(container_of_uninstalled_type)
12871287
# Nothing else should have been affected by the publish:
12881288
assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
@@ -1291,7 +1291,7 @@ def test_deep_publish_log(
12911291
]
12921292

12931293
# Publish great_grandparent. Should publish the whole tree.
1294-
with django_assert_num_queries(126):
1294+
with django_assert_num_queries(128):
12951295
publish_log = publish_entity(great_grandparent)
12961296
assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
12971297
"child_entity2",

tests/openedx_content/applets/publishing/test_signals.py

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
pytestmark = pytest.mark.django_db(transaction=True)
1414
now_time = datetime.now(tz=timezone.utc)
1515

16-
17-
class DeliberateRollbackException(Exception):
18-
"""Exception used to deliberately cancel and roll back a DB transaction"""
16+
# LEARNING_PACKAGE_ENTITIES_CHANGED
1917

2018

2119
def test_single_entity_changed() -> None:
@@ -147,3 +145,104 @@ def test_multiple_entites_change_aborted() -> None:
147145
api.create_publishable_entity_version(entity2.id, version_num=2, title="Entity 2 V2", **created_args)
148146
# Delete entity 3:
149147
api.set_draft_version(entity3.id, None, set_at=now_time, set_by=None)
148+
149+
150+
# LEARNING_PACKAGE_ENTITIES_PUBLISHED
151+
152+
153+
def test_publish_events(admin_user) -> None:
154+
"""
155+
Test that LEARNING_PACKAGE_ENTITIES_PUBLISHED is emitted when we publish
156+
changes to entities in a learning package.
157+
"""
158+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
159+
created_args = {"created": now_time, "created_by": admin_user.id}
160+
161+
# Entity 1 will have no initial version:
162+
entity1 = api.create_publishable_entity(learning_package.id, key="entity1", **created_args)
163+
# Entity 2 will have an initial version with some changes:
164+
entity2 = api.create_publishable_entity(learning_package.id, key="entity2", **created_args)
165+
api.create_publishable_entity_version(entity2.id, version_num=1, title="Entity 2 V1", **created_args)
166+
api.create_publishable_entity_version(entity2.id, version_num=2, title="Entity 2 V2", **created_args)
167+
# Entity 3 will have an initial version that later gets deleted:
168+
entity3 = api.create_publishable_entity(learning_package.id, key="entity3", **created_args)
169+
api.create_publishable_entity_version(entity3.id, version_num=1, title="Entity 3 V1", **created_args)
170+
171+
# Publish these initial changes:
172+
first_publish_time = datetime.now(tz=timezone.utc)
173+
with capture_events(expected_count=1) as captured:
174+
first_log = api.publish_all_drafts(
175+
learning_package.id, published_at=first_publish_time, published_by=admin_user.id
176+
)
177+
178+
event = captured[0]
179+
assert event.signal is api.signals.LEARNING_PACKAGE_ENTITIES_PUBLISHED
180+
assert event.kwargs["learning_package"].id == learning_package.id
181+
assert event.kwargs["learning_package"].title == "Test LP 📦"
182+
assert event.kwargs["changed_by"].user_id is admin_user.id
183+
assert event.kwargs["change_log"].publish_log_id == first_log.id
184+
assert event.kwargs["change_log"].changes == [
185+
# Entity 1 is not yet published, since it has no draft version.
186+
# Entity 2 is newly published, and now at v2:
187+
api.signals.ChangeLogRecordData(entity_id=entity2.id, old_version=None, new_version=2),
188+
# Entity 3 is newly published, and now at v1:
189+
api.signals.ChangeLogRecordData(entity_id=entity3.id, old_version=None, new_version=1),
190+
]
191+
assert event.kwargs["metadata"].time == first_publish_time
192+
193+
# Now modify the entities again:
194+
# Create a version of entity1:
195+
api.create_publishable_entity_version(entity1.id, version_num=1, title="Entity 1 V1", **created_args)
196+
# Create a version 3 of entity2:
197+
api.create_publishable_entity_version(entity2.id, version_num=3, title="Entity 2 V3", **created_args)
198+
# Delete entity 3:
199+
api.set_draft_version(entity3.id, None, set_at=now_time, set_by=admin_user.id)
200+
201+
# Publish these new changes:
202+
second_publish_time = datetime.now(tz=timezone.utc)
203+
with capture_events(expected_count=1) as captured:
204+
second_log = api.publish_all_drafts(
205+
learning_package.id, published_at=second_publish_time, published_by=admin_user.id
206+
)
207+
208+
event = captured[0]
209+
assert event.signal is api.signals.LEARNING_PACKAGE_ENTITIES_PUBLISHED
210+
assert event.kwargs["learning_package"].id == learning_package.id
211+
assert event.kwargs["learning_package"].title == "Test LP 📦"
212+
assert event.kwargs["changed_by"].user_id is admin_user.id
213+
assert event.kwargs["change_log"].publish_log_id == second_log.id
214+
assert event.kwargs["change_log"].changes == [
215+
# Entity 1 is newly published at v1:
216+
api.signals.ChangeLogRecordData(entity_id=entity1.id, old_version=None, new_version=1),
217+
# Entity 2 jumps v2 -> v3:
218+
api.signals.ChangeLogRecordData(entity_id=entity2.id, old_version=2, new_version=3),
219+
# Entity 3 gets deleted:
220+
api.signals.ChangeLogRecordData(entity_id=entity3.id, old_version=1, new_version=None),
221+
]
222+
assert event.kwargs["metadata"].time == second_publish_time
223+
224+
225+
def test_publish_events_aborted(admin_user) -> None:
226+
"""
227+
Test that LEARNING_PACKAGE_ENTITIES_PUBLISHED is NOT emitted when we roll
228+
back a transaction that would have published some entities.
229+
"""
230+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
231+
created_args = {"created": now_time, "created_by": admin_user.id}
232+
233+
# Create an entity with some initial version:
234+
entity1 = api.create_publishable_entity(learning_package.id, key="entity1", **created_args)
235+
api.create_publishable_entity_version(entity1.id, version_num=1, title="Entity 1 V1", **created_args)
236+
237+
def do_publish():
238+
draft_qset = api.get_all_drafts(learning_package.id).filter(entity=entity1)
239+
api.publish_from_drafts(
240+
learning_package.id, draft_qset=draft_qset, published_at=now_time, published_by=admin_user.id
241+
)
242+
243+
with capture_events(expected_count=0):
244+
with abort_transaction():
245+
do_publish()
246+
247+
with capture_events(expected_count=1):
248+
do_publish()

tests/openedx_content/applets/sections/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def test_section_queries(self) -> None:
155155
"""
156156
with self.assertNumQueries(39):
157157
section = self.create_section_with_subsections([self.subsection_1, self.subsection_2_v1])
158-
with self.assertNumQueries(160):
158+
with self.assertNumQueries(162):
159159
content_api.publish_from_drafts(
160160
self.learning_package.id,
161161
draft_qset=content_api.get_all_drafts(self.learning_package.id).filter(entity=section.id),

tests/openedx_content/applets/subsections/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def test_subsection_queries(self) -> None:
133133
"""
134134
with self.assertNumQueries(39):
135135
subsection = self.create_subsection_with_units([self.unit_1, self.unit_1_v1])
136-
with self.assertNumQueries(102): # TODO: this seems high?
136+
with self.assertNumQueries(104): # TODO: this seems high?
137137
content_api.publish_from_drafts(
138138
self.learning_package.id,
139139
draft_qset=content_api.get_all_drafts(self.learning_package.id).filter(entity=subsection.id),

tests/openedx_content/applets/units/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def test_unit_queries(self) -> None:
134134
"""
135135
with self.assertNumQueries(37):
136136
unit = self.create_unit_with_components([self.component_1, self.component_2_v1])
137-
with self.assertNumQueries(48): # TODO: this seems high?
137+
with self.assertNumQueries(50): # TODO: this seems high?
138138
content_api.publish_from_drafts(
139139
self.learning_package.id,
140140
draft_qset=content_api.get_all_drafts(self.learning_package.id).filter(entity=unit.id),

0 commit comments

Comments
 (0)