From 452a84ef6e860fd52664665ff8c389abd720592e Mon Sep 17 00:00:00 2001
From: Jillian Vogel <jill@opencraft.com>
Date: Mon, 31 Jul 2023 17:35:45 +0930
Subject: [PATCH] refactor: applies functional programming principles to event
 transformation

This reduces the side-effects of event transformation, which will allow
us to re-use base event transformation methods when generating multiple
events from a single source event.

* Removes the BaseTransformerMixin.transformed_event instance variable
  in favor of passing an event through to base_transform() to be
  modified and returned.
* Adds BaseTransformerMixin.get_object() so that caliper events don't
  need to reference self.transformed_event when updating the object data.
* Adds BaseTransformerMixin.get_extensions() so that caliper events can
  don't have to hack in their transformerVersion during
  BaseTransformerMixin.transform()
---
 .../event_transformers/enrollment_events.py   |  5 +-
 .../event_transformers/navigation_events.py   |  2 +-
 .../problem_interaction_events.py             |  2 +-
 .../event_transformers/video_events.py        | 10 +--
 .../processors/caliper/transformer.py         | 67 +++++++++++++------
 .../processors/mixins/base_transformer.py     | 32 +++++----
 .../processors/xapi/transformer.py            | 14 ++--
 7 files changed, 86 insertions(+), 46 deletions(-)

diff --git a/event_routing_backends/processors/caliper/event_transformers/enrollment_events.py b/event_routing_backends/processors/caliper/event_transformers/enrollment_events.py
index dfdda372..74c27a55 100644
--- a/event_routing_backends/processors/caliper/event_transformers/enrollment_events.py
+++ b/event_routing_backends/processors/caliper/event_transformers/enrollment_events.py
@@ -50,10 +50,11 @@ def get_object(self):
 
         # TODO: replace with anonymous enrollment id?
         course_root_url = self.get_object_iri('course', self.get_data('data.course_id', True))
-        caliper_object = {
+        caliper_object = super().get_object()
+        caliper_object.update({
             'id': course_root_url,
             'type': 'CourseOffering',
             'name': course['display_name'],
             'extensions': {'mode': self.get_data('data.mode')} if self.get_data('data.mode') is not None else None,
-        }
+        })
         return caliper_object
diff --git a/event_routing_backends/processors/caliper/event_transformers/navigation_events.py b/event_routing_backends/processors/caliper/event_transformers/navigation_events.py
index 8c322ecc..50171dc8 100644
--- a/event_routing_backends/processors/caliper/event_transformers/navigation_events.py
+++ b/event_routing_backends/processors/caliper/event_transformers/navigation_events.py
@@ -39,7 +39,7 @@ def get_object(self):
             dict
         """
         self.backend_name = 'caliper'
-        caliper_object = self.transformed_event['object']
+        caliper_object = super().get_object()
 
         data = self.get_data('data')
         extensions = {}
diff --git a/event_routing_backends/processors/caliper/event_transformers/problem_interaction_events.py b/event_routing_backends/processors/caliper/event_transformers/problem_interaction_events.py
index 38f0b36d..23e007f5 100644
--- a/event_routing_backends/processors/caliper/event_transformers/problem_interaction_events.py
+++ b/event_routing_backends/processors/caliper/event_transformers/problem_interaction_events.py
@@ -149,7 +149,7 @@ def get_object(self):
         else:
             iri_url = object_id
 
-        caliper_object = self.transformed_event['object']
+        caliper_object = super().get_object()
         caliper_object.update({
             'id': self.get_object_iri('xblock', iri_url),
             'type': OBJECT_TYPE_MAP.get(key, 'Attempt'),
diff --git a/event_routing_backends/processors/caliper/event_transformers/video_events.py b/event_routing_backends/processors/caliper/event_transformers/video_events.py
index d202a348..87bc3516 100644
--- a/event_routing_backends/processors/caliper/event_transformers/video_events.py
+++ b/event_routing_backends/processors/caliper/event_transformers/video_events.py
@@ -82,7 +82,7 @@ def get_object(self):
             dict
         """
         self.backend_name = 'caliper'
