Skip to content

Commit 5d07241

Browse files
authored
Merge pull request #168 from openedx/robrap/add-time-and-fix-metadata
feat: updated event signal metadata time
2 parents 023b75d + 52d6efc commit 5d07241

File tree

5 files changed

+124
-12
lines changed

5 files changed

+124
-12
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ Change Log
1313
1414
Unreleased
1515
----------
16+
Fixed
17+
~~~~~
18+
* 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
19+
20+
Changed
21+
~~~~~~~
22+
* Updated send_event with an optional time argument to be used as metadata.
1623

1724
[4.1.1] - 2023-01-23
1825
---------------------

openedx_events/data.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
pattern.
66
"""
77
import socket
8-
from datetime import datetime
8+
from datetime import datetime, timezone
99
from uuid import UUID, uuid1
1010

1111
import attr
@@ -14,6 +14,17 @@
1414
import openedx_events
1515

1616

17+
def _ensure_utc_time(_, attribute, value):
18+
"""
19+
Ensure the value is a UTC datetime.
20+
21+
Note: Meant to be used along-side an instance_of attr validator.
22+
"""
23+
if value.tzinfo and value.tzinfo == timezone.utc:
24+
return
25+
raise ValueError(f"'{attribute.name}' must have timezone.utc")
26+
27+
1728
@attr.s(frozen=True)
1829
class EventsMetadata:
1930
"""
@@ -28,16 +39,26 @@ class EventsMetadata:
2839
minorversion (int): version of the event type.
2940
source (str): logical source of an event.
3041
sourcehost (str): physical source of the event.
31-
time (datetime): timestamp when the event was sent.
32-
sourcelib (str): Open edX Events library version.
42+
time (datetime): (optional) timestamp when the event was sent with
43+
UTC timezone. Defaults to current time in UTC. See OEP-41 for
44+
details.
45+
sourcelib (tuple of ints): Open edX Events library version. A tuple was
46+
selected so that version comparisons don't have to worry about
47+
lexical ordering of strings (e.g. '0.9.0' vs. '0.10.0').
3348
"""
3449

3550
id = attr.ib(type=UUID, init=False)
3651
event_type = attr.ib(type=str)
3752
minorversion = attr.ib(type=int, converter=attr.converters.default_if_none(0))
3853
source = attr.ib(type=str, init=False)
3954
sourcehost = attr.ib(type=str, init=False)
40-
time = attr.ib(type=datetime, init=False)
55+
current_utc_time = datetime.now(timezone.utc)
56+
time = attr.ib(
57+
type=datetime,
58+
default=None,
59+
converter=attr.converters.default_if_none(attr.Factory(lambda: datetime.now(timezone.utc))),
60+
validator=attr.validators.optional([attr.validators.instance_of(datetime), _ensure_utc_time]),
61+
)
4162
sourcelib = attr.ib(type=tuple, init=False)
4263

4364
def __attrs_post_init__(self):
@@ -56,7 +77,6 @@ def __attrs_post_init__(self):
5677
),
5778
)
5879
object.__setattr__(self, "sourcehost", socket.gethostname())
59-
object.__setattr__(self, "time", datetime.utcnow())
6080
object.__setattr__(
6181
self, "sourcelib", tuple(map(int, openedx_events.__version__.split(".")))
6282
)

openedx_events/event_bus/tests/test_loader.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,6 @@ def test_default_does_nothing(self):
109109
# Nothing thrown, no warnings.
110110
assert producer.send(
111111
signal=SESSION_LOGIN_COMPLETED, topic='user-logins',
112-
event_key_field='user.id', event_data={}, event_metadata=EventsMetadata(event_type='eh', minorversion=0)
112+
event_key_field='user.id', event_data={},
113+
event_metadata=EventsMetadata(event_type='eh', minorversion=0)
113114
) is None

openedx_events/tests/test_tooling.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
Classes:
55
EventsToolingTest: Test events tooling.
66
"""
7+
import datetime
78
from contextlib import contextmanager
89
from unittest.mock import Mock, patch
10+
from uuid import UUID
911

