Skip to content

Commit 19b76fa

Browse files
feat: emit change log details, don't emit if txn is rolled back
1 parent 0f39f27 commit 19b76fa

10 files changed

Lines changed: 136 additions & 28 deletions

File tree

src/openedx_content/api.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"""
1010

1111
# These wildcard imports are okay because these api modules declare __all__.
12-
# pylint: disable=wildcard-import
12+
# pylint: disable=wildcard-import,unused-import
1313
from .applets.backup_restore.api import *
1414
from .applets.collections.api import *
1515
from .applets.components.api import *
@@ -19,3 +19,5 @@
1919
from .applets.sections.api import *
2020
from .applets.subsections.api import *
2121
from .applets.units.api import *
22+
# Signals are kept in a separate namespace to avoid collisions between data structures and models
23+
from . import api_signals as signals

src/openedx_content/api_signals.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Signals that are part of the public API of openedx_content
3+
"""
4+
5+
# These wildcard imports are okay because these api modules declare __all__.
6+
# pylint: disable=wildcard-import
7+
from .applets.publishing.signals import *

src/openedx_content/applets/publishing/api.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from django.core.exceptions import ObjectDoesNotExist
1515
from django.db.models import F, Prefetch, Q, QuerySet
16-
from django.db.transaction import atomic
16+
from django.db.transaction import atomic, on_commit
1717

1818
from openedx_django_lib.fields import create_hash_digest
1919

@@ -686,17 +686,8 @@ def set_draft_version(
686686
)
687687
draft.save()
688688
_create_side_effects_for_change_log(change_log)
689-
# Send out an event immediately, since this is an isolated change.
690-
# TODO: use transaction.on_commit for this event.
691-
signals.LEARNING_PACKAGE_ENTITIES_CHANGED.send_event(
692-
time=set_at,
693-
learning_package=signals.LearningPackageEventData(
694-
id=learning_package_id,
695-
title="TODO: set me",
696-
),
697-
changed_by=signals.UserAttributionEventData(user_id=set_by),
698-
change_log=signals.DraftChangeLogEventData(draft_change_log_id=change_log.id),
699-
)
689+
# Send out an event immediately after this database transaction commits, since this is an isolated change.
690+
_emit_event_for_change_log(change_log, time_stamp=set_at, user_id=set_by)
700691

701692

