Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ Change Log

Unreleased
~~~~~~~~~~
Added
_____
* Add tooling needed to create and trigger events in Open edX platform


[0.2.0] - 2021-07-28
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
62 changes: 62 additions & 0 deletions openedx_events/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""
Data attributes for events within the architecture subdomain `learning`.
These attributes follow the form of attr objects specified in OEP-49 data
pattern.
"""
import socket
from datetime import datetime
from uuid import UUID, uuid1

import attr
from django.conf import settings

import openedx_events


@attr.s(frozen=True)
class EventsMetadata:
"""
Attributes defined for Open edX Events metadata object.
The attributes defined in this class are a subset of the
OEP-41: Asynchronous Server Event Message Format.
Arguments:
id (UUID): event identifier.
event_type (str): name of the event.
minorversion (int): version of the event type.
source (str): logical source of an event.
sourcehost (str): physical source of the event.
time (datetime): timestamp when the event was sent.
sourcelib (str): Open edX Events library version.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mariajgrimaldi: Hello Maria. It seems we went with a tuple instead of a string. I'm curious why we did that. I'm working on the event bus, which is going to need to send this metadata across the wire, so I may need to pass in a string and convert it back to a tuple, which will be a bit strange, but would make the type in this comment more correct.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@robrap Hello there, Robert. After some digging, I found this discussion I had with dave when this PR was open: #7 (comment) If you think there's room for improvement, let me know! -besides updating the docstring 😅 -

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"""

id = attr.ib(type=UUID, init=False)
event_type = attr.ib(type=str)
minorversion = attr.ib(type=int, converter=attr.converters.default_if_none(0))
source = attr.ib(type=str, init=False)
sourcehost = attr.ib(type=str, init=False)
time = attr.ib(type=datetime, init=False)
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, "time", datetime.utcnow())
object.__setattr__(
self, "sourcelib", tuple(map(int, openedx_events.__version__.split(".")))
)
69 changes: 69 additions & 0 deletions openedx_events/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""
Custom exceptions thrown by Open edX events tooling.
"""


class OpenEdxEventException(Exception):
"""
Base class for Open edX Events exceptions.
"""

def __init__(self, message=""):
"""
Init method for OpenEdxEventException base class.
Arguments:
message (str): message describing why the exception was raised.
"""
super().__init__()
self.message = message

def __str__(self):
"""
Show string representation of OpenEdxEventException using its message.
"""
return self.message


class InstantiationError(OpenEdxEventException):
"""
Describes errors that occur while instantiating events.
This exception is raised when there's an error instantiating an Open edX
event, it can be that a required argument for the event definition is
missing.
"""

def __init__(self, event_type="", message=""):
"""
Init method for InstantiationError custom exception class.
Arguments:
event_type (str): name of the event raising the exception.
message (str): message describing why the exception was raised.
"""
super().__init__(
message="InstantiationError {event_type}: {message}".format(
event_type=event_type, message=message
)
)


class SenderValidationError(OpenEdxEventException):
"""
Describes errors that occur while validating arguments of send methods.
"""

def __init__(self, event_type="", message=""):
"""
Init method for SenderValidationError custom exception class.
Arguments:
event_type (str): name of the event raising the exception.
message (str): message describing why the exception was raised.
"""
super().__init__(
message="SenderValidationError {event_type}: {message}".format(
event_type=event_type, message=message
)
)
193 changes: 193 additions & 0 deletions openedx_events/tests/test_tooling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
"""This file contains all test for the tooling.py file.
Classes:
EventsToolingTest: Test events tooling.
"""
from unittest.mock import Mock, patch

import attr
import ddt
from django.test import TestCase, override_settings

from openedx_events.exceptions import InstantiationError, SenderValidationError
from openedx_events.tooling import OpenEdxPublicSignal


@ddt.ddt
class OpenEdxPublicSignalTest(TestCase):
"""
Test cases for Open edX events base class.
"""

def setUp(self):
"""
Setup common conditions for every test case.
"""
super().setUp()
self.event_type = "org.openedx.learning.session.login.completed.v1"
self.user_mock = Mock()
self.data_attr = {
"user": Mock,
}
self.public_signal = OpenEdxPublicSignal(
event_type=self.event_type,
data=self.data_attr,
)

def test_string_representation(self):
"""
This methods checks the string representation for events base class.
Expected behavior:
The representation contains the event_type.
"""
self.assertIn(self.event_type, str(self.public_signal))

