Skip to content

Commit 9ae9c4f

Browse files
authored
Allow event-routing-backends to be used when transforming non-openedx 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.
1 parent 980b2aa commit 9ae9c4f

File tree

10 files changed

+331
-147
lines changed

10 files changed

+331
-147
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ Change Log
1111

1212
.. There should always be an "Unreleased" section for changes pending release.
1313
14-
Unreleased
15-
~~~~~~~~~~
14+
[9.3.0]
15+
16+
* Support use of ERB for transforming non-openedx events
17+
1618
[9.2.1]
1719

1820
* Add support for either 'whitelist' or 'registry.mapping' options (whitelist introduced in v9.0.0)

event_routing_backends/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Various backends for receiving edX LMS events..
33
"""
44

5-
__version__ = '9.2.1'
5+
__version__ = '9.3.0'

event_routing_backends/helpers.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,29 @@
66
import uuid
77
from urllib.parse import parse_qs, urlparse
88

9-
# Imported from edx-platform
10-
# pylint: disable=import-error
11-
from common.djangoapps.student.models import get_potentially_retired_user_by_username
129
from dateutil.parser import parse
1310
from django.conf import settings
1411
from django.contrib.auth import get_user_model
1512
from isodate import duration_isoformat
1613
from opaque_keys.edx.keys import CourseKey
17-
from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews
18-
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
1914

2015
logger = logging.getLogger(__name__)
21-
User = get_user_model()
2216

17+
# Imported from edx-platform
18+
try:
19+
from common.djangoapps.student.models import get_potentially_retired_user_by_username
20+
from openedx.core.djangoapps.content.course_overviews.api import get_course_overviews
21+
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
22+
except ImportError as exc: # pragma: no cover
23+
logger.exception(exc)
24+
25+
get_potentially_retired_user_by_username = None
26+
get_course_overviews = None
27+
ExternalId = None
28+
ExternalIdType = None
29+
30+
31+
User = get_user_model()
2332
UTC_DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%f'
2433
BLOCK_ID_FORMAT = '{block_version}:{course_id}+type@{block_type}+block@{block_id}'
2534

@@ -57,6 +66,9 @@ def get_anonymous_user_id(username_or_id, external_type):
5766
Returns:
5867
str
5968
"""
69+
if not (ExternalId and ExternalIdType):
70+
raise ImportError("Could not import external_user_ids from edx-platform.") # pragma: no cover
71+
6072
user = get_user(username_or_id)
6173
if not user:
6274
logger.warning('User with username "%s" does not exist. '
@@ -94,6 +106,9 @@ def get_user(username_or_id):
94106
Returns:
95107
user object
96108
"""
109+
if not get_potentially_retired_user_by_username:
110+
raise ImportError("Could not import student.models from edx-platform.") # pragma: no cover
111+
97112
user = username = None
98113

99114
if not username_or_id:
@@ -145,6 +160,9 @@ def get_course_from_id(course_id):
145160
Returns:
146161
Course
147162
"""
163+
if not get_course_overviews:
164+
raise ImportError("Could not import course_overviews.api from edx-platform.") # pragma: no cover
165+
148166
course_key = CourseKey.from_string(course_id)
149167
course_overviews = get_course_overviews([course_key])
150168
if course_overviews:

event_routing_backends/processors/caliper/tests/test_transformers.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,27 @@
66
from django.test import TestCase
77

88
from event_routing_backends.processors.caliper.registry import CaliperTransformersRegistry
9-
from event_routing_backends.processors.tests.transformers_test_mixin import TransformersTestMixin
9+
from event_routing_backends.processors.tests.transformers_test_mixin import (
10+
TransformersFixturesTestMixin,
11+
TransformersTestMixin,
12+
)
1013

1114

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

23+
@property
24+
def expected_events_fixture_path(self):
25+
"""
26+
Return the path to the expected transformed events fixture files.
27+
"""
28+
return '{}/fixtures/expected'.format(os.path.dirname(os.path.abspath(__file__)))
29+
2030
def assert_correct_transformer_version(self, transformed_event, transformer_version):
2131
self.assertEqual(transformed_event['extensions']['transformerVersion'], transformer_version)
2232

@@ -36,3 +46,9 @@ def compare_events(self, transformed_event, expected_event):
3646
expected_event.pop('id')
3747
transformed_event.pop('id')
3848
self.assertDictEqual(expected_event, transformed_event)
49+
50+
51+
class TestCaliperTransformers(CaliperTransformersFixturesTestMixin, TransformersTestMixin, TestCase):
52+
"""
53+
Test that supported events are transformed into Caliper format correctly.
54+
"""

event_routing_backends/processors/tests/transformers_test_mixin.py

Lines changed: 99 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Mixin for testing transformers for all of the currently supported events
33
"""
44
import json
5+
import logging
56
import os
67
from abc import abstractmethod
78
from unittest.mock import patch
@@ -16,101 +17,89 @@
1617
from event_routing_backends.processors.mixins.base_transformer import BaseTransformerMixin
1718
from event_routing_backends.tests.factories import UserFactory
1819

20+
logger = logging.getLogger(__name__)
1921
User = get_user_model()
2022

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

23-
EVENT_FIXTURE_FILENAMES = [
24-
event_file_name for event_file_name in os.listdir(
25-
f'{TEST_DIR_PATH}/fixtures/current/'
26-
) if event_file_name.endswith(".json")
27-
]
25+
try:
26+
EVENT_FIXTURE_FILENAMES = [
27+
event_file_name for event_file_name in os.listdir(
28+
f'{TEST_DIR_PATH}/fixtures/current/'
29+
) if event_file_name.endswith(".json")
30+
]
31+
32+
except FileNotFoundError as exc: # pragma: no cover
33+
# This exception may happen when these test mixins are used outside of the ERB package.
34+
logger.exception(exc)
35+
EVENT_FIXTURE_FILENAMES = []
2836

2937

3038
class DummyTransformer(BaseTransformerMixin):
3139
required_fields = ('does_not_exist',)
3240

3341

34-
@ddt.ddt
35-
class TransformersTestMixin:
42+
class TransformersFixturesTestMixin:
3643
"""
37-
Test that supported events are transformed correctly.
44+
Mixin to help test event transforms using "raw" and "expected" fixture data.
3845
"""
3946
# no limit to diff in the output of tests
4047
maxDiff = None
4148

4249
registry = None
43-
EXCEPTED_EVENTS_FIXTURES_PATH = None
4450

4551
def setUp(self):
52+
super().setUp()
4653
UserFactory.create(username='edx', email='edx@example.com')
4754

55+
@property
56+
def raw_events_fixture_path(self):
57+
"""
58+
Return the path to the raw events fixture files.
59+
"""
60+
return f"{TEST_DIR_PATH}/fixtures/current"
61+
62+
@property
63+
def expected_events_fixture_path(self):
64+
"""
65+
Return the path to the expected transformed events fixture files.
66+
"""
67+
raise NotImplementedError
68+
4869
def get_raw_event(self, event_filename):
4970
"""
5071
Return raw event json parsed from current fixtures
5172
"""
73+
base_event_filename = os.path.basename(event_filename)
5274

53-
input_event_file_path = '{test_dir}/fixtures/current/{event_filename}'.format(
54-
test_dir=TEST_DIR_PATH, event_filename=event_filename
75+
input_event_file_path = '{test_dir}/{event_filename}'.format(
76+
test_dir=self.raw_events_fixture_path, event_filename=base_event_filename
5577
)
5678
with open(input_event_file_path, encoding='utf-8') as current:
5779
data = json.loads(current.read())
5880
return data
5981

60-
@override_settings(RUNNING_WITH_TEST_SETTINGS=True)
61-
def test_transformer_version_with_test_settings(self):
62-
self.registry.register('test_event')(DummyTransformer)
63-
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
64-
transformed_event = self.registry.get_transformer(raw_event).transform()
65-
self.assert_correct_transformer_version(transformed_event, 'event-routing-backends@1.1.1')
66-
67-
@override_settings(RUNNING_WITH_TEST_SETTINGS=False)
68-
def test_transformer_version(self):
69-
self.registry.register('test_event')(DummyTransformer)
70-
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
71-
transformed_event = self.registry.get_transformer(raw_event).transform()
72-
self.assert_correct_transformer_version(transformed_event, 'event-routing-backends@{}'.format(__version__))
73-
74-
def test_with_no_field_transformer(self):
75-
self.registry.register('test_event')(DummyTransformer)
76-
with self.assertRaises(ValueError):
77-
self.registry.get_transformer({
78-
'name': 'test_event'
79-
}).transform()
80-
81-
def test_required_field_transformer(self):
82-
self.registry.register('test_event')(DummyTransformer)
83-
with self.assertRaises(ValueError):
84-
self.registry.get_transformer({
85-
"name": "edx.course.enrollment.activated"
86-
}).transform()
87-
8882
@abstractmethod
8983
def compare_events(self, transformed_event, expected_event):
9084
"""
9185
Every transformer's test case will implement its own logic to test
9286
events transformation
9387
"""
94-
@patch('event_routing_backends.helpers.uuid.uuid4')
95-
@ddt.data(*EVENT_FIXTURE_FILENAMES)
96-
def test_event_transformer(self, event_filename, mocked_uuid4):
97-
# Used to generate the anonymized actor.name,
98-
# which in turn is used to generate the event UUID.
99-
mocked_uuid4.return_value = UUID('32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb')
88+
raise NotImplementedError
10089

101-
# if an event's expected fixture doesn't exist, the test shouldn't fail.
102-
# evaluate transformation of only supported event fixtures.
103-
expected_event_file_path = '{expected_events_fixtures_path}/{event_filename}'.format(
104-
expected_events_fixtures_path=self.EXCEPTED_EVENTS_FIXTURES_PATH, event_filename=event_filename
105-
)
90+
def check_event_transformer(self, raw_event_file, expected_event_file):
91+
"""
92+
Test that given event is transformed correctly.
10693
107-
if not os.path.isfile(expected_event_file_path):
108-
return
94+
Transforms the contents of `raw_event_file` and compare it against the contents of `expected_event_file`.
10995
110-
original_event = self.get_raw_event(event_filename)
111-
with open(expected_event_file_path, encoding='utf-8') as expected:
96+
Writes errors to test_out/ for analysis.
97+
"""
98+
original_event = self.get_raw_event(raw_event_file)
99+
with open(expected_event_file, encoding='utf-8') as expected:
112100
expected_event = json.loads(expected.read())
113101

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

135-
with open(f"test_output/expected.{event_filename}.json", "w") as expected_event_file:
136-
json.dump(expected_event, expected_event_file, indent=4)
124+
with open(f"test_output/expected.{raw_event_file}.json", "w") as test_output_file:
125+
json.dump(expected_event, test_output_file, indent=4)
137126

138127
raise e
128+
129+
130+
@ddt.ddt
131+
class TransformersTestMixin:
132+
"""
133+
Tests that supported events are transformed correctly.
134+
"""
135+
def test_with_no_field_transformer(self):
136+
self.registry.register('test_event')(DummyTransformer)
137+
with self.assertRaises(ValueError):
138+
self.registry.get_transformer({
139+
'name': 'test_event'
140+
}).transform()
141+
142+
def test_required_field_transformer(self):
143+
self.registry.register('test_event')(DummyTransformer)
144+
with self.assertRaises(ValueError):
145+
self.registry.get_transformer({
146+
"name": "edx.course.enrollment.activated"
147+
}).transform()
148+
149+
@override_settings(RUNNING_WITH_TEST_SETTINGS=True)
150+
def test_transformer_version_with_test_settings(self):
151+
self.registry.register('test_event')(DummyTransformer)
152+
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
153+
transformed_event = self.registry.get_transformer(raw_event).transform()
154+
self.assert_correct_transformer_version(transformed_event, 'event-routing-backends@1.1.1')
155+
156+
@override_settings(RUNNING_WITH_TEST_SETTINGS=False)
157+
def test_transformer_version(self):
158+
self.registry.register('test_event')(DummyTransformer)
159+
raw_event = self.get_raw_event('edx.course.enrollment.activated.json')
160+
transformed_event = self.registry.get_transformer(raw_event).transform()
161+
self.assert_correct_transformer_version(transformed_event, 'event-routing-backends@{}'.format(__version__))
162+
163+
@patch('event_routing_backends.helpers.uuid.uuid4')
164+
@ddt.data(*EVENT_FIXTURE_FILENAMES)
165+
def test_event_transformer(self, raw_event_file_path, mocked_uuid4):
166+
# Used to generate the anonymized actor.name,
167+
# which in turn is used to generate the event UUID.
168+
mocked_uuid4.return_value = UUID('32e08e30-f8ae-4ce2-94a8-c2bfe38a70cb')
169+
170+
# if an event's expected fixture doesn't exist, the test shouldn't fail.
171+
# evaluate transformation of only supported event fixtures.
172+
base_event_filename = os.path.basename(raw_event_file_path)
173+
174+
expected_event_file_path = '{expected_events_fixture_path}/{event_filename}'.format(
175+
expected_events_fixture_path=self.expected_events_fixture_path, event_filename=base_event_filename
176+
)
177+
178+
if not os.path.isfile(expected_event_file_path):
179+
return
180+
181+
self.check_event_transformer(raw_event_file_path, expected_event_file_path)

event_routing_backends/processors/xapi/tests/test_transformers.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,30 @@
88
from django.test import TestCase
99
from django.test.utils import override_settings
1010

11-
from event_routing_backends.processors.tests.transformers_test_mixin import TransformersTestMixin
11+
from event_routing_backends.processors.tests.transformers_test_mixin import (
12+
TransformersFixturesTestMixin,
13+
TransformersTestMixin,
14+
)
1215
from event_routing_backends.processors.xapi import constants
1316
from event_routing_backends.processors.xapi.registry import XApiTransformersRegistry
1417
from event_routing_backends.processors.xapi.transformer import XApiTransformer
1518

1619

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

28+
@property
29+
def expected_events_fixture_path(self):
30+
"""
31+
Return the path to the expected transformed events fixture files.
32+
"""
33+
return '{}/fixtures/expected'.format(os.path.dirname(os.path.abspath(__file__)))
34+
2435
def assert_correct_transformer_version(self, transformed_event, transformer_version):
2536
self.assertEqual(
2637
transformed_event.context.extensions[constants.XAPI_TRANSFORMER_VERSION_KEY],
@@ -68,6 +79,12 @@ def _compare_events(self, transformed_event, expected_event):
6879
transformed_event_json = json.loads(transformed_event.to_json())
6980
self.assertDictEqual(expected_event, transformed_event_json)
7081

82+
83+
class TestXApiTransformers(XApiTransformersFixturesTestMixin, TransformersTestMixin, TestCase):
84+
"""
85+
Test xApi event transforms and settings.
86+
"""
87+
7188
@override_settings(XAPI_AGENT_IFI_TYPE='mbox')
7289
def test_xapi_agent_ifi_settings_mbox(self):
7390
self.registry.register('test_event')(XApiTransformer)

0 commit comments

Comments
 (0)