1012
import attr
1113
import ddt
@@ -85,7 +87,8 @@ def test_get_signal_by_type(self):
8587
@override_settings(SERVICE_VARIANT="lms")
8688
@patch("openedx_events.data.openedx_events")
8789
@patch("openedx_events.data.socket")
88-
def test_get_signal_metadata(self, socket_mock, events_package_mock):
90+
@patch("openedx_events.data.datetime")
91+
def test_generate_signal_metadata(self, datetime_mock, socket_mock, events_package_mock):
8992
"""
9093
This methods tests getting the generated metadata for an event.
9194
@@ -94,23 +97,72 @@ def test_get_signal_metadata(self, socket_mock, events_package_mock):
9497
"""
9598
events_package_mock.__version__ = "0.1.0"
9699
socket_mock.gethostname.return_value = "edx.devstack.lms"
100+
expected_time = datetime.datetime.now(datetime.timezone.utc)
101+
datetime_mock.now.return_value = expected_time
97102
expected_metadata = {
98103
"event_type": self.event_type,
99104
"minorversion": 0,
100105
"source": "openedx/lms/web",
101106
"sourcehost": "edx.devstack.lms",
102107
"sourcelib": [0, 1, 0],
108+
"time": expected_time,
103109
}
104110

105111
metadata = self.public_signal.generate_signal_metadata()
106112

107113
self.assertDictContainsSubset(expected_metadata, attr.asdict(metadata))
114+
self.assertIsInstance(metadata.id, UUID)
115+
116+
@override_settings(SERVICE_VARIANT="lms")
117+
@patch("openedx_events.data.openedx_events")
118+
@patch("openedx_events.data.socket")
119+
def test_generate_signal_metadata_with_valid_time(self, socket_mock, events_package_mock):
120+
"""
121+
Tests getting the generated metadata for an event, providing a valid time in UTC.
122+
123+
Expected behavior:
124+
Returns the metadata containing information about the event.
125+
"""
126+
events_package_mock.__version__ = "0.1.0"
127+
socket_mock.gethostname.return_value = "edx.devstack.lms"
128+
expected_time = datetime.datetime.now(datetime.timezone.utc)
129+
expected_metadata = {
130+
"event_type": self.event_type,
131+
"minorversion": 0,
132+
"source": "openedx/lms/web",
133+
"sourcehost": "edx.devstack.lms",
134+
"sourcelib": [0, 1, 0],
135+
"time": expected_time,
136+
}
137+
138+
metadata = self.public_signal.generate_signal_metadata(time=expected_time)
139+
140+
self.assertDictContainsSubset(expected_metadata, attr.asdict(metadata))
141+
self.assertIsInstance(metadata.id, UUID)
142+
143+
@ddt.data(
144+
(1, TypeError, "'time' must be <class 'datetime.datetime'",),
145+
# WARNING: utcnow() has no timezone, and could be misinterpreted in local time
146+
(datetime.datetime.utcnow(), ValueError, "'time' must have timezone.utc",),
147+
)
148+
@ddt.unpack
149+
def test_generate_signal_metadata_fails_with_invalid_time(
150+
self, invalid_time, error_class, error_message
151+
):
152+
"""
153+
Tests getting generated metadata for an event fails with a non-UTC time.
154+
155+
Expected behavior:
156+
Raises an exception
157+
"""
158+
with self.assertRaisesMessage(error_class, error_message):
159+
self.public_signal.generate_signal_metadata(time=invalid_time)
108160

