Skip to content

Commit

Permalink
Allow event-routing-backends to be used when transforming non-openedx…
Browse files Browse the repository at this point in the history
… events (#431)

* fix: catch ImportErrors

so that event-routing-backends can be dependency for non-edx-platform
repos like openedx-completion-aggregator.

* feat: adds utils.settings.event_tracking_backends_config()

* Pulls the EVENT_TRACKING_BACKENDS configuration template into a
  utility method so it can be used by other apps.
* Pulls allowed xAPI and Caliper events into settings to preserve
  changes

* fix: event_tracking_backends_config must use the provided plugin settings

instead of global django.conf.settings

* refactor: creates fixture test mixins that can be used outside of ERB

Refactors the transformer test classes to split the test functionality into two:

* TransformersFixturesTestMixin -- for running the event fixture tests
* TransformersTestMixin -- for running the ERB-specific event tests

The fixtures file path constants have been moved to property methods so
they can be overrideen when used outside of ERB.

* fix: ensure plugin loading order doesn't matter for event routing settings

Initialize EVENT_TRACKING_BACKENDS_ALLOWED_XAPI_EVENTS and
EVENT_TRACKING_BACKENDS_ALLOWED_CALIPER_EVENTS only if they aren't
already initialized, and append our events to them.

This allows other plugins to modify these settings too.
  • Loading branch information
pomegranited authored Jun 20, 2024
1 parent 980b2aa commit 9ae9c4f
Show file tree
Hide file tree
Showing 10 changed files with 331 additions and 147 deletions.
6 changes: 4 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ Change Log

.. There should always be an "Unreleased" section for changes pending release.
Unreleased
~~~~~~~~~~
[9.3.0]

* Support use of ERB for transforming non-openedx events

[9.2.1]

* Add support for either 'whitelist' or 'registry.mapping' options (whitelist introduced in v9.0.0)
Expand Down
2 changes: 1 addition & 1 deletion event_routing_backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Various backends for receiving edX LMS events..
"""

__version__ = '9.2.1'
__version__ = '9.3.0'
30 changes: 24 additions & 6 deletions event_routing_backends/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,29 @@
import uuid
from urllib.parse import parse_qs, urlparse

# Imported from edx-platform
# pylint: disable=import-error
from common.djangoapps.student.models import get_potentially_retired_user_by_username
from dateutil.parser import parse
from django.conf import settings
from django.contrib.auth import get_user_model
from isodate import duration_isoformat
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType

logger = logging.getLogger(__name__)
User = get_user_model()

# Imported from edx-platform
try:
from common.djangoapps.student.models import get_potentially_retired_user_by_username
from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
except ImportError as exc: # pragma: no cover
logger.exception(exc)

get_potentially_retired_user_by_username = None
get_course_overviews = None
ExternalId = None
ExternalIdType = None


User = get_user_model()
UTC_DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%f'
BLOCK_ID_FORMAT = '{block_version}:{course_id}+type@{block_type}+block@{block_id}'

Expand Down Expand Up @@ -57,6 +66,9 @@ def get_anonymous_user_id(username_or_id, external_type):
Returns:
str
"""
if not (ExternalId and ExternalIdType):
raise ImportError("Could not import external_user_ids from edx-platform.") # pragma: no cover

user = get_user(username_or_id)
if not user:
logger.warning('User with username "%s" does not exist. '
Expand Down Expand Up @@ -94,6 +106,9 @@ def get_user(username_or_id):
Returns:
user object
"""
if not get_potentially_retired_user_by_username:
raise ImportError("Could not import student.models from edx-platform.") # pragma: no cover

user = username = None

if not username_or_id:
Expand Down Expand Up @@ -145,6 +160,9 @@ def get_course_from_id(course_id):
Returns:
Course
"""
if not get_course_overviews:
raise ImportError("Could not import course_overviews.api from edx-platform.") # pragma: no cover

course_key = CourseKey.from_string(course_id)
course_overviews = get_course_overviews([course_key])
if course_overviews:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,27 @@
from django.test import TestCase

from event_routing_backends.processors.caliper.registry import CaliperTransformersRegistry
from event_routing_backends.processors.tests.transformers_test_mixin import TransformersTestMixin
from event_routing_backends.processors.tests.transformers_test_mixin import (
TransformersFixturesTestMixin,
TransformersTestMixin,
)


class TestCaliperTransformers(TransformersTestMixin, TestCase):
class CaliperTransformersFixturesTestMixin(TransformersFixturesTestMixin):
"""
Test that supported events are transformed into Caliper format correctly.
"""
EXCEPTED_EVENTS_FIXTURES_PATH = '{}/fixtures/expected'.format(os.path.dirname(os.path.abspath(__file__)))
Mixin for testing Caliper event transformers.
This mixin is split into its own class so it can be used by packages outside of ERB.
"""
registry = CaliperTransformersRegistry

@property
def expected_events_fixture_path(self):
"""
Return the path to the expected transformed events fixture files.
"""
return '{}/fixtures/expected'.format(os.path.dirname(os.path.abspath(__file__)))

def assert_correct_transformer_version(self, transformed_event, transformer_version):
self.assertEqual(transformed_event['extensions']['transformerVersion'], transformer_version)

Expand All @@ -36,3 +46,9 @@ def compare_events(self, transformed_event, expected_event):
expected_event.pop('id')
transformed_event.pop('id')
self.assertDictEqual(expected_event, transformed_event)


class TestCaliperTransformers(CaliperTransformersFixturesTestMixin, TransformersTestMixin, TestCase):
"""
Test that supported events are transformed into Caliper format correctly.
"""
155 changes: 99 additions & 56 deletions event_routing_backends/processors/tests/transformers_test_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Mixin for testing transformers for all of the currently supported events
"""
import json
import logging
import os
from abc import abstractmethod
from unittest.mock import patch
Expand All @@ -16,101 +17,89 @@
from event_routing_backends.processors.mixins.base_transformer import BaseTransformerMixin
from event_routing_backends.tests.factories import UserFactory

logger = logging.getLogger(__name__)
User = get_user_model()

TEST_DIR_PATH = os.path.dirname(os.path.abspath(__file__))

EVENT_FIXTURE_FILENAMES = [
event_file_name for event_file_name in os.listdir(
f'{TEST_DIR_PATH}/fixtures/current/'
) if event_file_name.endswith(".json")
]
try:
EVENT_FIXTURE_FILENAMES = [
event_file_name for event_file_name in os.listdir(
f'{TEST_DIR_PATH}/fixtures/current/'
) if event_file_name.endswith(".json")
]

except FileNotFoundError as exc: # pragma: no cover
# This exception may happen when these test mixins are used outside of the ERB package.
logger.exception(exc)
EVENT_FIXTURE_FILENAMES = []


class DummyTransformer(BaseTransformerMixin):
required_fields = ('does_not_exist',)


@ddt.ddt
class TransformersTestMixin:
class TransformersFixturesTestMixin:
"""
Test that supported events are transformed correctly.
Mixin to help test event transforms using "raw" and "expected" fixture data.
"""
# no limit to diff in the output of tests
maxDiff = None

registry = None
EXCEPTED_EVENTS_FIXTURES_PATH = None

def setUp(self):
super().setUp()
UserFactory.create(username='edx', email='[email protected]')

@property
def raw_events_fixture_path(self):
"""
Return the path to the raw events fixture files.
"""
return f"{TEST_DIR_PATH}/fixtures/current"

@property
def expected_events_fixture_path(self):
"""
Return the path to the expected transformed events fixture files.
"""
raise NotImplementedError

def get_raw_event(self, event_filename):
"""
Return raw event json parsed from current fixtures
"""
base_event_filename = os.path.basename(event_filename)

input_event_file_path = '{test_dir}/fixtures/current/{event_filename}'.format(
test_dir=TEST_DIR_PATH, event_filename=event_filename
input_event_file_path = '{test_dir}/{event_filename}'.format(
test_dir=self.raw_events_fixture_path, event_filename=base_event_filename
)
with open(input_event_file_path, encoding='utf-8') as current:
data = json.loads(current.read())
return data

@override_settings(RUNNING_WITH_TEST_SETTINGS=True)
def test_transformer_version_with_test_settings(self):
self.registry.register('test_event')(DummyTransformer)
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
transformed_event = self.registry.get_transformer(raw_event).transform()
self.assert_correct_transformer_version(transformed_event, '[email protected]')

@override_settings(RUNNING_WITH_TEST_SETTINGS=False)
def test_transformer_version(self):
self.registry.register('test_event')(DummyTransformer)
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
transformed_event = self.registry.get_transformer(raw_event).transform()
self.assert_correct_transformer_version(transformed_event, 'event-routing-backends@{}'.format(__version__))

def test_with_no_field_transformer(self):
self.registry.register('test_event')(DummyTransformer)
with self.assertRaises(ValueError):
self.registry.get_transformer({
'name': 'test_event'
}).transform()

def test_required_field_transformer(self):
self.registry.register('test_event')(DummyTransformer)
with self.assertRaises(ValueError):
self.registry.get_transformer({
"name": "edx.course.enrollment.activated"
}).transform()

@abstractmethod
def compare_events(self, transformed_event, expected_event):
"""
Every transformer's test case will implement its own logic to test
events transformation
"""
@patch('event_routing_backends.helpers.uuid.uuid4')
@ddt.data(*EVENT_FIXTURE_FILENAMES)
def test_event_transformer(self, event_filename, mocked_uuid4):
# Used to generate the anonymized actor.name,
# which in turn is used to generate the event UUID.
mocked_uuid4.return_value = UUID('32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb')
raise NotImplementedError

# if an event's expected fixture doesn't exist, the test shouldn't fail.
# evaluate transformation of only supported event fixtures.
expected_event_file_path = '{expected_events_fixtures_path}/{event_filename}'.format(
expected_events_fixtures_path=self.EXCEPTED_EVENTS_FIXTURES_PATH, event_filename=event_filename
)
def check_event_transformer(self, raw_event_file, expected_event_file):
"""
Test that given event is transformed correctly.
if not os.path.isfile(expected_event_file_path):
return
Transforms the contents of `raw_event_file` and compare it against the contents of `expected_event_file`.
original_event = self.get_raw_event(event_filename)
with open(expected_event_file_path, encoding='utf-8') as expected:
Writes errors to test_out/ for analysis.
"""
original_event = self.get_raw_event(raw_event_file)
with open(expected_event_file, encoding='utf-8') as expected:
expected_event = json.loads(expected.read())

event_filename = os.path.basename(raw_event_file)
if "anonymous" in event_filename:
with pytest.raises(ValueError):
self.registry.get_transformer(original_event).transform()
Expand All @@ -132,7 +121,61 @@ def test_event_transformer(self, event_filename, mocked_uuid4):
actual_transformed_event_file.write(",".join(out_events))
actual_transformed_event_file.write("]")

with open(f"test_output/expected.{event_filename}.json", "w") as expected_event_file:
json.dump(expected_event, expected_event_file, indent=4)
with open(f"test_output/expected.{raw_event_file}.json", "w") as test_output_file:
json.dump(expected_event, test_output_file, indent=4)

raise e


@ddt.ddt
class TransformersTestMixin:
"""
Tests that supported events are transformed correctly.
"""
def test_with_no_field_transformer(self):
self.registry.register('test_event')(DummyTransformer)
with self.assertRaises(ValueError):
self.registry.get_transformer({
'name': 'test_event'
}).transform()

def test_required_field_transformer(self):
self.registry.register('test_event')(DummyTransformer)
with self.assertRaises(ValueError):
self.registry.get_transformer({
"name": "edx.course.enrollment.activated"
}).transform()

@override_settings(RUNNING_WITH_TEST_SETTINGS=True)
def test_transformer_version_with_test_settings(self):
self.registry.register('test_event')(DummyTransformer)
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
transformed_event = self.registry.get_transformer(raw_event).transform()
self.assert_correct_transformer_version(transformed_event, '[email protected]')

@override_settings(RUNNING_WITH_TEST_SETTINGS=False)
def test_transformer_version(self):
self.registry.register('test_event')(DummyTransformer)
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
transformed_event = self.registry.get_transformer(raw_event).transform()
self.assert_correct_transformer_version(transformed_event, 'event-routing-backends@{}'.format(__version__))

@patch('event_routing_backends.helpers.uuid.uuid4')
@ddt.data(*EVENT_FIXTURE_FILENAMES)
def test_event_transformer(self, raw_event_file_path, mocked_uuid4):
# Used to generate the anonymized actor.name,
# which in turn is used to generate the event UUID.
mocked_uuid4.return_value = UUID('32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb')

# if an event's expected fixture doesn't exist, the test shouldn't fail.
# evaluate transformation of only supported event fixtures.
base_event_filename = os.path.basename(raw_event_file_path)

expected_event_file_path = '{expected_events_fixture_path}/{event_filename}'.format(
expected_events_fixture_path=self.expected_events_fixture_path, event_filename=base_event_filename
)

if not os.path.isfile(expected_event_file_path):
return

self.check_event_transformer(raw_event_file_path, expected_event_file_path)
25 changes: 21 additions & 4 deletions event_routing_backends/processors/xapi/tests/test_transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,30 @@
from django.test import TestCase
from django.test.utils import override_settings

from event_routing_backends.processors.tests.transformers_test_mixin import TransformersTestMixin
from event_routing_backends.processors.tests.transformers_test_mixin import (
TransformersFixturesTestMixin,
TransformersTestMixin,
)
from event_routing_backends.processors.xapi import constants
from event_routing_backends.processors.xapi.registry import XApiTransformersRegistry
from event_routing_backends.processors.xapi.transformer import XApiTransformer


class TestXApiTransformers(TransformersTestMixin, TestCase):
class XApiTransformersFixturesTestMixin(TransformersFixturesTestMixin):
"""
Test that supported events are transformed into xAPI format correctly.
Mixin for testing xAPI event transformers.
This mixin is split into its own class so it can be used by packages outside of ERB.
"""
EXCEPTED_EVENTS_FIXTURES_PATH = '{}/fixtures/expected'.format(os.path.dirname(os.path.abspath(__file__)))
registry = XApiTransformersRegistry

@property
def expected_events_fixture_path(self):
"""
Return the path to the expected transformed events fixture files.
"""
return '{}/fixtures/expected'.format(os.path.dirname(os.path.abspath(__file__)))

def assert_correct_transformer_version(self, transformed_event, transformer_version):
self.assertEqual(
transformed_event.context.extensions[constants.XAPI_TRANSFORMER_VERSION_KEY],
Expand Down Expand Up @@ -68,6 +79,12 @@ def _compare_events(self, transformed_event, expected_event):
transformed_event_json = json.loads(transformed_event.to_json())
self.assertDictEqual(expected_event, transformed_event_json)


class TestXApiTransformers(XApiTransformersFixturesTestMixin, TransformersTestMixin, TestCase):
"""
Test xApi event transforms and settings.
"""

@override_settings(XAPI_AGENT_IFI_TYPE='mbox')
def test_xapi_agent_ifi_settings_mbox(self):
self.registry.register('test_event')(XApiTransformer)
Expand Down
Loading

0 comments on commit 9ae9c4f

Please sign in to comment.