diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8a3c963d..4b79379d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,15 @@ Change Log Unreleased ---------- +[8.3.0] - 2023-07-10 +-------------------- +Changed +~~~~~~~ +* Added new XBLOCK_CREATED and XBLOCK_UPDATED events in content_authoring. +* Added new COURSE_CREATED event in content_authoring. +* Added new CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_UPDATED and CONTENT_LIBRARY_DELETED events in content_authoring. +* Added new LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_UPDATED and LIBRARY_BLOCK_DELETED events in content_authoring. + [8.2.0] - 2023-06-08 -------------------- Changed diff --git a/openedx_events/__init__.py b/openedx_events/__init__.py index a6dcc808..5dbea635 100644 --- a/openedx_events/__init__.py +++ b/openedx_events/__init__.py @@ -5,4 +5,4 @@ more information about the project. """ -__version__ = "8.2.0" +__version__ = "8.3.0" diff --git a/openedx_events/content_authoring/data.py b/openedx_events/content_authoring/data.py index f9dcc872..bf6d603e 100644 --- a/openedx_events/content_authoring/data.py +++ b/openedx_events/content_authoring/data.py @@ -12,6 +12,19 @@ import attr from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 + + +@attr.s(frozen=True) +class CourseData: + """ + Attributes defined for Open edX Course object. + + Arguments: + course_key (CourseKey): identifier of the Course object. + """ + + course_key = attr.ib(type=CourseKey) @attr.s(frozen=True) @@ -65,10 +78,13 @@ class XBlockData: Arguments: usage_key (UsageKey): identifier of the XBlock object. block_type (str): type of block. + version (UsageKey): identifier of the XBlock object with branch and version data (optional). This + could be used to get the exact version of the XBlock object. """ usage_key = attr.ib(type=UsageKey) block_type = attr.ib(type=str) + version = attr.ib(type=UsageKey, default=None, kw_only=True) @attr.s(frozen=True) @@ -136,3 +152,31 @@ class CertificateConfigData: title = attr.ib(type=str) signatories = attr.ib(type=List[CertificateSignatoryData], factory=list) is_active = attr.ib(type=bool, default=False) + + +@attr.s(frozen=True) +class ContentLibraryData: + """ + Data about changed ContentLibrary. + + Arguments: + library_key (LibraryLocatorV2): a key that represents a Blockstore-based content library. + update_blocks (bool): flag that indicates whether the content library blocks indexes should be updated + """ + + library_key = attr.ib(type=LibraryLocatorV2) + update_blocks = attr.ib(type=bool, default=False) + + +@attr.s(frozen=True) +class LibraryBlockData: + """ + Data about changed LibraryBlock. + + Arguments: + library_key (LibraryLocatorV2): a key that represents a Blockstore-based content library. + usage_key (LibraryUsageLocatorV2): a key that represents a XBlock in a Blockstore-based content library. + """ + + library_key = attr.ib(type=LibraryLocatorV2) + usage_key = attr.ib(type=LibraryUsageLocatorV2) diff --git a/openedx_events/content_authoring/signals.py b/openedx_events/content_authoring/signals.py index 8421cb8b..a0323f4e 100644 --- a/openedx_events/content_authoring/signals.py +++ b/openedx_events/content_authoring/signals.py @@ -7,11 +7,13 @@ They also must comply with the payload definition specified in docs/decisions/0003-events-payload.rst """ - from openedx_events.content_authoring.data import ( CertificateConfigData, + ContentLibraryData, CourseCatalogData, + CourseData, DuplicatedXBlockData, + LibraryBlockData, XBlockData, ) from openedx_events.tooling import OpenEdxPublicSignal @@ -27,6 +29,27 @@ } ) +# .. event_type: org.openedx.content_authoring.xblock.created.v1 +# .. event_name: XBLOCK_CREATED +# .. event_description: Fired when an XBlock is created. +# .. event_data: XBlockData +XBLOCK_CREATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.xblock.created.v1", + data={ + "xblock_info": XBlockData, + } +) + +# .. event_type: org.openedx.content_authoring.xblock.updated.v1 +# .. event_name: XBLOCK_UPDATED +# .. event_description: Fired when an XBlock is updated. +# .. event_data: XBlockData +XBLOCK_UPDATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.xblock.updated.v1", + data={ + "xblock_info": XBlockData, + } +) # .. event_type: org.openedx.content_authoring.xblock.published.v1 # .. event_name: XBLOCK_PUBLISHED @@ -92,3 +115,80 @@ "certificate_config": CertificateConfigData, } ) + +# .. event_type: org.openedx.content_authoring.course.created.v1 +# .. event_name: COURSE_CREATED +# .. event_description: emitted when a course is created +# .. event_data: CourseData +COURSE_CREATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.course.created.v1", + data={ + "course": CourseData, + } +) + +# .. event_type: org.openedx.content_authoring.content_library.created.v1 +# .. event_name: CONTENT_LIBRARY_CREATED +# .. event_description: emitted when a content library is created +# .. event_data: ContentLibraryData +CONTENT_LIBRARY_CREATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.content_library.created.v1", + data={ + "content_library": ContentLibraryData, + } +) + +# .. event_type: org.openedx.content_authoring.content_library.updated.v1 +# .. event_name: CONTENT_LIBRARY_UPDATED +# .. event_description: emitted when a content library is updated +# .. event_data: ContentLibraryData +CONTENT_LIBRARY_UPDATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.content_library.updated.v1", + data={ + "content_library": ContentLibraryData, + } +) + +# .. event_type: org.openedx.content_authoring.content_library.deleted.v1 +# .. event_name: CONTENT_LIBRARY_DELETED +# .. event_description: emitted when a content library is deleted +# .. event_data: ContentLibraryData +CONTENT_LIBRARY_DELETED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.content_library.deleted.v1", + data={ + "content_library": ContentLibraryData, + } +) + +# .. event_type: org.openedx.content_authoring.library_block.created.v1 +# .. event_name: LIBRARY_BLOCK_CREATED +# .. event_description: emitted when a library block is created +# .. event_data: LibraryBlockData +LIBRARY_BLOCK_CREATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.library_block.created.v1", + data={ + "library_block": LibraryBlockData, + } +) + +# .. event_type: org.openedx.content_authoring.library_block.updated.v1 +# .. event_name: LIBRARY_BLOCK_UPDATED +# .. event_description: emitted when a library block is updated +# .. event_data: LibraryBlockData +LIBRARY_BLOCK_UPDATED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.library_block.updated.v1", + data={ + "library_block": LibraryBlockData, + } +) + +# .. event_type: org.openedx.content_authoring.library_block.deleted.v1 +# .. event_name: LIBRARY_BLOCK_DELETED +# .. event_description: emitted when a library block is deleted +# .. event_data: LibraryBlockData +LIBRARY_BLOCK_DELETED = OpenEdxPublicSignal( + event_type="org.openedx.content_authoring.library_block.deleted.v1", + data={ + "library_block": LibraryBlockData, + } +) diff --git a/openedx_events/event_bus/avro/custom_serializers.py b/openedx_events/event_bus/avro/custom_serializers.py index 2b18905f..37a8a150 100644 --- a/openedx_events/event_bus/avro/custom_serializers.py +++ b/openedx_events/event_bus/avro/custom_serializers.py @@ -6,6 +6,7 @@ from datetime import datetime from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.event_bus.avro.types import PYTHON_TYPE_TO_AVRO_MAPPING @@ -90,4 +91,48 @@ def deserialize(data: str): return UsageKey.from_string(data) -DEFAULT_CUSTOM_SERIALIZERS = [CourseKeyAvroSerializer, DatetimeAvroSerializer, UsageKeyAvroSerializer] +class LibraryLocatorV2AvroSerializer(BaseCustomTypeAvroSerializer): + """ + CustomTypeAvroSerializer for LibraryLocatorV2 class. + """ + + cls = LibraryLocatorV2 + 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 LibraryLocatorV2.from_string(data) + + +class LibraryUsageLocatorV2AvroSerializer(BaseCustomTypeAvroSerializer): + """ + CustomTypeAvroSerializer for LibraryUsageLocatorV2 class. + """ + + cls = LibraryUsageLocatorV2 + 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 LibraryUsageLocatorV2.from_string(data) + + +DEFAULT_CUSTOM_SERIALIZERS = [ + CourseKeyAvroSerializer, + DatetimeAvroSerializer, + LibraryLocatorV2AvroSerializer, + LibraryUsageLocatorV2AvroSerializer, + UsageKeyAvroSerializer, +] diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+created+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+created+v1_schema.avsc new file mode 100644 index 00000000..eccd82e6 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+created+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "content_library", + "type": { + "name": "ContentLibraryData", + "type": "record", + "fields": [ + { + "name": "library_key", + "type": "string" + }, + { + "name": "update_blocks", + "type": "boolean" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.content_library.created.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+deleted+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+deleted+v1_schema.avsc new file mode 100644 index 00000000..117eee3b --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+deleted+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "content_library", + "type": { + "name": "ContentLibraryData", + "type": "record", + "fields": [ + { + "name": "library_key", + "type": "string" + }, + { + "name": "update_blocks", + "type": "boolean" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.content_library.deleted.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+updated+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+updated+v1_schema.avsc new file mode 100644 index 00000000..6a0dfdd7 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+content_library+updated+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "content_library", + "type": { + "name": "ContentLibraryData", + "type": "record", + "fields": [ + { + "name": "library_key", + "type": "string" + }, + { + "name": "update_blocks", + "type": "boolean" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.content_library.updated.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+course+created+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+course+created+v1_schema.avsc new file mode 100644 index 00000000..ce082ea0 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+course+created+v1_schema.avsc @@ -0,0 +1,21 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "course", + "type": { + "name": "CourseData", + "type": "record", + "fields": [ + { + "name": "course_key", + "type": "string" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.course.created.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+created+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+created+v1_schema.avsc new file mode 100644 index 00000000..c7206ca2 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+created+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "library_block", + "type": { + "name": "LibraryBlockData", + "type": "record", + "fields": [ + { + "name": "library_key", + "type": "string" + }, + { + "name": "usage_key", + "type": "string" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.library_block.created.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+deleted+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+deleted+v1_schema.avsc new file mode 100644 index 00000000..ae7f5822 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+deleted+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "library_block", + "type": { + "name": "LibraryBlockData", + "type": "record", + "fields": [ + { + "name": "library_key", + "type": "string" + }, + { + "name": "usage_key", + "type": "string" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.library_block.deleted.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+updated+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+updated+v1_schema.avsc new file mode 100644 index 00000000..b6e2d4a5 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+library_block+updated+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "library_block", + "type": { + "name": "LibraryBlockData", + "type": "record", + "fields": [ + { + "name": "library_key", + "type": "string" + }, + { + "name": "usage_key", + "type": "string" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.library_block.updated.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+xblock+created+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+xblock+created+v1_schema.avsc new file mode 100644 index 00000000..e07936a4 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+xblock+created+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "xblock_info", + "type": { + "name": "XBlockData", + "type": "record", + "fields": [ + { + "name": "usage_key", + "type": "string" + }, + { + "name": "block_type", + "type": "string" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.xblock.created.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+xblock+updated+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+xblock+updated+v1_schema.avsc new file mode 100644 index 00000000..24984868 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+content_authoring+xblock+updated+v1_schema.avsc @@ -0,0 +1,25 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "xblock_info", + "type": { + "name": "XBlockData", + "type": "record", + "fields": [ + { + "name": "usage_key", + "type": "string" + }, + { + "name": "block_type", + "type": "string" + } + ] + } + } + ], + "namespace": "org.openedx.content_authoring.xblock.updated.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/test_avro.py b/openedx_events/event_bus/avro/tests/test_avro.py index da3a7aba..bf2f1836 100644 --- a/openedx_events/event_bus/avro/tests/test_avro.py +++ b/openedx_events/event_bus/avro/tests/test_avro.py @@ -9,6 +9,7 @@ from fastavro.repository.base import SchemaRepositoryError from fastavro.schema import load_schema from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.event_bus.avro.deserializer import AvroSignalDeserializer, deserialize_bytes_to_event_data from openedx_events.event_bus.avro.serializer import AvroSignalSerializer, serialize_event_data_to_bytes @@ -102,6 +103,8 @@ def generate_test_event_data_for_data_type(data_type): # pragma: no cover UsageKey: UsageKey.from_string( "block-v1:edx+DemoX+Demo_course+type@video+block@UaEBjyMjcLW65gaTXggB93WmvoxGAJa0JeHRrDThk", ), + LibraryLocatorV2: LibraryLocatorV2.from_string('lib:MITx:reallyhardproblems'), + LibraryUsageLocatorV2: LibraryUsageLocatorV2.from_string('lb:MITx:reallyhardproblems:problem:problem1'), List[int]: [1, 2, 3], datetime: datetime.now(), } diff --git a/openedx_events/event_bus/avro/tests/test_deserializer.py b/openedx_events/event_bus/avro/tests/test_deserializer.py index 9b8f6567..e7037998 100644 --- a/openedx_events/event_bus/avro/tests/test_deserializer.py +++ b/openedx_events/event_bus/avro/tests/test_deserializer.py @@ -5,6 +5,7 @@ from unittest import TestCase from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.event_bus.avro.deserializer import AvroSignalDeserializer, deserialize_bytes_to_event_data from openedx_events.event_bus.avro.tests.test_utilities import ( @@ -119,6 +120,32 @@ def test_default_usagekey_deserialization(self): self.assertIsInstance(usage_key_deserialized, UsageKey) self.assertEqual(usage_key_deserialized, usage_key) + def test_default_librarylocatorv2_deserialization(self): + """ + Test deserialization of LibraryLocatorV2 + """ + SIGNAL = create_simple_signal({"library_key": LibraryLocatorV2}) + deserializer = AvroSignalDeserializer(SIGNAL) + library_key = LibraryLocatorV2.from_string("lib:MITx:reallyhardproblems") + as_dict = {"library_key": str(library_key)} + event_data = deserializer.from_dict(as_dict) + library_key_deserialized = event_data["library_key"] + self.assertIsInstance(library_key_deserialized, LibraryLocatorV2) + self.assertEqual(library_key_deserialized, library_key) + + def test_default_libraryusagelocatorv2_deserialization(self): + """ + Test deserialization of LibraryUsageLocatorV2 + """ + SIGNAL = create_simple_signal({"usage_key": LibraryUsageLocatorV2}) + deserializer = AvroSignalDeserializer(SIGNAL) + usage_key = LibraryUsageLocatorV2.from_string("lb:MITx:reallyhardproblems:problem:problem1") + 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, LibraryUsageLocatorV2) + 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 6b2dc367..8e8fba0e 100644 --- a/openedx_events/event_bus/avro/tests/test_serializer.py +++ b/openedx_events/event_bus/avro/tests/test_serializer.py @@ -6,6 +6,7 @@ import pytest from django.test import TestCase from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.event_bus.avro.serializer import AvroSignalSerializer, serialize_event_data_to_bytes from openedx_events.event_bus.avro.tests.test_utilities import ( @@ -119,6 +120,28 @@ def test_default_usagekey_serialization(self): data_dict = serializer.to_dict(test_data) self.assertDictEqual(data_dict, {"usage_key": str(usage_key)}) + def test_default_librarylocatorv2_serialization(self): + """ + Test serialization of LibraryLocatorV2 + """ + SIGNAL = create_simple_signal({"library_key": LibraryLocatorV2}) + serializer = AvroSignalSerializer(SIGNAL) + library_key = LibraryLocatorV2.from_string("lib:MITx:reallyhardproblems") + test_data = {"library_key": library_key} + data_dict = serializer.to_dict(test_data) + self.assertDictEqual(data_dict, {"library_key": str(library_key)}) + + def test_default_libraryusagelocatorv2_serialization(self): + """ + Test serialization of LibraryUsageLocatorV2 + """ + SIGNAL = create_simple_signal({"usage_key": LibraryUsageLocatorV2}) + serializer = AvroSignalSerializer(SIGNAL) + usage_key = LibraryUsageLocatorV2.from_string("lb:MITx:reallyhardproblems:problem:problem1") + 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})