Skip to content

Commit 2b373f8

Browse files
feat: emit event signals when publishable entities are changed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3f936dc commit 2b373f8

4 files changed

Lines changed: 176 additions & 0 deletions

File tree

src/openedx_content/applets/publishing/api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
PublishSideEffect,
3636
)
3737
from .models.publish_log import Published
38+
from . import signals
3839

3940
# The public API that will be re-exported by openedx_content.api
4041
# is listed in the __all__ entries below. Internal helper functions that are
@@ -685,6 +686,17 @@ def set_draft_version(
685686
)
686687
draft.save()
687688
_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+
)
688700

689701

690702
def _add_to_existing_draft_change_log(
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Low-level events/signals emitted by openedx_content
3+
"""
4+
5+
from attrs import define
6+
from openedx_events.tooling import OpenEdxPublicSignal # type: ignore[import-untyped]
7+
8+
from .models.learning_package import LearningPackage
9+
10+
11+
@define
12+
class LearningPackageEventData:
13+
"""Identifies which learning package an event is associated with."""
14+
id: LearningPackage.ID
15+
title: str # Since 'id' is not easily human-understandable, we include the title too
16+
17+
@define
18+
class UserAttributionEventData:
19+
"""Identifies which user triggered the event."""
20+
user_id: int | None
21+
22+
@define
23+
class DraftChangeLogEventData:
24+
"""Summary of a `DraftChangeLog`"""
25+
draft_change_log_id: int
26+
27+
28+
LEARNING_PACKAGE_ENTITIES_CHANGED = OpenEdxPublicSignal(
29+
event_type="org.openedx.content.publishing.lp_entities_changed.v1",
30+
data={
31+
"learning_package": LearningPackageEventData,
32+
"changed_by": UserAttributionEventData,
33+
"change_log": DraftChangeLogEventData,
34+
},
35+
)
36+
"""
37+
The draft version of one or more entities in a `LearningPackage` has changed.
38+
39+
This is a low-level batch event. It does not have any course or library context
40+
information available. It does not distinguish between Containers, Components,
41+
or other entity types.
42+
43+
Collections and tags are not `PublishableEntity`-based, so do not participate in
44+
this event.
45+
"""
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""
2+
Tests related to the Catalog signal handlers
3+
"""
4+
5+
from datetime import datetime, timezone
6+
7+
import pytest
8+
9+
from openedx_content import api
10+
from openedx_content.applets.publishing.signals import LEARNING_PACKAGE_ENTITIES_CHANGED
11+
12+
from tests.utils import capture_events
13+
14+
pytestmark = pytest.mark.django_db
15+
now_time = datetime.now(tz=timezone.utc)
16+
17+
18+
def test_unbatched_events() -> None:
19+
"""
20+
Test that LEARNING_PACKAGE_ENTITIES_CHANGED is emitted when we change a
21+
publishable entity.
22+
"""
23+
learning_package = api.create_learning_package(key="lp1", title="Test LP")
24+
25+
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
27+
with capture_events(expected_count=1) as captured:
28+
v1 = api.create_publishable_entity_version(
29+
entity.id, version_num=1, title="Entity 1 V1", created=now_time, created_by=None
30+
)
31+
32+
entity.refresh_from_db()
33+
assert api.get_draft_version(entity.id) == v1
34+
35+
event = captured[0]
36+
assert event.signal is LEARNING_PACKAGE_ENTITIES_CHANGED
37+
assert event.kwargs["learning_package"].id == learning_package.id
38+
assert event.kwargs["changed_by"].user_id is None
39+
assert event.kwargs["change_log"].draft_change_log_id > 0
40+
assert event.kwargs["metadata"].time == now_time

tests/utils.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""
2+
Shared testing utilities for openedx-core tests.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from contextlib import contextmanager
8+
from dataclasses import dataclass
9+
from typing import Generator
10+
11+
from openedx_events.tooling import OpenEdxPublicSignal # type: ignore[import-untyped]
12+
13+
14+
@dataclass
15+
class CapturedEvent:
16+
"""A single captured event emission."""
17+
18+
signal: OpenEdxPublicSignal
19+
kwargs: dict
20+
21+
22+
@contextmanager
23+
def capture_events(
24+
signals: list[OpenEdxPublicSignal] | None = None,
25+
expected_count: int | None = None,
26+
) -> Generator[list[CapturedEvent], None, None]:
27+
"""
28+
Context manager that captures Open edX events emitted during the block.
29+
30+
Args:
31+
signals: Optional list of ``OpenEdxPublicSignal`` instances to monitor.
32+
Defaults to all registered signals (OpenEdxPublicSignal.all_events()).
33+
expected_count: How many events are expected (optional). If specified,
34+
will assert that the resulting list has this length.
35+
36+
Yields:
37+
list[CapturedEvent]: A list that is populated as each event fires.
38+
Each entry has a ``signal`` attribute and a ``kwargs``
39+
dict containing the event data (learning_package,
40+
changed_by, etc.) plus ``metadata`` and
41+
``from_event_bus``.
42+
43+
Example usage::
44+
45+
with capture_events(expected_count=1) as captured:
46+
api.do_something(entity.id, ...)
47+
48+
assert captured[0].signal is LEARNING_PACKAGE_ENTITIES_CHANGED
49+
assert captured[0].kwargs['learning_package'].id == learning_package.id
50+
"""
51+
if signals is None:
52+
signals = list(OpenEdxPublicSignal.all_events())
53+
54+
captured: list[CapturedEvent] = []
55+
receivers: dict[OpenEdxPublicSignal, object] = {}
56+
57+
for signal in signals:
58+
59+
def make_receiver(sig: OpenEdxPublicSignal):
60+
def receiver(sender, **kwargs):
61+
kwargs.pop("signal", None)
62+
captured.append(CapturedEvent(signal=sig, kwargs=kwargs))
63+
64+
return receiver
65+
66+
receiver = make_receiver(signal)
67+
signal.connect(receiver)
68+
receivers[signal] = receiver
69+
70+
try:
71+
yield captured
72+
finally:
73+
for signal, receiver in receivers.items():
74+
signal.disconnect(receiver)
75+
76+
if expected_count is not None:
77+
assert len(captured) == expected_count, (
78+
f"Expected {expected_count} event(s), got {len(captured)}: {[e.signal for e in captured]}"
79+
)

0 commit comments

Comments
 (0)