Skip to content

Commit 4aa66f5

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

10 files changed

Lines changed: 138 additions & 30 deletions

File tree

src/openedx_content/api.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
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
13+
14+
# Signals are kept in a separate namespace to avoid collisions between data structures and models
15+
from . import api_signals as signals
1316
from .applets.backup_restore.api import *
1417
from .applets.collections.api import *
1518
from .applets.components.api import *

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: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
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

20+
from . import signals
2021
from .contextmanagers import DraftChangeLogContext
2122
from .models import (
2223
Draft,
@@ -35,7 +36,6 @@
3536
PublishSideEffect,
3637
)
3738
from .models.publish_log import Published
38-
from . import signals
3939

4040
# The public API that will be re-exported by openedx_content.api
4141
# is listed in the __all__ entries below. Internal helper functions that are
@@ -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 & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,68 @@
55
from datetime import datetime, timezone
66

77
import pytest
8+
from django.db import transaction
89

910
from openedx_content import api
10-
from openedx_content.applets.publishing.signals import LEARNING_PACKAGE_ENTITIES_CHANGED
11-
1211
from tests.utils import capture_events
1312

14-
pytestmark = pytest.mark.django_db
13+
pytestmark = pytest.mark.django_db(transaction=True)
1514
now_time = datetime.now(tz=timezone.utc)
1615

1716

18-
def test_unbatched_events() -> None:
17+
class DeliberateRollbackException(Exception):
18+
"""Exception used to deliberately cancel and roll back a DB transaction"""
19+
20+
21+
def test_single_entity_changed() -> None:
1922
"""
2023
Test that LEARNING_PACKAGE_ENTITIES_CHANGED is emitted when we change a
2124
publishable entity.
2225
"""
23-
learning_package = api.create_learning_package(key="lp1", title="Test LP")
26+
learning_package = api.create_learning_package(key="lp1", title="Test LP 📦")
2427

2528
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
29+
30+
NEW_VERSION_NUM = 3 # Just for fun let's use a version number other than 1
31+
2732
with capture_events(expected_count=1) as captured:
2833
v1 = api.create_publishable_entity_version(
29-
entity.id, version_num=1, title="Entity 1 V1", created=now_time, created_by=None
34+
entity.id, version_num=NEW_VERSION_NUM, title="Entity 1 V3", created=now_time, created_by=None
3035
)
3136

3237
entity.refresh_from_db()
3338
assert api.get_draft_version(entity.id) == v1
3439

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