diff --git a/.gitignore b/.gitignore index 280755e1..8e0f6d72 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ requirements/private.txt # IDA cruft .idea + +# emacs backup files +*~ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6c1383f9..76d6137c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,17 @@ Change Log Unreleased ---------- +[9.11.0] - 2024-05-15 +--------------------- + +Added +~~~~~~~ + +* Added new enterprise signals ``LEDGER_TRANSACTION_CREATED``, ``LEDGER_TRANSACTION_COMMITTED``, + ``LEDGER_TRANSACTION_FAILED``, and ``LEDGER_TRANSACTION_REVERSED``. +* Added a ``UuidAvroSerializer`` to serialize uuid fields. +* Added ``isort`` make target. + [9.10.0] - 2024-05-08 --------------------- diff --git a/Makefile b/Makefile index 34a7bbb6..af1991aa 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,9 @@ test-all: quality ## run tests on every supported Python/Django combination validate: quality test ## run tests and quality checks +isort: ## fix improperly sorted imports + isort test_utils openedx_events manage.py setup.py + selfcheck: ## check that the Makefile is well-formed @echo "The Makefile is well-formed." diff --git a/openedx_events/__init__.py b/openedx_events/__init__.py index 6eb6186c..99ce4e4a 100644 --- a/openedx_events/__init__.py +++ b/openedx_events/__init__.py @@ -5,4 +5,4 @@ more information about the project. """ -__version__ = "9.10.0" +__version__ = "9.11.0" diff --git a/openedx_events/enterprise/data.py b/openedx_events/enterprise/data.py index 23c3dd01..c3b3d1f6 100644 --- a/openedx_events/enterprise/data.py +++ b/openedx_events/enterprise/data.py @@ -4,8 +4,11 @@ These attributes follow the form of attr objects specified in OEP-49 data pattern. """ +from datetime import datetime +from uuid import UUID import attr +from opaque_keys.edx.keys import CourseKey @attr.s(frozen=True) @@ -22,3 +25,72 @@ class SubsidyRedemption: subsidy_identifier = attr.ib(type=str) content_key = attr.ib(type=str) lms_user_id = attr.ib(type=int) + + +@attr.s(frozen=True) +class BaseLedgerTransaction: + """ + Defines the common attributes of the transaction classes below. + """ + + uuid = attr.ib(type=UUID) + created = attr.ib(type=datetime) + modified = attr.ib(type=datetime) + idempotency_key = attr.ib(type=str) + quantity = attr.ib(type=int) + state = attr.ib(type=str) + + +@attr.s(frozen=True) +class LedgerTransactionReversal(BaseLedgerTransaction): + """ + Attributes of an ``openedx_ledger.Reversal`` record. + + A ``Reversal`` is a model that represents the "undo-ing" of a ``Transaction`` (see below). It's primarily + used within the domain of edX Enterprise for recording unenrollments and refunds of subsidized + enterprise enrollments. + https://github.com/openedx/openedx-ledger/blob/master/openedx_ledger/models.py + + Arguments: + uuid (str): Primary identifier of the record. + created (datetime): When the record was created. + modified (datetime): When the record was last modified. + idempotency_key (str): Client-generated unique value to achieve idempotency of operations. + quantity (int): How many units of value this reversal represents (e.g. USD cents). + state (str): Current lifecyle state of the record, one of (created, pending, committed, failed). + """ + + +@attr.s(frozen=True) +class LedgerTransaction(BaseLedgerTransaction): + """ + Attributes of an ``openedx_ledger.Transaction`` record. + + A ``Transaction`` is a model that represents value moving in or out of a ``Ledger``. It's primarily + used within the domain of edX Enterprise for recording the redemption of subsidized enrollments. + https://github.com/openedx/openedx-ledger/blob/master/openedx_ledger/models.py + + Arguments: + uuid (UUID): Primary identifier of the Transaction. + created (datetime): When the record was created. + modified (datetime): When the record was last modified. + idempotency_key (str): Client-generated unique value to achieve idempotency of operations. + quantity (int): How many units of value this transaction represents (e.g. USD cents). + state (str): Current lifecyle state of the record, one of (created, pending, committed, failed). + ledger_uuid (UUID): The primary identifier of this Transaction's ledger object. + subsidy_access_policy_uuid (UUID): The primary identifier of the subsidy access policy for this transaction. + lms_user_id (int): The LMS user id of the user associated with this transaction. + content_key (CourseKey): The course (run) key associated with this transaction. + parent_content_key (str): The parent (just course, not run) key for the course key. + fulfillment_identifier (str): The identifier of the subsidized enrollment record for a learner, + generated durning enrollment. + reversal (LedgerTransactionReversal): Any reversal associated with this transaction. + """ + + ledger_uuid = attr.ib(type=UUID) + subsidy_access_policy_uuid = attr.ib(type=UUID) + lms_user_id = attr.ib(type=int) + content_key = attr.ib(type=CourseKey) + parent_content_key = attr.ib(type=str, default=None) + fulfillment_identifier = attr.ib(type=str, default=None) + reversal = attr.ib(type=LedgerTransactionReversal, default=None) diff --git a/openedx_events/enterprise/signals.py b/openedx_events/enterprise/signals.py index 287a3849..50c8a3b7 100644 --- a/openedx_events/enterprise/signals.py +++ b/openedx_events/enterprise/signals.py @@ -8,7 +8,7 @@ docs/decisions/0003-events-payload.rst """ -from openedx_events.enterprise.data import SubsidyRedemption +from openedx_events.enterprise.data import LedgerTransaction, SubsidyRedemption from openedx_events.tooling import OpenEdxPublicSignal # .. event_type: org.openedx.enterprise.subsidy.redeemed.v1 @@ -32,3 +32,55 @@ "redemption": SubsidyRedemption, } ) + + +# .. event_type: org.openedx.enterprise.subsidy_ledger_transaction.created.v1 +# .. event_name: LEDGER_TRANSACTION_CREATED +# .. event_description: emitted when an enterprise ledger transaction is created. +# See: https://github.com/openedx/openedx-ledger/tree/main/docs/decisions +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_CREATED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise.subsidy_ledger_transaction.created.v1", + data={ + "ledger_transaction": LedgerTransaction, + } +) + + +# .. event_type: org.openedx.enterprise.subsidy_ledger_transaction.committed.v1 +# .. event_name: LEDGER_TRANSACTION_COMMITTED +# .. event_description: emitted when an enterprise ledger transaction is committed. +# See: https://github.com/openedx/openedx-ledger/tree/main/docs/decisions +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_COMMITTED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise.subsidy_ledger_transaction.committed.v1", + data={ + "ledger_transaction": LedgerTransaction, + } +) + + +# .. event_type: org.openedx.enterprise.subsidy_ledger_transaction.failed.v1 +# .. event_name: LEDGER_TRANSACTION_FAILED +# .. event_description: emitted when an enterprise ledger transaction fails. +# See: https://github.com/openedx/openedx-ledger/tree/main/docs/decisions +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_FAILED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise.subsidy_ledger_transaction.failed.v1", + data={ + "ledger_transaction": LedgerTransaction, + } +) + + +# .. event_type: org.openedx.enterprise.subsidy_ledger_transaction.reversed.v1 +# .. event_name: LEDGER_TRANSACTION_REVERSED +# .. event_description: emitted when an enterprise ledger transaction is reversed. +# See: https://github.com/openedx/openedx-ledger/tree/main/docs/decisions +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_REVERSED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise.subsidy_ledger_transaction.reversed.v1", + data={ + "ledger_transaction": LedgerTransaction, + } +) diff --git a/openedx_events/event_bus/avro/custom_serializers.py b/openedx_events/event_bus/avro/custom_serializers.py index d41616c2..d3503011 100644 --- a/openedx_events/event_bus/avro/custom_serializers.py +++ b/openedx_events/event_bus/avro/custom_serializers.py @@ -4,6 +4,7 @@ """ from abc import ABC, abstractmethod from datetime import datetime +from uuid import UUID from ccx_keys.locator import CCXLocator from opaque_keys.edx.keys import CourseKey, UsageKey @@ -149,6 +150,27 @@ def deserialize(data: str): return LibraryUsageLocatorV2.from_string(data) +class UuidAvroSerializer(BaseCustomTypeAvroSerializer): + """ + CustomTypeAvroSerializer for the UUID class. + + https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/avro-format.md#21-type-system-mapping + """ + + cls = UUID + 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 UUID(data) + + DEFAULT_CUSTOM_SERIALIZERS = [ CourseKeyAvroSerializer, CcxCourseLocatorAvroSerializer, @@ -156,4 +178,5 @@ def deserialize(data: str): LibraryLocatorV2AvroSerializer, LibraryUsageLocatorV2AvroSerializer, UsageKeyAvroSerializer, + UuidAvroSerializer, ] diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+committed+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+committed+v1_schema.avsc new file mode 100644 index 00000000..9eb1146e --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+committed+v1_schema.avsc @@ -0,0 +1,110 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "ledger_transaction", + "type": { + "name": "LedgerTransaction", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + }, + { + "name": "ledger_uuid", + "type": "string" + }, + { + "name": "subsidy_access_policy_uuid", + "type": "string" + }, + { + "name": "lms_user_id", + "type": "long" + }, + { + "name": "content_key", + "type": "string" + }, + { + "name": "parent_content_key", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "fulfillment_identifier", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "reversal", + "type": [ + "null", + { + "name": "LedgerTransactionReversal", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + } + ] + } + ], + "default": null + } + ] + } + } + ], + "namespace": "org.openedx.enterprise.subsidy_ledger_transaction.committed.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+created+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+created+v1_schema.avsc new file mode 100644 index 00000000..f32785e6 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+created+v1_schema.avsc @@ -0,0 +1,110 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "ledger_transaction", + "type": { + "name": "LedgerTransaction", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + }, + { + "name": "ledger_uuid", + "type": "string" + }, + { + "name": "subsidy_access_policy_uuid", + "type": "string" + }, + { + "name": "lms_user_id", + "type": "long" + }, + { + "name": "content_key", + "type": "string" + }, + { + "name": "parent_content_key", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "fulfillment_identifier", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "reversal", + "type": [ + "null", + { + "name": "LedgerTransactionReversal", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + } + ] + } + ], + "default": null + } + ] + } + } + ], + "namespace": "org.openedx.enterprise.subsidy_ledger_transaction.created.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+failed+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+failed+v1_schema.avsc new file mode 100644 index 00000000..f37d08b6 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+failed+v1_schema.avsc @@ -0,0 +1,110 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "ledger_transaction", + "type": { + "name": "LedgerTransaction", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + }, + { + "name": "ledger_uuid", + "type": "string" + }, + { + "name": "subsidy_access_policy_uuid", + "type": "string" + }, + { + "name": "lms_user_id", + "type": "long" + }, + { + "name": "content_key", + "type": "string" + }, + { + "name": "parent_content_key", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "fulfillment_identifier", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "reversal", + "type": [ + "null", + { + "name": "LedgerTransactionReversal", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + } + ] + } + ], + "default": null + } + ] + } + } + ], + "namespace": "org.openedx.enterprise.subsidy_ledger_transaction.failed.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+reversed+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+reversed+v1_schema.avsc new file mode 100644 index 00000000..fe0d5df1 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise+subsidy_ledger_transaction+reversed+v1_schema.avsc @@ -0,0 +1,110 @@ +{ + "name": "CloudEvent", + "type": "record", + "doc": "Avro Event Format for CloudEvents created with openedx_events/schema", + "fields": [ + { + "name": "ledger_transaction", + "type": { + "name": "LedgerTransaction", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + }, + { + "name": "ledger_uuid", + "type": "string" + }, + { + "name": "subsidy_access_policy_uuid", + "type": "string" + }, + { + "name": "lms_user_id", + "type": "long" + }, + { + "name": "content_key", + "type": "string" + }, + { + "name": "parent_content_key", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "fulfillment_identifier", + "type": [ + "null", + "string" + ], + "default": null + }, + { + "name": "reversal", + "type": [ + "null", + { + "name": "LedgerTransactionReversal", + "type": "record", + "fields": [ + { + "name": "uuid", + "type": "string" + }, + { + "name": "created", + "type": "string" + }, + { + "name": "modified", + "type": "string" + }, + { + "name": "idempotency_key", + "type": "string" + }, + { + "name": "quantity", + "type": "long" + }, + { + "name": "state", + "type": "string" + } + ] + } + ], + "default": null + } + ] + } + } + ], + "namespace": "org.openedx.enterprise.subsidy_ledger_transaction.reversed.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 b42a39e8..4d564b27 100644 --- a/openedx_events/event_bus/avro/tests/test_avro.py +++ b/openedx_events/event_bus/avro/tests/test_avro.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import List from unittest import TestCase +from uuid import UUID, uuid4 from ccx_keys.locator import CCXLocator from fastavro import schemaless_reader, schemaless_writer @@ -109,6 +110,7 @@ def generate_test_event_data_for_data_type(data_type): # pragma: no cover List[int]: [1, 2, 3], datetime: datetime.now(), CCXLocator: CCXLocator(org='edx', course='DemoX', run='Demo_course', ccx='1'), + UUID: uuid4(), } data_dict = {} for attribute in data_type.__attrs_attrs__: diff --git a/openedx_events/event_bus/avro/tests/test_custom_serializers.py b/openedx_events/event_bus/avro/tests/test_custom_serializers.py index ab852fc0..40fec1af 100644 --- a/openedx_events/event_bus/avro/tests/test_custom_serializers.py +++ b/openedx_events/event_bus/avro/tests/test_custom_serializers.py @@ -1,9 +1,10 @@ """Test custom servializers""" from unittest import TestCase +from uuid import UUID, uuid4 from ccx_keys.locator import CCXLocator -from openedx_events.event_bus.avro.custom_serializers import CcxCourseLocatorAvroSerializer +from openedx_events.event_bus.avro.custom_serializers import CcxCourseLocatorAvroSerializer, UuidAvroSerializer class TestCCXLocatorSerailizer(TestCase): @@ -19,7 +20,7 @@ def test_serialize(self): result1 = CcxCourseLocatorAvroSerializer.serialize(obj1) self.assertEqual(result1, expected1) - def test_deseialize(self): + def test_deserialize(self): """ Test case for deserializing CCXLocator object. """ @@ -28,3 +29,26 @@ def test_deseialize(self): expected1 = CCXLocator(org="edx", course="DemoX", run="Demo_course", ccx="1") result1 = CcxCourseLocatorAvroSerializer.deserialize(data1) self.assertEqual(result1, expected1) + + +class TestUuidAvroSerializer(TestCase): + """ + Tests case for Avro UUID de-/serialization. + """ + def test_serialize(self): + """ + Test UUID Avro serialization. + """ + some_uuid = uuid4() + expected_result = str(some_uuid) + actual_result = UuidAvroSerializer.serialize(some_uuid) + self.assertEqual(actual_result, expected_result) + + def test_deserialize(self): + """ + Test UUID Avro de-serialization. + """ + uuid_str = str(uuid4()) + expected_result = UUID(uuid_str) + actual_result = UuidAvroSerializer.deserialize(uuid_str) + self.assertEqual(actual_result, expected_result)