-        caliper_object = self.transformed_event['object']
+        caliper_object = super().get_object()
         data = self.get_data('data')
         course_id = self.get_data('context.course_id', True)
         video_id = self.get_data('data.id', True)
@@ -176,10 +176,12 @@ class VideoSpeedChangedTransformer(BaseVideoTransformer):
     """
     Transform the event fired when a video's speed is changed.
     """
-    additional_fields = ('target', 'extensions',)
+    additional_fields = ('target',)
 
     def get_extensions(self):
-        return {
+        extensions = super().get_extensions()
+        extensions.update({
             'oldSpeed': self.get_data('old_speed'),
             'newSpeed': self.get_data('new_speed'),
-        }
+        })
+        return extensions
diff --git a/event_routing_backends/processors/caliper/transformer.py b/event_routing_backends/processors/caliper/transformer.py
index 0ec532eb..0f7e9496 100644
--- a/event_routing_backends/processors/caliper/transformer.py
+++ b/event_routing_backends/processors/caliper/transformer.py
@@ -19,41 +19,35 @@ class CaliperTransformer(BaseTransformerMixin):
     required_fields = (
         'type',
         'object',
-        'action'
+        'action',
+        'extensions',
     )
 
-    def base_transform(self):
+    def base_transform(self, transformed_event):
         """
         Transform common Caliper fields.
         """
-        self._add_generic_fields()
-        self._add_actor_info()
-        self._add_session_info()
+        transformed_event = super().base_transform(transformed_event)
+        self._add_generic_fields(transformed_event)
+        self._add_actor_info(transformed_event)
+        self._add_session_info(transformed_event)
+        return transformed_event
 
-    def _add_generic_fields(self):
+    def _add_generic_fields(self, transformed_event):
         """
         Add all of the generic fields to the transformed_event object.
         """
