From 75216dee9037af92c5eb8817f75217471c2c42ef Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 3 Jan 2023 20:24:54 +0530 Subject: [PATCH] feat: adds events to handle changes in xblocks (#143) Adds events and data classes to allow consumers to react on xblock events like published, deleted and duplicated. The idea is to fire these events in delete_item, publish_item and _duplicate_item and update skills related to the xblock in taxonomy-connector (part of course-discovery). --- CHANGELOG.rst | 8 ++++ openedx_events/__init__.py | 2 +- openedx_events/content_authoring/data.py | 30 ++++++++++++- openedx_events/content_authoring/signals.py | 43 ++++++++++++++++++- .../event_bus/avro/custom_serializers.py | 23 +++++++++- .../event_bus/avro/tests/test_avro.py | 5 ++- .../event_bus/avro/tests/test_deserializer.py | 17 +++++++- .../event_bus/avro/tests/test_serializer.py | 15 ++++++- 8 files changed, 135 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f35a320e..e47dc0f2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,14 @@ Change Log Unreleased ---------- +[4.1.0] - 2023-01-03 +--------------------- +Added +~~~~~~~ +* Added new XBLOCK_PUBLISHED, XBLOCK_DUPLICATED and XBLOCK_DELETED signals in content_authoring. +* Added XBlockData and DuplicatedXBlockData classes +* Added custom UsageKeyAvroSerializer for opaque_keys UsageKey. + [4.0.0] - 2022-12-01 -------------------- Changed diff --git a/openedx_events/__init__.py b/openedx_events/__init__.py index 79dd1d2b..dff0d4f8 100644 --- a/openedx_events/__init__.py +++ b/openedx_events/__init__.py @@ -5,4 +5,4 @@ more information about the project. """ -__version__ = "4.0.0" +__version__ = "4.1.0" diff --git a/openedx_events/content_authoring/data.py b/openedx_events/content_authoring/data.py index 193ce88c..0c89d2dd 100644 --- a/openedx_events/content_authoring/data.py +++ b/openedx_events/content_authoring/data.py @@ -10,7 +10,7 @@ from datetime import datetime import attr -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey @attr.s(frozen=True) @@ -54,3 +54,31 @@ class CourseCatalogData: schedule_data = attr.ib(type=CourseScheduleData) hidden = attr.ib(type=bool, default=False) invitation_only = attr.ib(type=bool, default=False) + + +@attr.s(frozen=True) +class XBlockData: + """ + Data about changed XBlock. + + Arguments: + usage_key (UsageKey): identifier of the XBlock object. + block_type (str): type of block. + """ + + usage_key = attr.ib(type=UsageKey) + block_type = attr.ib(type=str) + + +@attr.s(frozen=True) +class DuplicatedXBlockData(XBlockData): + """ + Data about duplicated XBlock. + + This class extends XBlockData to include source_usage_key. + + Arguments: + source_usage_key (UsageKey): identifier of the source XBlock object. + """ + + source_usage_key = attr.ib(type=UsageKey) diff --git a/openedx_events/content_authoring/signals.py b/openedx_events/content_authoring/signals.py index e8e36f20..f0b85c04 100644 --- a/openedx_events/content_authoring/signals.py +++ b/openedx_events/content_authoring/signals.py @@ -8,7 +8,7 @@ docs/decisions/0003-events-payload.rst """ -from openedx_events.content_authoring.data import CourseCatalogData +from openedx_events.content_authoring.data import CourseCatalogData, DuplicatedXBlockData, XBlockData from openedx_events.tooling import OpenEdxPublicSignal # .. event_type: org.openedx.content_authoring.course.catalog_info.changed.v1 @@ -21,3 +21,44 @@ "catalog_info": CourseCatalogData, } ) + + +# .. event_type: org.openedx.content_authoring.xblock.published.v1 +# .. event_name: XBLOCK_PUBLISHED +# .. event_description: Fired when an XBlock is published. If a parent block +# with changes in one or more child blocks is published, only a single +# XBLOCK_PUBLISHED event is fired with parent block details. +# For example: If a section is published with changes in multiple units, +# only a single event is fired with section details like : +# `XBlockData(usage_key="section-usage-key", block_type="chapter")` +# .. event_data: XBlockData +XBLOCK_PUBLISHED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.xblock.published.v1", + data={ + "xblock_info": XBlockData, + } +) + + +# .. event_type: org.openedx.content_authoring.xblock.deleted.v1 +# .. event_name: XBLOCK_DELETED +# .. event_description: Fired when an XBlock is deleted. +# .. event_data: XBlockData +XBLOCK_DELETED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.xblock.deleted.v1", + data={ + "xblock_info": XBlockData, + } +) + + +# .. event_type: org.openedx.content_authoring.xblock.duplicated.v1 +# .. event_name: XBLOCK_DUPLICATED +# .. event_description: Fired when an XBlock is duplicated in Studio. +# .. event_data: DuplicatedXBlockData +XBLOCK_DUPLICATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.xblock.duplicated.v1", + data={ + "xblock_info": DuplicatedXBlockData, + } +) diff --git a/openedx_events/event_bus/avro/custom_serializers.py b/openedx_events/event_bus/avro/custom_serializers.py index 0cf62e1f..2b18905f 100644 --- a/openedx_events/event_bus/avro/custom_serializers.py +++ b/openedx_events/event_bus/avro/custom_serializers.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from datetime import datetime -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_events.event_bus.avro.types import PYTHON_TYPE_TO_AVRO_MAPPING @@ -71,4 +71,23 @@ def deserialize(data: str): return datetime.fromisoformat(data) -DEFAULT_CUSTOM_SERIALIZERS = [CourseKeyAvroSerializer, DatetimeAvroSerializer] +class UsageKeyAvroSerializer(BaseCustomTypeAvroSerializer): + """ + CustomTypeAvroSerializer for UsageKey class. + """ + + cls = UsageKey + field_type = PYTHON_TYPE_TO_AVRO_MAPPING[str] + + @staticmethod + def serialize(obj) -> str: + """Serialize obj into string.""" + return str(obj) + + @staticmethod + def deserialize(data: str): + """Deserialize string into obj.""" + return UsageKey.from_string(data) + + +DEFAULT_CUSTOM_SERIALIZERS = [CourseKeyAvroSerializer, DatetimeAvroSerializer, UsageKeyAvroSerializer] diff --git a/openedx_events/event_bus/avro/tests/test_avro.py b/openedx_events/event_bus/avro/tests/test_avro.py index 737379c7..6abd65e1 100644 --- a/openedx_events/event_bus/avro/tests/test_avro.py +++ b/openedx_events/event_bus/avro/tests/test_avro.py @@ -2,7 +2,7 @@ from datetime import datetime from unittest import TestCase -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey # Each new folder with signals must be manually imported in order for the signals to be cached # and used in the unit tests. Using 'disable=reimported' with pylint will work, @@ -49,6 +49,9 @@ def generate_test_event_data_for_data_type(data_type): str: "default", float: 1.0, CourseKey: CourseKey.from_string("course-v1:edX+DemoX.1+2014"), + UsageKey: UsageKey.from_string( + "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk", + ), datetime: datetime.now(), } for attribute in data_type.__attrs_attrs__: diff --git a/openedx_events/event_bus/avro/tests/test_deserializer.py b/openedx_events/event_bus/avro/tests/test_deserializer.py index b5a5a539..6d6f1118 100644 --- a/openedx_events/event_bus/avro/tests/test_deserializer.py +++ b/openedx_events/event_bus/avro/tests/test_deserializer.py @@ -3,7 +3,7 @@ from datetime import datetime from unittest import TestCase -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_events.event_bus.avro.deserializer import AvroSignalDeserializer from openedx_events.event_bus.avro.tests.test_utilities import ( @@ -102,6 +102,21 @@ def test_default_coursekey_deserialization(self): self.assertIsInstance(course_deserialized, CourseKey) self.assertEqual(course_deserialized, course_key) + def test_default_usagekey_deserialization(self): + """ + Test deserialization of UsageKey + """ + SIGNAL = create_simple_signal({"usage_key": UsageKey}) + deserializer = AvroSignalDeserializer(SIGNAL) + usage_key = UsageKey.from_string( + "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk", + ) + as_dict = {"usage_key": str(usage_key)} + event_data = deserializer.from_dict(as_dict) + usage_key_deserialized = event_data["usage_key"] + self.assertIsInstance(usage_key_deserialized, UsageKey) + self.assertEqual(usage_key_deserialized, usage_key) + def test_deserialization_with_custom_serializer(self): SIGNAL = create_simple_signal({"test_data": NonAttrs}) deserializer = SpecialDeserializer(SIGNAL) diff --git a/openedx_events/event_bus/avro/tests/test_serializer.py b/openedx_events/event_bus/avro/tests/test_serializer.py index 83b52f90..15420470 100644 --- a/openedx_events/event_bus/avro/tests/test_serializer.py +++ b/openedx_events/event_bus/avro/tests/test_serializer.py @@ -5,7 +5,7 @@ import pytest from django.test import TestCase -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_events.event_bus.avro.serializer import AvroSignalSerializer from openedx_events.event_bus.avro.tests.test_utilities import ( @@ -105,6 +105,19 @@ def test_default_coursekey_serialization(self): data_dict = serializer.to_dict(test_data) self.assertDictEqual(data_dict, {"course": str(course_key)}) + def test_default_usagekey_serialization(self): + """ + Test serialization of UsageKey + """ + SIGNAL = create_simple_signal({"usage_key": UsageKey}) + serializer = AvroSignalSerializer(SIGNAL) + usage_key = UsageKey.from_string( + "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk", + ) + test_data = {"usage_key": usage_key} + data_dict = serializer.to_dict(test_data) + self.assertDictEqual(data_dict, {"usage_key": str(usage_key)}) + def test_serialization_with_custom_serializer(self): SIGNAL = create_simple_signal({"test_data": NonAttrs})