702693
def _add_to_existing_draft_change_log(
@@ -932,6 +923,44 @@ def _create_side_effects_for_change_log(change_log: DraftChangeLog | PublishLog)
932923
update_dependencies_hash_digests_for_log(change_log)
933924

934925

926+
def _emit_event_for_change_log(
927+
change_log: PublishLog | DraftChangeLog, time_stamp: datetime, user_id: int | None
928+
) -> None:
929+
"""
930+
Construct and emit the _CHANGED event when a set of entities is changed or published.
931+
932+
Works with either `DraftChangeLog` or `PublishLog`.
933+
"""
934+
935+
learning_package_id = change_log.learning_package.id
936+
learning_package_title = change_log.learning_package.title
937+
changes = [
938+
signals.ChangeLogRecord(
939+
entity_id=record.entity_id,
940+
old_version=record.old_version.version_num if record.old_version else None,
941+
new_version=record.new_version.version_num if record.new_version else None,
942+
)
943+
for record in change_log.records.select_related("old_version", "new_version").all()
944+
]
945+
946+
if isinstance(change_log, DraftChangeLog):
947+
signal = signals.LEARNING_PACKAGE_ENTITIES_CHANGED
948+
change_log_data = signals.DraftChangeLogEventData(draft_change_log_id=change_log.id, changes=changes)
949+
else:
950+
raise NotImplementedError
951+
952+
# Send out an event immediately after this database transaction commits.
953+
def send_change_event():
954+
signal.send_event(
955+
time=time_stamp,
956+
learning_package=signals.LearningPackageEventData(id=learning_package_id, title=learning_package_title),
957+
changed_by=signals.UserAttributionEventData(user_id=user_id),
958+
change_log=change_log_data,
959+
)
960+
961+
on_commit(send_change_event)
962+
963+
935964
def update_dependencies_hash_digests_for_log(
936965
change_log: DraftChangeLog | PublishLog,
937966
backfill: bool = False,

src/openedx_content/applets/publishing/signals.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,60 @@
66
from openedx_events.tooling import OpenEdxPublicSignal # type: ignore[import-untyped]
77

88
from .models.learning_package import LearningPackage
9+
from .models.publishable_entity import PublishableEntity
10+
11+
# Public API available via openedx_content.api
12+
__all__ = [
13+
"LearningPackageEventData",
14+
"UserAttributionEventData",
15+
"ChangeLogRecord",
16+
"DraftChangeLogEventData",
17+
"LEARNING_PACKAGE_ENTITIES_CHANGED",
18+
]
919

1020

1121
@define
1222
class LearningPackageEventData:
1323
"""Identifies which learning package an event is associated with."""
24+
1425
id: LearningPackage.ID
1526
title: str # Since 'id' is not easily human-understandable, we include the title too
1627

28+
1729
@define
1830
class UserAttributionEventData:
1931
"""Identifies which user triggered the event."""
32+
2033
user_id: int | None
2134

35+
36+
@define
37+
class ChangeLogRecord:
38+
"""A single change that was made to a PublishableEntity"""
39+
40+
entity_id: PublishableEntity.ID
41+
42+
old_version: int | None
43+
"""
44+
The old version_num of this entity (not the PublishableEntityVersion ID).
45+
This is None if the entity is newly created.
46+
"""
47+
48+
new_version: int | None
49+
"""
50+
The new version_num of this entity (not the PublishableEntityVersion ID).
51+
This is None if the entity is now deleted.
52+
"""
53+
54+
# Future: direct? https://github.com/openedx/openedx-core/pull/539
55+
56+
2257
@define
2358
class DraftChangeLogEventData:
2459
"""Summary of a `DraftChangeLog`"""
60+
2561
draft_change_log_id: int
62+
changes: list[ChangeLogRecord]
2663

2764

2865
LEARNING_PACKAGE_ENTITIES_CHANGED = OpenEdxPublicSignal(

tests/openedx_content/applets/containers/test_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,10 @@ def test_create_container_queries(lp: LearningPackage, child_entity1: TestEntity
355355
"container_cls": TestContainer,
356356
}
357357
# The exact numbers here aren't too important - this is just to alert us if anything significant changes.
358-
with django_assert_num_queries(31):
358+
with django_assert_num_queries(33):
359359
containers_api.create_container_and_version(lp.id, key="c1", **base_args)
360360
# And try with a a container that has children:
361-
with django_assert_num_queries(32):
361+
with django_assert_num_queries(34):
362362
containers_api.create_container_and_version(lp.id, key="c2", **base_args, entities=[child_entity1])
363363

364364

tests/openedx_content/applets/publishing/test_signals.py

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,70 @@
44

55
from datetime import datetime, timezone
66

7+
from django.db import transaction
78
import pytest
89

910
from openedx_content import api
10-
from openedx_content.applets.publishing.signals import LEARNING_PACKAGE_ENTITIES_CHANGED
1111

1212
from tests.utils import capture_events
1313

14-
pytestmark = pytest.mark.django_db
14+
pytestmark = pytest.mark.django_db(transaction=True)
1515
now_time = datetime.now(tz=timezone.utc)
1616

1717

18-
def test_unbatched_events() -> None:
18+
class DeliberateRollbackException(Exception):
19+
"""Exception used to deliberately cancel and roll back a DB transaction"""
20+
21+
22+
def test_single_entity_changed() -> None:
1923
"""
2024
Test that LEARNING_PACKAGE_ENTITIES_CHANGED is emitted when we change a
2125
publishable entity.
2226
"""
23-
learning_package = api.create_learning_package(key="lp1", title="Test LP")
27+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
2428

2529
entity = api.create_publishable_entity(learning_package.id, key="entity1", created=now_time, created_by=None)
26-
# create_publishable_entity_version also calls set_draft_version internally, so
30+
31+
NEW_VERSION_NUM = 3 # Just for fun let's use a version number other than 1
32+
2733
with capture_events(expected_count=1) as captured:
2834
v1 = api.create_publishable_entity_version(
29-
entity.id, version_num=1, title="Entity 1 V1", created=now_time, created_by=None
35+
entity.id, version_num=NEW_VERSION_NUM, title="Entity 1 V3", created=now_time, created_by=None
3036
)
3137

3238
entity.refresh_from_db()
3339
assert api.get_draft_version(entity.id) == v1
3440

35-
event = captured[0]
36-
assert event.signal is LEARNING_PACKAGE_ENTITIES_CHANGED
41+
# Because only one change (create_..._version) has affected this version, it's easy for us to get its DraftChangeLog
42+
expected_draft_change_log_id = v1.draftchangelogrecord_set.get().draft_change_log_id
43+
44+
event = captured[0] # capture_events(...) context manager already asserted there's only one event.
45+
assert event.signal is api.signals.LEARNING_PACKAGE_ENTITIES_CHANGED
3746
assert event.kwargs["learning_package"].id == learning_package.id
47+
assert event.kwargs["learning_package"].title == "Test LP 📦"
3848
assert event.kwargs["changed_by"].user_id is None
39-
assert event.kwargs["change_log"].draft_change_log_id > 0
49+
assert event.kwargs["change_log"].draft_change_log_id == expected_draft_change_log_id
50+
assert event.kwargs["change_log"].changes == [
51+
api.signals.ChangeLogRecord(entity_id=entity.id, old_version=None, new_version=NEW_VERSION_NUM),
52+
]
4053
assert event.kwargs["metadata"].time == now_time
54+
55+
56+
def test_single_entity_changed_abort() -> None:
57+
"""
58+
Test that no events are emitted when we roll back a transaction that would have
59+
changed a publishable entity.
60+
"""
61+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
62+
63+
entity = api.create_publishable_entity(learning_package.id, key="entity1", created=now_time, created_by=None)
64+
65+
with capture_events(expected_count=0):
66+
try:
67+
with transaction.atomic():
68+
api.create_publishable_entity_version(
69+
entity.id, version_num=1, title="Entity 1 V1", created=now_time, created_by=None
70+
)
71+
raise DeliberateRollbackException()
72+
except DeliberateRollbackException:
73+
pass

tests/openedx_content/applets/sections/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def test_section_queries(self) -> None:
153153
"""
154154
Test the number of queries needed for each part of the sections API
155155
"""
156-
with self.assertNumQueries(37):
156+
with self.assertNumQueries(39):
157157
section = self.create_section_with_subsections([self.subsection_1, self.subsection_2_v1])
158158
with self.assertNumQueries(160):
159159
content_api.publish_from_drafts(

tests/openedx_content/applets/subsections/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def test_subsection_queries(self) -> None:
131131
"""
132132
Test the number of queries needed for each part of the subsections API
133133
"""
134-
with self.assertNumQueries(37):
134+
with self.assertNumQueries(39):
135135
subsection = self.create_subsection_with_units([self.unit_1, self.unit_1_v1])
136136
with self.assertNumQueries(102): # TODO: this seems high?
137137
content_api.publish_from_drafts(

tests/openedx_content/applets/units/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def test_unit_queries(self) -> None:
132132
"""
133133
Test the number of queries needed for each part of the units API
134134
"""
135-
with self.assertNumQueries(35):
135+
with self.assertNumQueries(37):
136136
unit = self.create_unit_with_components([self.component_1, self.component_2_v1])
137137
with self.assertNumQueries(48): # TODO: this seems high?
138138
content_api.publish_from_drafts(

tests/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def capture_events(
5757
for signal in signals:
5858

5959
def make_receiver(sig: OpenEdxPublicSignal):
60-
def receiver(sender, **kwargs):
60+
def receiver(sender, **kwargs): # pylint: disable=unused-argument
6161
kwargs.pop("signal", None)
6262
captured.append(CapturedEvent(signal=sig, kwargs=kwargs))
6363

0 commit comments

Comments
 (0)