@override_settings(SERVICE_VARIANT="lms")
@patch("openedx_events.data.openedx_events")
@patch("openedx_events.data.socket")
def test_get_signal_metadata(self, socket_mock, events_package_mock):
"""
This methods tests getting the generated metadata for an event.
Expected behavior:
Returns the metadata containing information about the event.
"""
events_package_mock.__version__ = "0.1.0"
socket_mock.gethostname.return_value = "edx.devstack.lms"
expected_metadata = {
"event_type": self.event_type,
"minorversion": 0,
"source": "openedx/lms/web",
"sourcehost": "edx.devstack.lms",
"sourcelib": [0, 1, 0],
}

metadata = self.public_signal.generate_signal_metadata()

self.assertDictContainsSubset(expected_metadata, attr.asdict(metadata))

@ddt.data(
("", {"user": Mock()}, "event_type"),
("org.openedx.learning.session.login.completed.v1", None, "data"),
)
@ddt.unpack
def test_event_instantiation_exception(
self, event_type, event_data, missing_argument
):
"""
This method tests when an event is instantiated without event_type or
event data.
Expected behavior:
An InstantiationError exception is raised.
"""
exception_message = "InstantiationError {event_type}: Missing required argument '{missing_argument}'".format(
event_type=event_type, missing_argument=missing_argument
)

with self.assertRaisesMessage(InstantiationError, exception_message):
OpenEdxPublicSignal(event_type=event_type, data=event_data)

@patch("openedx_events.tooling.OpenEdxPublicSignal.generate_signal_metadata")
@patch("openedx_events.tooling.Signal.send")
def test_send_event_successfully(self, send_mock, fake_metadata):
"""
This method tests the process of sending an event.
Expected behavior:
The event is sent as a django signal.
"""
expected_metadata = {
"some_data": "data",
"raise_exception": True,
}
fake_metadata.return_value = expected_metadata

self.public_signal.send_event(user=self.user_mock)

send_mock.assert_called_once_with(
sender=None,
user=self.user_mock,
metadata=expected_metadata,
)

@patch("openedx_events.tooling.OpenEdxPublicSignal.generate_signal_metadata")
@patch("openedx_events.tooling.Signal.send_robust")
def test_send_robust_event_successfully(self, send_robust_mock, fake_metadata):
"""
This method tests the process of sending an event.
Expected behavior:
The event is sent as a django signal.
"""
expected_metadata = {
"some_data": "data",
"raise_exception": True,
}
fake_metadata.return_value = expected_metadata

self.public_signal.send_event(user=self.user_mock, send_robust=True)

send_robust_mock.assert_called_once_with(
sender=None,
user=self.user_mock,
metadata=expected_metadata,
)

@ddt.data(
(
{"student": Mock()},
"SenderValidationError org.openedx.learning.session.login.completed.v1: "
"Missing required argument 'user'",
),
(
{"user": {"student": Mock()}},
"SenderValidationError org.openedx.learning.session.login.completed.v1: "
"The argument 'user' is not instance of the Class Attribute 'type'",
),
(
{"student": Mock(), "user": Mock()},
"SenderValidationError org.openedx.learning.session.login.completed.v1: "
"There's a mismatch between initialization data and send_event arguments",
),
)
@ddt.unpack
def test_invalid_sender(self, send_arguments, exception_message):
"""
This method tests sending an event with invalid setup on the sender
side.
Expected behavior:
A SenderValidationError exception is raised.
"""
with self.assertRaisesMessage(SenderValidationError, exception_message):
self.public_signal.send_event(**send_arguments)

def test_send_event_with_django(self):
"""
This method tests sending an event using the `send` built-in Django
method.
Expected behavior:
A warning is showed advicing to use Open edX events custom
send_signal method.
"""
message = "Please, use 'send_event' when triggering an Open edX event."

with self.assertWarns(Warning, msg=message):
self.public_signal.send(sender=Mock())

def test_send_robust_event_with_django(self):
"""
This method tests sending an event using the `send` built-in Django
method.
Expected behavior:
A warning is showed advicing to use Open edX events custom
send_signal method.
"""
message = "Please, use 'send_event' with send_robust equals to True when triggering an Open edX event."

with self.assertWarns(Warning, msg=message):
self.public_signal.send_robust(sender=Mock())
Loading