-        self.transformed_event.update({
+        transformed_event.update({
             '@context': CALIPER_EVENT_CONTEXT,
             'id': uuid.uuid4().urn,
             'eventTime': convert_datetime_to_iso(self.get_data('timestamp', True)),
-            'extensions': {}
         })
-        self.transformed_event['object'] = {}
-        course_id = self.get_data('context.course_id')
-        if course_id is not None:
-            extensions = {"isPartOf": {}}
-            extensions['isPartOf']['id'] = self.get_object_iri('course', course_id)
-            extensions['isPartOf']['type'] = 'CourseOffering'
-            self.transformed_event['object']['extensions'] = {}
-            self.transformed_event['object']['extensions'].update(extensions)
 
-    def _add_actor_info(self):
+    def _add_actor_info(self, transformed_event):
         """
-        Add all generic information related to `actor`.
+        Add all generic information related to `actor` to the transformed_event.
         """
-        self.transformed_event['actor'] = {
+        transformed_event['actor'] = {
             'id': self.get_object_iri(
                 'user',
                 get_anonymous_user_id(self.extract_username_or_userid(), 'CALIPER'),
@@ -61,16 +55,45 @@ def _add_actor_info(self):
             'type': 'Person'
         }
 
-    def _add_session_info(self):
+    def _add_session_info(self, transformed_event):
         """
-        Add session info related to the event
+        Add session info related to the transformed_event.
         """
         sessionid = self.extract_sessionid()
         if sessionid:
-            self.transformed_event['session'] = {
+            transformed_event['session'] = {
                 'id': self.get_object_iri(
                     'sessions',
                     sessionid,
                 ),
                 'type': 'Session'
             }
+
+    def get_object(self):
+        """
+        Return object for the event.
+
+        Returns:
+            dict
+        """
+        caliper_object = super().get_object()
+        course_id = self.get_data('context.course_id')
+        if course_id is not None:
+            extensions = {"isPartOf": {}}
+            extensions['isPartOf']['id'] = self.get_object_iri('course', course_id)
+            extensions['isPartOf']['type'] = 'CourseOffering'
+            caliper_object['extensions'] = {}
+            caliper_object['extensions'].update(extensions)
+
+        return caliper_object
+
+    def get_extensions(self):
+        """
+        Return extensions for the event.
+
+        Returns:
+            dict
+        """
+        return {
+            'transformerVersion': self.transformer_version,
+        }
diff --git a/event_routing_backends/processors/mixins/base_transformer.py b/event_routing_backends/processors/mixins/base_transformer.py
index 78cf8398..a4c78743 100644
--- a/event_routing_backends/processors/mixins/base_transformer.py
+++ b/event_routing_backends/processors/mixins/base_transformer.py
@@ -30,7 +30,6 @@ def __init__(self, event):
             event (dict)    :   event to be transformed
         """
         self.event = event.copy()
-        self.transformed_event = {}
 
     @staticmethod
     def find_nested(source_dict, key):
@@ -65,14 +64,17 @@ def _find_nested(event_dict):
 
         return _find_nested(source_dict)
 
-    def base_transform(self):
+    def base_transform(self, transformed_event):
         """
         Transform the fields that are common for all events.
 
         Other classes can override this method to add common transformation
         code for events.
+
+        Returns:
+            ANY
         """
-        return
+        return transformed_event or {}
 
     @property
     def transformer_version(self):
@@ -92,18 +94,18 @@ def transform(self):
         Transform the edX event.
 
         Returns:
-            dict
+            ANY
         """
-        self.base_transform()
+        transformed_event = self.base_transform({})
 
         transforming_fields = self.required_fields + self.additional_fields
         for key in transforming_fields:
             if hasattr(self, key):
                 value = getattr(self, key)
-                self.transformed_event[key] = value
+                transformed_event[key] = value
             elif hasattr(self, f'get_{key}'):
                 value = getattr(self, f'get_{key}')()
-                self.transformed_event[key] = value
+                transformed_event[key] = value
             else:
                 raise ValueError(
                     'Cannot find value for "{}" in transformer {} for the edx event "{}"'.format(
@@ -111,12 +113,9 @@ def transform(self):
                     )
                 )
 
-        if self.backend_name == 'caliper':
-            self.transformed_event['extensions']['transformerVersion'] = self.transformer_version
-
-        self.transformed_event = self.del_none(self.transformed_event)
+        transformed_event = self.del_none(transformed_event)
 
-        return self.transformed_event
+        return transformed_event
 
     def extract_username_or_userid(self):
         """
@@ -218,3 +217,12 @@ def get_object_iri(self, object_type, object_id):
             object_type=object_type,
             object_id=object_id
         )
+
+    def get_object(self):
+        """
+        Return object for the event.
+
+        Returns:
+            dict
+        """
+        return {}
diff --git a/event_routing_backends/processors/xapi/transformer.py b/event_routing_backends/processors/xapi/transformer.py
index 74f2ff0e..ee2860ad 100644
--- a/event_routing_backends/processors/xapi/transformer.py
+++ b/event_routing_backends/processors/xapi/transformer.py
@@ -46,22 +46,28 @@ def transform(self):
         transformed_props["version"] = constants.XAPI_SPECIFICATION_VERSION
         return Statement(**transformed_props)
 
-    def base_transform(self):
+    def base_transform(self, transformed_event):
         """
         Transform the fields that are common for all events.
         """
+        transformed_event = super().base_transform(transformed_event)
         actor = self.get_actor()
         event_timestamp = self.get_timestamp()
-        self.transformed_event = {
+        transformed_event.update({
             'actor': actor,
             'context': self.get_context(),
             'timestamp': event_timestamp,
-        }
+        })
+        transformed_event['actor'] = self.get_actor()
+        transformed_event['context'] = self.get_context()
+        transformed_event['timestamp'] = self.get_timestamp()
+
         # Warning! changing anything in these 2 lines or changing the "base_uuid" can invalidate
         # billions of rows in the database. Please have a community discussion first before introducing
         # any change in generation of UUID.
         uuid_str = f'{actor.to_json()}-{event_timestamp}'
-        self.transformed_event['id'] = get_uuid5(self.verb.to_json(), uuid_str)  # pylint: disable=no-member
+        transformed_event['id'] = get_uuid5(self.verb.to_json(), uuid_str)  # pylint: disable=no-member
+        return transformed_event
 
     def get_actor(self):
         """