diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index 8365f435c3..5ac744f08a 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -13,7 +13,12 @@ from django.dispatch import receiver from edx_toggles.toggles import SettingToggle from opaque_keys.edx.keys import CourseKey from openedx_events.content_authoring.data import CourseCatalogData, CourseScheduleData -from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED +from openedx_events.content_authoring.signals import ( + COURSE_CATALOG_INFO_CHANGED, + XBLOCK_DELETED, + XBLOCK_DUPLICATED, + XBLOCK_PUBLISHED, +) from openedx_events.event_bus import get_producer from pytz import UTC @@ -166,6 +171,42 @@ def listen_for_course_catalog_info_changed(sender, signal, **kwargs): ) +@receiver(XBLOCK_PUBLISHED) +def listen_for_xblock_published(sender, signal, **kwargs): + """ + Publish XBLOCK_PUBLISHED signals onto the event bus. + """ + get_producer().send( + signal=XBLOCK_PUBLISHED, topic='xblock-published', + event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, + event_metadata=kwargs['metadata'], + ) + + +@receiver(XBLOCK_DELETED) +def listen_for_xblock_deleted(sender, signal, **kwargs): + """ + Publish XBLOCK_DELETED signals onto the event bus. + """ + get_producer().send( + signal=XBLOCK_DELETED, topic='xblock-deleted', + event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, + event_metadata=kwargs['metadata'], + ) + + +@receiver(XBLOCK_DUPLICATED) +def listen_for_xblock_duplicated(sender, signal, **kwargs): + """ + Publish XBLOCK_DUPLICATED signals onto the event bus. + """ + get_producer().send( + signal=XBLOCK_DUPLICATED, topic='xblock-duplicated', + event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, + event_metadata=kwargs['metadata'], + ) + + @receiver(SignalHandler.course_deleted) def listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable=unused-argument """ diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 439bed09cf..c65aaace82 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -11,9 +11,12 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse, HttpResponseBadRequest +from django.utils.timezone import timezone from django.utils.translation import gettext as _ from django.views.decorators.http import require_http_methods from edx_django_utils.plugins import pluggable_override +from openedx_events.content_authoring.data import DuplicatedXBlockData +from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED from edx_proctoring.api import ( does_backend_support_onboarding, get_exam_by_content_id, @@ -959,6 +962,16 @@ def _duplicate_block(parent_usage_key, duplicate_source_usage_key, user, display parent.children.append(dest_block.location) store.update_item(parent, user.id) + # .. event_implemented_name: XBLOCK_DUPLICATED + XBLOCK_DUPLICATED.send_event( + time=datetime.now(timezone.utc), + xblock_info=DuplicatedXBlockData( + usage_key=dest_block.location, + block_type=dest_block.location.block_type, + source_usage_key=duplicate_source_usage_key, + ) + ) + return dest_block.location diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 9ebc7e554b..7f5cbfe2be 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -12,6 +12,9 @@ from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory from django.urls import reverse +from openedx_events.content_authoring.data import DuplicatedXBlockData +from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED +from openedx_events.tests.utils import OpenEdxEventsTestMixin from edx_proctoring.exceptions import ProctoredExamNotFoundException from opaque_keys import InvalidKeyError from opaque_keys.edx.asides import AsideUsageKeyV2 @@ -550,6 +553,7 @@ class DuplicateHelper: self._check_equality(source_usage_key, usage_key, parent_usage_key, check_asides=check_asides), "Duplicated item differs from original" ) + return usage_key def _check_equality(self, source_usage_key, duplicate_usage_key, parent_usage_key=None, check_asides=False, is_child=False): @@ -642,11 +646,25 @@ class DuplicateHelper: return self.response_usage_key(resp) -class TestDuplicateItem(ItemTest, DuplicateHelper): +class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin): """ Test the duplicate method. """ + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.content_authoring.xblock.duplicated.v1", + ] + + @classmethod + def setUpClass(cls): + """ + Set up class method for the Test class. + This method starts manually events isolation. Explanation here: + openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44 + """ + super().setUpClass() + cls.start_events_isolation() + def setUp(self): """ Creates the test course structure and a few components to 'duplicate'. """ super().setUp() @@ -684,6 +702,27 @@ class TestDuplicateItem(ItemTest, DuplicateHelper): self._duplicate_and_verify(self.seq_usage_key, self.chapter_usage_key) self._duplicate_and_verify(self.chapter_usage_key, self.usage_key) + def test_duplicate_event(self): + """ + Check that XBLOCK_DUPLICATED event is sent when xblock is duplicated. + """ + event_receiver = Mock() + XBLOCK_DUPLICATED.connect(event_receiver) + usage_key = self._duplicate_and_verify(self.vert_usage_key, self.seq_usage_key) + event_receiver.assert_called() + self.assertDictContainsSubset( + { + "signal": XBLOCK_DUPLICATED, + "sender": None, + "xblock_info": DuplicatedXBlockData( + usage_key=usage_key, + block_type=usage_key.block_type, + source_usage_key=self.vert_usage_key, + ), + }, + event_receiver.call_args.kwargs + ) + def test_ordering(self): """ Tests the a duplicated xblock appears immediately after its source diff --git a/docs/guides/hooks/events.rst b/docs/guides/hooks/events.rst index f0b31358cc..146eca342d 100644 --- a/docs/guides/hooks/events.rst +++ b/docs/guides/hooks/events.rst @@ -195,3 +195,15 @@ Content Authoring Events * - `COURSE_CATALOG_INFO_CHANGED `_ - org.openedx.content_authoring.course.catalog_info.changed.v1 - `2022-08-24 `_ + + * - `XBLOCK_PUBLISHED `_ + - org.openedx.content_authoring.xblock.published.v1 + - `2022-12-06 `_ + + * - `XBLOCK_DELETED `_ + - org.openedx.content_authoring.xblock.deleted.v1 + - `2022-12-06 `_ + + * - `XBLOCK_DUPLICATED `_ + - org.openedx.content_authoring.xblock.duplicated.v1 + - `2022-12-06 `_ diff --git a/xmodule/modulestore/mixed.py b/xmodule/modulestore/mixed.py index 58c58e32f3..77436220d1 100644 --- a/xmodule/modulestore/mixed.py +++ b/xmodule/modulestore/mixed.py @@ -12,7 +12,10 @@ from contextlib import contextmanager from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator +from openedx_events.content_authoring.data import XBlockData +from openedx_events.content_authoring.signals import XBLOCK_DELETED, XBLOCK_PUBLISHED +from django.utils.timezone import datetime, timezone from xmodule.assetstore import AssetMetadata from . import XMODULE_FIELDS_WITH_USAGE_KEYS, ModuleStoreWriteBase @@ -797,7 +800,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): Delete the given item from persistence. kwargs allow modulestore specific parameters. """ store = self._verify_modulestore_support(location.course_key, 'delete_item') - return store.delete_item(location, user_id=user_id, **kwargs) + item = store.delete_item(location, user_id=user_id, **kwargs) + # .. event_implemented_name: XBLOCK_DELETED + XBLOCK_DELETED.send_event( + time=datetime.now(timezone.utc), + xblock_info=XBlockData( + usage_key=location, + block_type=location.block_type, + ) + ) + return item def revert_to_published(self, location, user_id): """ @@ -911,7 +923,16 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): Returns the newly published item. """ store = self._verify_modulestore_support(location.course_key, 'publish') - return store.publish(location, user_id, **kwargs) + item = store.publish(location, user_id, **kwargs) + # .. event_implemented_name: XBLOCK_PUBLISHED + XBLOCK_PUBLISHED.send_event( + time=datetime.now(timezone.utc), + xblock_info=XBlockData( + usage_key=location, + block_type=location.block_type, + ) + ) + return item @strip_key def unpublish(self, location, user_id, **kwargs): diff --git a/xmodule/modulestore/tests/test_mixed_modulestore.py b/xmodule/modulestore/tests/test_mixed_modulestore.py index 5d5360d8f0..ba6120e3f1 100644 --- a/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -15,6 +15,9 @@ from uuid import uuid4 from unittest.mock import Mock, call, patch import ddt +from openedx_events.content_authoring.data import XBlockData +from openedx_events.content_authoring.signals import XBLOCK_DELETED, XBLOCK_PUBLISHED +from openedx_events.tests.utils import OpenEdxEventsTestMixin import pymongo import pytest # Mixed modulestore depends on django, so we'll manually configure some django settings @@ -63,7 +66,7 @@ if not settings.configured: log = logging.getLogger(__name__) -class CommonMixedModuleStoreSetup(CourseComparisonTest): +class CommonMixedModuleStoreSetup(CourseComparisonTest, OpenEdxEventsTestMixin): """ Quasi-superclass which tests Location based apps against both split and mongo dbs (Locator and Location-based dbs) @@ -109,6 +112,20 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest): ], 'xblock_mixins': modulestore_options['xblock_mixins'], } + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.content_authoring.xblock.deleted.v1", + "org.openedx.content_authoring.xblock.published.v1", + ] + + @classmethod + def setUpClass(cls): + """ + Set up class method for the Test class. + This method starts manually events isolation. Explanation here: + openedx/core/djangoapps/user_authn/views/tests/test_events.py#L44 + """ + super().setUpClass() + cls.start_events_isolation() def setUp(self): """ @@ -724,6 +741,71 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup): self.store.delete_item(vertical.location, self.user_id) assert not self._has_changes(sequential.location) + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) + def test_xblock_publish_event(self, default_ms): + """ + Check that XBLOCK_PUBLISHED event is sent when xblock is published. + """ + self.initdb(default_ms) + event_receiver = Mock() + XBLOCK_PUBLISHED.connect(event_receiver) + + test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id) + + # create sequential and vertical to test against + sequential = self.store.create_child(self.user_id, test_course.location, 'sequential', 'test_sequential') + self.store.create_child(self.user_id, sequential.location, 'vertical', 'test_vertical') + + # publish sequential changes + self.store.publish(sequential.location, self.user_id) + + event_receiver.assert_called() + self.assertDictContainsSubset( + { + "signal": XBLOCK_PUBLISHED, + "sender": None, + "xblock_info": XBlockData( + usage_key=sequential.location, + block_type=sequential.location.block_type, + ), + }, + event_receiver.call_args.kwargs + ) + + @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) + def test_xblock_delete_event(self, default_ms): + """ + Check that XBLOCK_DELETED event is sent when xblock is deleted. + """ + self.initdb(default_ms) + event_receiver = Mock() + XBLOCK_DELETED.connect(event_receiver) + + test_course = self.store.create_course('test_org', 'test_course', 'test_run', self.user_id) + + # create sequential and vertical to test against + sequential = self.store.create_child(self.user_id, test_course.location, 'sequential', 'test_sequential') + vertical = self.store.create_child(self.user_id, sequential.location, 'vertical', 'test_vertical') + + # publish sequential changes + self.store.publish(sequential.location, self.user_id) + + # delete vertical + self.store.delete_item(vertical.location, self.user_id) + + event_receiver.assert_called() + self.assertDictContainsSubset( + { + "signal": XBLOCK_DELETED, + "sender": None, + "xblock_info": XBlockData( + usage_key=vertical.location, + block_type=vertical.location.block_type, + ), + }, + event_receiver.call_args.kwargs + ) + def setup_has_changes(self, default_ms): """ Common set up for has_changes tests below.