Skip to content

Commit e827c18

Browse files
feat: add necessary tooling for Open edX events
- Add signal class used to create events - Add metadata atrr class - Add metadata generator to class - Add argument validator to class - Override signal methods to recommend using the custom send_event
1 parent 0f11b1f commit e827c18

File tree

15 files changed

+557
-56
lines changed

15 files changed

+557
-56
lines changed

CHANGELOG.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ Change Log
1010
This project adheres to Semantic Versioning (https://semver.org/).
1111

1212
.. There should always be an "Unreleased" section for changes pending release.
13-
1413
Unreleased
1514
~~~~~~~~~~
1615

16+
Added
17+
_____
18+
* Add tooling needed to create and trigger events in Open edX platform
19+
20+
1721
[0.2.0] - 2021-07-28
1822
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1923
Changed

openedx_events/data.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Data attributes for events within the architecture subdomain `learning`.
3+
4+
These attributes follow the form of attr objects specified in OEP-49 data
5+
pattern.
6+
"""
7+
import socket
8+
from datetime import datetime
9+
from uuid import UUID, uuid1
10+
11+
import attr
12+
from django.conf import settings
13+
14+
import openedx_events
15+
16+
17+
@attr.s(frozen=True)
18+
class EventsMetadata:
19+
"""
20+
Attributes defined for Open edX Events metadata object.
21+
22+
The attributes defined in this class are a subset of the
23+
OEP-41: Asynchronous Server Event Message Format.
24+
25+
Arguments:
26+
id (UUID): event identifier.
27+
event_type (str): name of the event.
28+
minorversion (int): version of the event type.
29+
source (str): logical source of an event.
30+
sourcehost (str): physical source of the event.
31+
time (datetime): timestamp when the event was sent.
32+
sourcelib (str): Open edX Events library version.
33+
"""
34+
35+
id = attr.ib(type=UUID, init=False)
36+
event_type = attr.ib(type=str)
37+
minorversion = attr.ib(type=int, converter=attr.converters.default_if_none(0))
38+
source = attr.ib(type=str, init=False)
39+
sourcehost = attr.ib(type=str, init=False)
40+
time = attr.ib(type=datetime, init=False)
41+
sourcelib = attr.ib(type=tuple, init=False)
42+
43+
def __attrs_post_init__(self):
44+
"""
45+
Post-init hook that generates metadata for the Open edX Event.
46+
"""
47+
# Have to use this to get around the fact that the class is frozen
48+
# (which we almost always want, but not while we're initializing it).
49+
# Taken from edX Learning Sequences data file.
50+
object.__setattr__(self, "id", uuid1())
51+
object.__setattr__(
52+
self,
53+
"source",
54+
"openedx/{service}/web".format(
55+
service=getattr(settings, "SERVICE_VARIANT", "")
56+
),
57+
)
58+
object.__setattr__(self, "sourcehost", socket.gethostname())
59+
object.__setattr__(self, "time", datetime.utcnow())
60+
object.__setattr__(
61+
self, "sourcelib", tuple(map(int, openedx_events.__version__.split(".")))
62+
)

openedx_events/exceptions.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
Custom exceptions thrown by Open edX events tooling.
3+
"""
4+
5+
6+
class OpenEdxEventException(Exception):
7+
"""
8+
Base class for Open edX Events exceptions.
9+
"""
10+
11+
def __init__(self, message=""):
12+
"""
13+
Init method for OpenEdxEventException base class.
14+
15+
Arguments:
16+
message (str): message describing why the exception was raised.
17+
"""
18+
super().__init__()
19+
self.message = message
20+
21+
def __str__(self):
22+
"""
23+
Show string representation of OpenEdxEventException using its message.
24+
"""
25+
return self.message
26+
27+
28+
class InstantiationError(OpenEdxEventException):
29+
"""
30+
Describes errors that occur while instantiating events.
31+
32+
This exception is raised when there's an error instantiating an Open edX
33+
event, it can be that a required argument for the event definition is
34+
missing.
35+
"""
36+
37+
def __init__(self, event_type="", message=""):
38+
"""
39+
Init method for InstantiationError custom exception class.
40+
41+
Arguments:
42+
event_type (str): name of the event raising the exception.
43+
message (str): message describing why the exception was raised.
44+
"""
45+
super().__init__(
46+
message="InstantiationError {event_type}: {message}".format(
47+
event_type=event_type, message=message
48+
)
49+
)
50+
51+
52+
class SenderValidationError(OpenEdxEventException):
53+
"""
54+
Describes errors that occur while validating arguments of send methods.
55+
"""
56+
57+
def __init__(self, event_type="", message=""):
58+
"""
59+
Init method for SenderValidationError custom exception class.
60+
61+
Arguments:
62+
event_type (str): name of the event raising the exception.
63+
message (str): message describing why the exception was raised.
64+
"""
65+
super().__init__(
66+
message="SenderValidationError {event_type}: {message}".format(
67+
event_type=event_type, message=message
68+
)
69+
)
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""This file contains all test for the tooling.py file.
2+
3+
Classes:
4+
EventsToolingTest: Test events tooling.
5+
"""
6+
from unittest.mock import Mock, patch
7+
8+
import attr
9+
import ddt
10+
from django.test import TestCase, override_settings
11+
12+
from openedx_events.exceptions import InstantiationError, SenderValidationError
13+
from openedx_events.tooling import OpenEdxPublicSignal
14+
15+
16+
@ddt.ddt
17+
class OpenEdxPublicSignalTest(TestCase):
18+
"""
19+
Test cases for Open edX events base class.
20+
"""
21+
22+
def setUp(self):
23+
"""
24+
Setup common conditions for every test case.
25+
"""
26+
super().setUp()
27+
self.event_type = "org.openedx.learning.session.login.completed.v1"
28+
self.user_mock = Mock()
29+
self.data_attr = {
30+
"user": Mock,
31+
}
32+
self.public_signal = OpenEdxPublicSignal(
33+
event_type=self.event_type,
34+
data=self.data_attr,
35+
)
36+
37+
def test_string_representation(self):
38+
"""
39+
This methods checks the string representation for events base class.
40+
41+
Expected behavior:
42+
The representation contains the event_type.
43+
"""
44+
self.assertIn(self.event_type, str(self.public_signal))
45+
46+
@override_settings(SERVICE_VARIANT="lms")
47+
@patch("openedx_events.data.openedx_events")
48+
@patch("openedx_events.data.socket")
49+
def test_get_signal_metadata(self, socket_mock, events_package_mock):
50+
"""
51+
This methods tests getting the generated metadata for an event.
52+
53+
Expected behavior:
54+
Returns the metadata containing information about the event.
55+
"""
56+
events_package_mock.__version__ = "0.1.0"
57+
socket_mock.gethostname.return_value = "edx.devstack.lms"
58+
expected_metadata = {
59+
"event_type": self.event_type,
60+
"minorversion": 0,
61+
"source": "openedx/lms/web",
62+
"sourcehost": "edx.devstack.lms",
63+
"sourcelib": [0, 1, 0],
64+
}
65+
66+
metadata = self.public_signal.generate_signal_metadata()
67+
68+
self.assertDictContainsSubset(expected_metadata, attr.asdict(metadata))
69+
70+
@ddt.data(
71+
("", {"user": Mock()}, "event_type"),
72+
("org.openedx.learning.session.login.completed.v1", None, "data"),
73+
)
74+
@ddt.unpack
75+
def test_event_instantiation_exception(
76+
self, event_type, event_data, missing_argument
77+
):
78+
"""
79+
This method tests when an event is instantiated without event_type or
80+
event data.
81+
82+
Expected behavior:
83+
An InstantiationError exception is raised.
84+
"""
85+
exception_message = "InstantiationError {event_type}: Missing required argument '{missing_argument}'".format(
86+
event_type=event_type, missing_argument=missing_argument
87+
)
88+
89+
with self.assertRaisesMessage(InstantiationError, exception_message):
90+
OpenEdxPublicSignal(event_type=event_type, data=event_data)
91+
92+
@patch("openedx_events.tooling.OpenEdxPublicSignal.generate_signal_metadata")
93+
@patch("openedx_events.tooling.Signal.send")
94+
def test_send_event_successfully(self, send_mock, fake_metadata):
95+
"""
96+
This method tests the process of sending an event.
97+
98+
Expected behavior:
99+
The event is sent as a django signal.
100+
"""
101+
expected_metadata = {
102+
"some_data": "data",
103+
"raise_exception": True,
104+
}
105+
fake_metadata.return_value = expected_metadata
106+
107+
self.public_signal.send_event(user=self.user_mock)
108+
109+
send_mock.assert_called_once_with(
110+
sender=None,
111+
user=self.user_mock,
112+
metadata=expected_metadata,
113+
)
114+
115+
@patch("openedx_events.tooling.OpenEdxPublicSignal.generate_signal_metadata")
116+
@patch("openedx_events.tooling.Signal.send_robust")
117+
def test_send_robust_event_successfully(self, send_robust_mock, fake_metadata):
118+
"""
119+
This method tests the process of sending an event.
120+
121+
Expected behavior:
122+
The event is sent as a django signal.
123+
"""
124+
expected_metadata = {
125+
"some_data": "data",
126+
"raise_exception": True,
127+
}
128+
fake_metadata.return_value = expected_metadata
129+
130+
self.public_signal.send_event(user=self.user_mock, send_robust=True)
131+
132+
send_robust_mock.assert_called_once_with(
133+
sender=None,
134+
user=self.user_mock,
135+
metadata=expected_metadata,
136+
)
137+
138+
@ddt.data(
139+
(
140+
{"student": Mock()},
141+
"SenderValidationError org.openedx.learning.session.login.completed.v1: "
142+
"Missing required argument 'user'",
143+
),
144+
(
145+
{"user": {"student": Mock()}},
146+
"SenderValidationError org.openedx.learning.session.login.completed.v1: "
147+
"The argument 'user' is not instance of the Class Attribute 'type'",
148+
),
149+
(
150+
{"student": Mock(), "user": Mock()},
151+
"SenderValidationError org.openedx.learning.session.login.completed.v1: "
152+
"There's a mismatch between initialization data and send_event arguments",
153+
),
154+
)
155+
@ddt.unpack
156+
def test_invalid_sender(self, send_arguments, exception_message):
157+
"""
158+
This method tests sending an event with invalid setup on the sender
159+
side.
160+
161+
Expected behavior:
162+
A SenderValidationError exception is raised.
163+
"""
164+
with self.assertRaisesMessage(SenderValidationError, exception_message):
165+
self.public_signal.send_event(**send_arguments)
166+
167+
def test_send_event_with_django(self):
168+
"""
169+
This method tests sending an event using the `send` built-in Django
170+
method.
171+
172+
Expected behavior:
173+
A warning is showed advicing to use Open edX events custom
174+
send_signal method.
175+
"""
176+
message = "Please, use 'send_event' when triggering an Open edX event."
177+
178+
with self.assertWarns(Warning, msg=message):
179+
self.public_signal.send(sender=Mock())
180+
181+
def test_send_robust_event_with_django(self):
182+
"""
183+
This method tests sending an event using the `send` built-in Django
184+
method.
185+
186+
Expected behavior:
187+
A warning is showed advicing to use Open edX events custom
188+
send_signal method.
189+
"""
190+
message = "Please, use 'send_event' with send_robust equals to True when triggering an Open edX event."
191+
192+
with self.assertWarns(Warning, msg=message):
193+
self.public_signal.send_robust(sender=Mock())

0 commit comments

Comments
 (0)