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..802774e1 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,62 @@ 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 LedgerTransactionReversal: + """ + Attributes of an ``openedx_ledger.Reversal`` record. + + 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). + """ + + 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 LedgerTransaction: + """ + Attributes of an ``openedx_ledger.Transaction`` record. + + 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. + """ + + 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) + 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) + fulfillment_identifier = attr.ib(type=str) + reversal = attr.ib(type=LedgerTransactionReversal, default=None) diff --git a/openedx_events/enterprise/signals.py b/openedx_events/enterprise/signals.py index 287a3849..dd8e1d93 100644 --- a/openedx_events/enterprise/signals.py +++ b/openedx_events/enterprise/signals.py @@ -8,12 +8,12 @@ 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 # .. event_name: SUBSIDY_REDEEMED -# .. event_description: emitted when an enterprise subsidy is utilized. +# .. event_description: (deprecated) emitted when an enterprise subsidy is utilized. # .. event_data: SubsidyRedemption SUBSIDY_REDEEMED = OpenEdxPublicSignal( event_type="org.openedx.enterprise.subsidy.redeemed.v1", @@ -24,7 +24,7 @@ # .. event_type: org.openedx.enterprise.subsidy.redemption-reversed.v1 # .. event_name: SUBSIDY_REDEMPTION_REVERSED -# .. event_description: emitted when an enterprise subsidy is reversed. +# .. event_description: (deprecated) emitted when an enterprise subsidy is reversed. # .. event_data: SubsidyRedemption SUBSIDY_REDEMPTION_REVERSED = OpenEdxPublicSignal( event_type="org.openedx.enterprise.subsidy.redemption-reversed.v1", @@ -32,3 +32,51 @@ "redemption": SubsidyRedemption, } ) + + +# .. event_type: org.openedx.enterprise_subsidies.ledger_transaction.created.v1 +# .. event_name: LEDGER_TRANSACTION_CREATED +# .. event_description: emitted when an enterprise ledger transaction is created. +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_CREATED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise_subsidies.ledger_transaction.created.v1", + data={ + "ledger_transaction": LedgerTransaction, + } +) + + +# .. event_type: org.openedx.enterprise_subsidies.ledger_transaction.committed.v1 +# .. event_name: LEDGER_TRANSACTION_COMMITTED +# .. event_description: emitted when an enterprise ledger transaction is committed. +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_COMMITTED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise_subsidies.ledger_transaction.committed.v1", + data={ + "ledger_transaction": LedgerTransaction, + } +) + + +# .. event_type: org.openedx.enterprise_subsidies.ledger_transaction.failed.v1 +# .. event_name: LEDGER_TRANSACTION_FAILED +# .. event_description: emitted when an enterprise ledger transaction fails. +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_FAILED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise_subsidies.ledger_transaction.failed.v1", + data={ + "ledger_transaction": LedgerTransaction, + } +) + + +# .. event_type: org.openedx.enterprise_subsidies.ledger_transaction.reversed.v1 +# .. event_name: LEDGER_TRANSACTION_REVERSED +# .. event_description: emitted when an enterprise ledger transaction is reversed. +# .. event_data: LedgerTransaction +LEDGER_TRANSACTION_REVERSED = OpenEdxPublicSignal( + event_type="org.openedx.enterprise_subsidies.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_subsidies+ledger_transaction+committed+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+committed+v1_schema.avsc new file mode 100644 index 00000000..165b4630 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+committed+v1_schema.avsc @@ -0,0 +1,102 @@ +{ + "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": "string" + }, + { + "name": "fulfillment_identifier", + "type": "string" + }, + { + "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_subsidies.ledger_transaction.committed.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+created+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+created+v1_schema.avsc new file mode 100644 index 00000000..eb84fb49 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+created+v1_schema.avsc @@ -0,0 +1,102 @@ +{ + "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": "string" + }, + { + "name": "fulfillment_identifier", + "type": "string" + }, + { + "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_subsidies.ledger_transaction.created.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+failed+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+failed+v1_schema.avsc new file mode 100644 index 00000000..34205ec8 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+failed+v1_schema.avsc @@ -0,0 +1,102 @@ +{ + "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": "string" + }, + { + "name": "fulfillment_identifier", + "type": "string" + }, + { + "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_subsidies.ledger_transaction.failed.v1" +} \ No newline at end of file diff --git a/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+reversed+v1_schema.avsc b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+reversed+v1_schema.avsc new file mode 100644 index 00000000..ccd43e62 --- /dev/null +++ b/openedx_events/event_bus/avro/tests/schemas/org+openedx+enterprise_subsidies+ledger_transaction+reversed+v1_schema.avsc @@ -0,0 +1,102 @@ +{ + "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": "string" + }, + { + "name": "fulfillment_identifier", + "type": "string" + }, + { + "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_subsidies.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..c07a9536 100644 --- a/openedx_events/event_bus/avro/tests/test_custom_serializers.py +++ b/openedx_events/event_bus/avro/tests/test_custom_serializers.py @@ -1,10 +1,13 @@ """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 ..custom_serializers import UuidAvroSerializer + class TestCCXLocatorSerailizer(TestCase): """Test case for CCXLocator serializer.""" @@ -19,7 +22,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 +31,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) diff --git a/setup.cfg b/setup.cfg index 4c16b98c..4bc0fb68 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,3 +13,6 @@ multi_line_output = 3 [wheel] universal = 1 + +[flake8] +max-line-length = 120 \ No newline at end of file