Skip to content

Commit

Permalink
feat: add send_event_with_custom_metadata (#175)
Browse files Browse the repository at this point in the history
Added send_event_with_custom_metadata. This new version of
send_event enables event bus consumers to send the event
signal with the same metadata fields that were used when
the event was produced.

#162
  • Loading branch information
robrap authored Jan 25, 2023
1 parent aac72c3 commit 0f106cb
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 68 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ Change Log
Unreleased
----------

[4.2.0] - 2023-01-24
---------------------
Added
~~~~~~
* Added ``send_event_with_custom_metadata``. This will enable event bus consumers to send the event signal with the same metadata fields that were used when the event was produced.

Fixed
~~~~~
* Updated time metadata to include UTC timezone. The original implementation used utcnow(), which could give different results if the time were ever interpreted to be local time. See https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow
Expand Down
2 changes: 1 addition & 1 deletion openedx_events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
more information about the project.
"""

__version__ = "4.1.1"
__version__ = "4.2.0"
63 changes: 33 additions & 30 deletions openedx_events/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,36 +47,39 @@ class EventsMetadata:
lexical ordering of strings (e.g. '0.9.0' vs. '0.10.0').
"""

id = attr.ib(type=UUID, init=False)
event_type = attr.ib(type=str)
minorversion = attr.ib(type=int, default=None, converter=attr.converters.default_if_none(0))
source = attr.ib(type=str, init=False)
sourcehost = attr.ib(type=str, init=False)
current_utc_time = datetime.now(timezone.utc)
event_type = attr.ib(type=str, validator=attr.validators.instance_of(str))
id = attr.ib(
type=UUID, default=None,
converter=attr.converters.default_if_none(attr.Factory(lambda: uuid1())), # pylint: disable=unnecessary-lambda
validator=attr.validators.instance_of(UUID),
)
minorversion = attr.ib(
type=int, default=None,
converter=attr.converters.default_if_none(0), validator=attr.validators.instance_of(int)
)
source = attr.ib(
type=str, default=None,
converter=attr.converters.default_if_none(
attr.Factory(lambda: "openedx/{service}/web".format(service=getattr(settings, "SERVICE_VARIANT", "")))
),
validator=attr.validators.instance_of(str),
)
sourcehost = attr.ib(
type=str, default=None,
converter=attr.converters.default_if_none(
attr.Factory(lambda: socket.gethostname()) # pylint: disable=unnecessary-lambda
),
validator=attr.validators.instance_of(str),
)
time = attr.ib(
type=datetime,
default=None,
type=datetime, default=None,
converter=attr.converters.default_if_none(attr.Factory(lambda: datetime.now(timezone.utc))),
validator=attr.validators.optional([attr.validators.instance_of(datetime), _ensure_utc_time]),
validator=[attr.validators.instance_of(datetime), _ensure_utc_time],
)
sourcelib = attr.ib(
type=tuple, default=None,
converter=attr.converters.default_if_none(
attr.Factory(lambda: tuple(map(int, openedx_events.__version__.split("."))))
),
validator=attr.validators.instance_of(tuple),
)
sourcelib = attr.ib(type=tuple, init=False)

def __attrs_post_init__(self):
"""
Post-init hook that generates metadata for the Open edX Event.
"""
# Have to use this to get around the fact that the class is frozen
# (which we almost always want, but not while we're initializing it).
# Taken from edX Learning Sequences data file.
object.__setattr__(self, "id", uuid1())
object.__setattr__(
self,
"source",
"openedx/{service}/web".format(
service=getattr(settings, "SERVICE_VARIANT", "")
),
)
object.__setattr__(self, "sourcehost", socket.gethostname())
object.__setattr__(
self, "sourcelib", tuple(map(int, openedx_events.__version__.split(".")))
)
37 changes: 35 additions & 2 deletions openedx_events/tests/test_tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import datetime
from contextlib import contextmanager
from unittest.mock import Mock, patch
from uuid import UUID
from uuid import UUID, uuid1

import attr
import ddt
Expand Down Expand Up @@ -160,7 +160,7 @@ def test_generate_signal_metadata_fails_with_invalid_time(

@patch("openedx_events.tooling.OpenEdxPublicSignal.generate_signal_metadata")
@patch("openedx_events.tooling.Signal.send")
def test_send_event_successfully(self, send_mock, fake_metadata):
def test_send_event_allow_failure_successfully(self, send_mock, fake_metadata):
"""
This method tests the process of sending an event that's allowed to fail.
Expand Down Expand Up @@ -224,6 +224,39 @@ def test_send_event_with_time(self, fake_metadata):
# generate_signal_metadata is fully tested elsewhere
fake_metadata.assert_called_once_with(time=expected_time)

@patch("openedx_events.tooling.OpenEdxPublicSignal._send_event_with_metadata")
def test_send_event_with_custom_metadata(self, mock_send_event_with_metadata):
"""
This method tests the process of sending an event with custom metadata.
Expected behavior:
The _send_event_with_metadata call is passed the appropriate metadata.
Note:
The _send_event_with_metadata is fully tested with the various send_event tests.
"""
expected_metadata = {
"id": uuid1(),
"event_type": self.event_type,
"minorversion": 99,
"source": "mock-source",
"sourcehost": "mock-sourcehost",
"time": datetime.datetime.now(datetime.timezone.utc),
"sourcelib": [6, 1, 7],
}
expected_response = "mock-response"
mock_send_event_with_metadata.return_value = expected_response

response = self.public_signal.send_event_with_custom_metadata(
user=self.user_mock, id=expected_metadata['id'], minorversion=expected_metadata['minorversion'],
source=expected_metadata['source'], sourcehost=expected_metadata['sourcehost'],
time=expected_metadata['time'], sourcelib=tuple(expected_metadata['sourcelib']),
)

assert response == expected_response
metadata = mock_send_event_with_metadata.call_args.kwargs['metadata']
self.assertDictContainsSubset(expected_metadata, attr.asdict(metadata))

@ddt.data(
(
{"student": Mock()},
Expand Down
122 changes: 87 additions & 35 deletions openedx_events/tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,45 +98,17 @@ def generate_signal_metadata(self, time=None):
time=time,
)

def send_event(self, send_robust=True, time=None, **kwargs):
def _send_event_with_metadata(self, metadata, send_robust=True, **kwargs):
"""
Send events to all connected receivers.
Send events to all connected receivers with the provided metadata.
This method is for internal use only.
Arguments:
metadata (EventsMetadata): The metadata to be sent with the signal.
send_robust (bool): Defaults to True. See Django signal docs.
time (datetime): (Optional - see note) Timestamp when the event was
sent with UTC timezone. For events requiring a DB create or
update, use the timestamp from the DB record. Defaults to
current time in UTC. This argument is optional for backward
compatability, but ideally would be explicitly set. See OEP-41
for details.
Used to send events just like Django signals are sent. In addition,
some validations are executed on the arguments, and then generates relevant
metadata that can be used for logging or debugging purposes. Besides this behavior,
send_event behaves just like the send method.
If the event is disabled (i.e _allow_events is False), then this method
won't have any effect. Meaning, the Django Signal won't be sent.
Example usage:
>>> STUDENT_REGISTRATION_COMPLETED.send_event(
user=user_data, registration=registration_data,
)
[(<function callback at 0x7f2ce638ef70>, 'callback response')]
Keyword arguments:
send_robust (bool): determines whether the Django signal will be
sent using the method `send` or `send_robust`.
Returns:
list: response of each receiver following the format
[(receiver, response), ... ]. Empty list if the event is disabled.
Exceptions raised:
SenderValidationError: raised when there's a mismatch between
arguments passed to this method and arguments used to initialize
the event.
See ``send_event`` docstring for more details on its usage and behavior.
"""

def validate_sender():
Expand Down Expand Up @@ -173,7 +145,7 @@ def validate_sender():

validate_sender()

kwargs["metadata"] = self.generate_signal_metadata(time=time)
kwargs["metadata"] = metadata

if self._allow_send_event_failure or settings.DEBUG or not send_robust:
return super().send(sender=None, **kwargs)
Expand All @@ -185,6 +157,86 @@ def validate_sender():

return responses

def send_event(self, send_robust=True, time=None, **kwargs):
"""
Send events to all connected receivers.
Arguments:
send_robust (bool): Defaults to True. See Django signal docs.
time (datetime): (Optional - see note) Timestamp when the event was
sent with UTC timezone. For events requiring a DB create or
update, use the timestamp from the DB record. Defaults to
current time in UTC. This argument is optional for backward
compatability, but ideally would be explicitly set. See OEP-41
for details.
Used to send events just like Django signals are sent. In addition,
some validations are executed on the arguments, and then generates relevant
metadata that can be used for logging or debugging purposes. Besides this behavior,
send_event behaves just like the send method.
If the event is disabled (i.e _allow_events is False), then this method
won't have any effect. Meaning, the Django Signal won't be sent.
Example usage:
>>> STUDENT_REGISTRATION_COMPLETED.send_event(
user=user_data, registration=registration_data,
)
[(<function callback at 0x7f2ce638ef70>, 'callback response')]
Keyword arguments:
send_robust (bool): determines whether the Django signal will be
sent using the method `send` or `send_robust`.
Returns:
list: response of each receiver following the format
[(receiver, response), ... ]. Empty list if the event is disabled.
Exceptions raised:
SenderValidationError: raised when there's a mismatch between
arguments passed to this method and arguments used to initialize
the event.
"""
metadata = self.generate_signal_metadata(time=time)
return self._send_event_with_metadata(metadata=metadata, send_robust=send_robust, **kwargs)

def send_event_with_custom_metadata(
self, *, id, minorversion, source, sourcehost, time, sourcelib, # pylint: disable=redefined-builtin
send_robust=True, **kwargs
):
"""
Send events to all connected receivers using the provided metadata.
This method works exactly like ``send_event``, except it takes most of
the event metadata fields as arguments. This could be used for an
event bus consumer, where we want to recreate the metadata used
in the producer when resending the same signal on the consuming
side.
Arguments:
id (UUID): from event production metadata.
event_type (str): from event production metadata.
minorversion (int): from event production metadata.
source (str): from event production metadata.
sourcehost (str): from event production metadata.
time (datetime): from event production metadata.
sourcelib (tuple of ints): from event production metadata.
send_robust (bool): Defaults to True. See Django signal docs.
See ``send_event`` docstring for more details.
"""
metadata = EventsMetadata(
id=id,
event_type=self.event_type,
minorversion=minorversion,
source=source,
sourcehost=sourcehost,
time=time,
sourcelib=sourcelib,
)
return self._send_event_with_metadata(metadata=metadata, send_robust=send_robust, **kwargs)

def send(self, sender, **kwargs): # pylint: disable=unused-argument
"""
Override method used to recommend the sender to adopt our custom send.
Expand Down

0 comments on commit 0f106cb

Please sign in to comment.