109161
@patch("openedx_events.tooling.OpenEdxPublicSignal.generate_signal_metadata")
110162
@patch("openedx_events.tooling.Signal.send")
111163
def test_send_event_successfully(self, send_mock, fake_metadata):
112164
"""
113-
This method tests the process of sending an event that's allow to fail.
165+
This method tests the process of sending an event that's allowed to fail.
114166
115167
Expected behavior:
116168
The event is sent as a django signal with send method.
@@ -155,6 +207,23 @@ def test_send_robust_event_successfully(self, format_responses_mock, log_mock, f
155207
"Responses of the Open edX Event <org.openedx.learning.session.login.completed.v1>: \nfake-output"
156208
)
157209

210+
@patch("openedx_events.tooling.OpenEdxPublicSignal.generate_signal_metadata")
211+
def test_send_event_with_time(self, fake_metadata):
212+
"""
213+
This method tests the process of sending an event with a time argument.
214+
215+
Expected behavior:
216+
The generate_signal_metadata is called using the passed time.
217+
"""
218+
expected_metadata = Mock(some_data="some_data")
219+
expected_time = datetime.datetime.now(datetime.timezone.utc)
220+
fake_metadata.return_value = expected_metadata
221+
222+
self.public_signal.send_event(user=self.user_mock, time=expected_time)
223+
224+
# generate_signal_metadata is fully tested elsewhere
225+
fake_metadata.assert_called_once_with(time=expected_time)
226+
158227
@ddt.data(
159228
(
160229
{"student": Mock()},
@@ -190,7 +259,7 @@ def test_send_event_with_django(self):
190259
method.
191260
192261
Expected behavior:
193-
A warning is showed advicing to use Open edX events custom
262+
A warning is showed advising to use Open edX events custom
194263
send_signal method.
195264
"""
196265
message = "Please, use 'send_event' when triggering an Open edX event."

openedx_events/tooling.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,18 @@ def get_signal_by_type(cls, event_type):
6666
"""
6767
return cls._mapping[event_type]
6868

69-
def generate_signal_metadata(self):
69+
def generate_signal_metadata(self, time=None):
7070
"""
7171
Generate signal metadata when an event is sent.
7272
7373
These fields are generated on the fly and are a subset of the Event
7474
Message defined in the OEP-41.
7575
76+
Arguments:
77+
time (datetime): (Optional) Timestamp when the event was sent with
78+
UTC timezone. Defaults to current time in UTC. See OEP-41 for
79+
details.
80+
7681
Example usage:
7782
>>> metadata = \
7883
STUDENT_REGISTRATION_COMPLETED.generate_signal_metadata()
@@ -90,12 +95,22 @@ def generate_signal_metadata(self):
9095
return EventsMetadata(
9196
event_type=self.event_type,
9297
minorversion=self.minor_version,
98+
time=time,
9399
)
94100

95-
def send_event(self, send_robust=True, **kwargs):
101+
def send_event(self, send_robust=True, time=None, **kwargs):
96102
"""
97103
Send events to all connected receivers.
98104
105+
Arguments:
106+
send_robust (bool): Defaults to True. See Django signal docs.
107+
time (datetime): (Optional - see note) Timestamp when the event was
108+
sent with UTC timezone. For events requiring a DB create or
109+
update, use the timestamp from the DB record. Defaults to
110+
current time in UTC. This argument is optional for backward
111+
compatability, but ideally would be explicitly set. See OEP-41
112+
for details.
113+
99114
Used to send events just like Django signals are sent. In addition,
100115
some validations are executed on the arguments, and then generates relevant
101116
metadata that can be used for logging or debugging purposes. Besides this behavior,
@@ -158,7 +173,7 @@ def validate_sender():
158173

159174
validate_sender()
160175

161-
kwargs["metadata"] = self.generate_signal_metadata()
176+
kwargs["metadata"] = self.generate_signal_metadata(time=time)
162177

163178
if self._allow_send_event_failure or settings.DEBUG or not send_robust:
164179
return super().send(sender=None, **kwargs)

0 commit comments

Comments
 (0)