diff --git a/cms/envs/common.py b/cms/envs/common.py index 4fc14fdcb6852408035479a6e07d1584fcc396b1..58f831ae77d8b6e2e8684e5cadb9be20feb64ec4 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -685,8 +685,11 @@ REQUIRE_DEBUG = False REQUIRE_EXCLUDE = ("build.txt",) # The execution environment in which to run r.js: auto, node or rhino. -# auto will autodetect the environment and make use of node if available and rhino if not. -# It can also be a path to a custom class that subclasses require.environments.Environment and defines some "args" function that returns a list with the command arguments to execute. +# auto will autodetect the environment and make use of node if available and +# rhino if not. +# It can also be a path to a custom class that subclasses +# require.environments.Environment and defines some "args" function that +# returns a list with the command arguments to execute. REQUIRE_ENVIRONMENT = "node" @@ -957,7 +960,7 @@ EVENT_TRACKING_BACKENDS = { }, 'processors': [ {'ENGINE': 'track.shim.LegacyFieldMappingProcessor'}, - {'ENGINE': 'track.shim.VideoEventProcessor'} + {'ENGINE': 'track.shim.PrefixedEventProcessor'} ] } }, diff --git a/common/djangoapps/track/shim.py b/common/djangoapps/track/shim.py index 311b2cacc1449c6808cd99fd2bad9af726c71aab..76e4a40a9312169fe2836f8742be4f871f633b86 100644 --- a/common/djangoapps/track/shim.py +++ b/common/djangoapps/track/shim.py @@ -1,14 +1,10 @@ """Map new event context values to old top-level field values. Ensures events can be parsed by legacy parsers.""" import json -import logging -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import UsageKey +from .transformers import EventTransformerRegistry -log = logging.getLogger(__name__) - CONTEXT_FIELDS_TO_INCLUDE = [ 'username', 'session', @@ -63,6 +59,9 @@ class LegacyFieldMappingProcessor(object): def remove_shim_context(event): + """ + Remove obsolete fields from event context. + """ if 'context' in event: context = event['context'] # These fields are present elsewhere in the event at this point @@ -74,100 +73,6 @@ def remove_shim_context(event): del context[field] -NAME_TO_EVENT_TYPE_MAP = { - 'edx.video.played': 'play_video', - 'edx.video.paused': 'pause_video', - 'edx.video.stopped': 'stop_video', - 'edx.video.loaded': 'load_video', - 'edx.video.position.changed': 'seek_video', - 'edx.video.seeked': 'seek_video', - 'edx.video.transcript.shown': 'show_transcript', - 'edx.video.transcript.hidden': 'hide_transcript', -} - - -class VideoEventProcessor(object): - """ - Converts new format video events into the legacy video event format. - - Mobile devices cannot actually emit events that exactly match their counterparts emitted by the LMS javascript - video player. Instead of attempting to get them to do that, we instead insert a shim here that converts the events - they *can* easily emit and converts them into the legacy format. - - TODO: Remove this shim and perform the conversion as part of some batch canonicalization process. - - """ - - def __call__(self, event): - name = event.get('name') - if not name: - return - - if name not in NAME_TO_EVENT_TYPE_MAP: - return - - # Convert edx.video.seeked to edx.video.position.changed because edx.video.seeked was not intended to actually - # ever be emitted. - if name == "edx.video.seeked": - event['name'] = "edx.video.position.changed" - - event['event_type'] = NAME_TO_EVENT_TYPE_MAP[name] - - if 'event' not in event: - return - payload = event['event'] - - if 'module_id' in payload: - module_id = payload['module_id'] - try: - usage_key = UsageKey.from_string(module_id) - except InvalidKeyError: - log.warning('Unable to parse module_id "%s"', module_id, exc_info=True) - else: - payload['id'] = usage_key.html_id() - - del payload['module_id'] - - if 'current_time' in payload: - payload['currentTime'] = payload.pop('current_time') - - if 'context' in event: - context = event['context'] - - # Converts seek_type to seek and skip|slide to onSlideSeek|onSkipSeek - if 'seek_type' in payload: - seek_type = payload['seek_type'] - if seek_type == 'slide': - payload['type'] = "onSlideSeek" - elif seek_type == 'skip': - payload['type'] = "onSkipSeek" - del payload['seek_type'] - - # For the iOS build that is returning a +30 for back skip 30 - if ( - context['application']['version'] == "1.0.02" and - context['application']['name'] == "edx.mobileapp.iOS" - ): - if 'requested_skip_interval' in payload and 'type' in payload: - if ( - payload['requested_skip_interval'] == 30 and - payload['type'] == "onSkipSeek" - ): - payload['requested_skip_interval'] = -30 - - # For the Android build that isn't distinguishing between skip/seek - if 'requested_skip_interval' in payload: - if abs(payload['requested_skip_interval']) != 30: - if 'type' in payload: - payload['type'] = 'onSlideSeek' - - if 'open_in_browser_url' in context: - page, _sep, _tail = context.pop('open_in_browser_url').rpartition('/') - event['page'] = page - - event['event'] = json.dumps(payload) - - class GoogleAnalyticsProcessor(object): """Adds course_id as label, and sets nonInteraction property""" @@ -184,3 +89,22 @@ class GoogleAnalyticsProcessor(object): copied_event['nonInteraction'] = 1 return copied_event + + +class PrefixedEventProcessor(object): + """ + Process any events whose name or prefix (ending with a '.') is registered + as an EventTransformer. + """ + + def __call__(self, event): + """ + If the event is registered with the EventTransformerRegistry, transform + it. Otherwise do nothing to it, and continue processing. + """ + try: + event = EventTransformerRegistry.create_transformer(event) + except KeyError: + return + event.transform() + return event diff --git a/common/djangoapps/track/tests/test_shim.py b/common/djangoapps/track/tests/test_shim.py index f9e0eada98df71509d9134d5edc811a7ee7c53a6..039a0d6cbd18c44289960107931927fd15de7aa1 100644 --- a/common/djangoapps/track/tests/test_shim.py +++ b/common/djangoapps/track/tests/test_shim.py @@ -1,10 +1,16 @@ """Ensure emitted events contain the fields legacy processors expect to find.""" +from collections import namedtuple + +import ddt from mock import sentinel from django.test.utils import override_settings from openedx.core.lib.tests.assertions.events import assert_events_equal -from track.tests import EventTrackingTestCase, FROZEN_TIME + +from . import EventTrackingTestCase, FROZEN_TIME +from ..shim import PrefixedEventProcessor +from .. import transformers LEGACY_SHIM_PROCESSOR = [ @@ -216,3 +222,100 @@ class MultipleShimGoogleAnalyticsProcessorTestCase(EventTrackingTestCase): 'timestamp': FROZEN_TIME, } assert_events_equal(expected_event, log_emitted_event) + + +SequenceDDT = namedtuple('SequenceDDT', ['action', 'tab_count', 'current_tab', 'legacy_event_type']) + + +@ddt.ddt +class EventTransformerRegistryTestCase(EventTrackingTestCase): + """ + Test the behavior of the event registry + """ + + def setUp(self): + super(EventTransformerRegistryTestCase, self).setUp() + self.registry = transformers.EventTransformerRegistry() + + @ddt.data( + ('edx.ui.lms.sequence.next_selected', transformers.NextSelectedEventTransformer), + ('edx.ui.lms.sequence.previous_selected', transformers.PreviousSelectedEventTransformer), + ('edx.ui.lms.sequence.tab_selected', transformers.SequenceTabSelectedEventTransformer), + ('edx.video.foo.bar', transformers.VideoEventTransformer), + ) + @ddt.unpack + def test_event_registry_dispatch(self, event_name, expected_transformer): + event = {'name': event_name} + transformer = self.registry.create_transformer(event) + self.assertIsInstance(transformer, expected_transformer) + + @ddt.data( + 'edx.ui.lms.sequence.next_selected.what', + 'edx', + 'unregistered_event', + ) + def test_dispatch_to_nonexistent_events(self, event_name): + event = {'name': event_name} + with self.assertRaises(KeyError): + self.registry.create_transformer(event) + + +@ddt.ddt +class PrefixedEventProcessorTestCase(EventTrackingTestCase): + """ + Test PrefixedEventProcessor + """ + + @ddt.data( + SequenceDDT(action=u'next', tab_count=5, current_tab=3, legacy_event_type=u'seq_next'), + SequenceDDT(action=u'next', tab_count=5, current_tab=5, legacy_event_type=None), + SequenceDDT(action=u'previous', tab_count=5, current_tab=3, legacy_event_type=u'seq_prev'), + SequenceDDT(action=u'previous', tab_count=5, current_tab=1, legacy_event_type=None), + ) + def test_sequence_linear_navigation(self, sequence_ddt): + event_name = u'edx.ui.lms.sequence.{}_selected'.format(sequence_ddt.action) + + event = { + u'name': event_name, + u'event': { + u'current_tab': sequence_ddt.current_tab, + u'tab_count': sequence_ddt.tab_count, + u'id': u'ABCDEFG', + } + } + + process_event_shim = PrefixedEventProcessor() + result = process_event_shim(event) + + # Legacy fields get added when needed + if sequence_ddt.action == u'next': + offset = 1 + else: + offset = -1 + if sequence_ddt.legacy_event_type: + self.assertEqual(result[u'event_type'], sequence_ddt.legacy_event_type) + self.assertEqual(result[u'event'][u'old'], sequence_ddt.current_tab) + self.assertEqual(result[u'event'][u'new'], sequence_ddt.current_tab + offset) + else: + self.assertNotIn(u'event_type', result) + self.assertNotIn(u'old', result[u'event']) + self.assertNotIn(u'new', result[u'event']) + + def test_sequence_tab_navigation(self): + event_name = u'edx.ui.lms.sequence.tab_selected' + event = { + u'name': event_name, + u'event': { + u'current_tab': 2, + u'target_tab': 5, + u'tab_count': 9, + u'id': u'block-v1:abc', + u'widget_placement': u'top', + } + } + + process_event_shim = PrefixedEventProcessor() + result = process_event_shim(event) + self.assertEqual(result[u'event_type'], u'seq_goto') + self.assertEqual(result[u'event'][u'old'], 2) + self.assertEqual(result[u'event'][u'new'], 5) diff --git a/common/djangoapps/track/transformers.py b/common/djangoapps/track/transformers.py new file mode 100644 index 0000000000000000000000000000000000000000..471defe9ce13cd0c9b0a18d1002fd33ff81caaf9 --- /dev/null +++ b/common/djangoapps/track/transformers.py @@ -0,0 +1,502 @@ +""" +EventTransformers are data structures that represents events, and modify those +events to match the format desired for the tracking logs. They are registered +by name (or name prefix) in the EventTransformerRegistry, which is used to +apply them to the appropriate events. +""" + +import json +import logging + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey + +log = logging.getLogger(__name__) + + +class DottedPathMapping(object): + """ + Dictionary-like object for creating keys of dotted paths. + + If a key is created that ends with a dot, it will be treated as a path + prefix. Any value whose prefix matches the dotted path can be used + as a key for that value, but only the most specific match will + be used. + """ + + # TODO: The current implementation of the prefix registry requires + # O(number of prefix event transformers) to access an event. If we get a + # large number of EventTransformers, it may be worth writing a tree-based + # map structure where each node is a segment of the match key, which would + # reduce access time to O(len(match.key.split('.'))), or essentially constant + # time. + + def __init__(self, registry=None): + self._match_registry = {} + self._prefix_registry = {} + self.update(registry or {}) + + def __contains__(self, key): + try: + _ = self[key] + return True + except KeyError: + return False + + def __getitem__(self, key): + if key in self._match_registry: + return self._match_registry[key] + if isinstance(key, basestring): + # Reverse-sort the keys to find the longest matching prefix. + for prefix in sorted(self._prefix_registry, reverse=True): + if key.startswith(prefix): + return self._prefix_registry[prefix] + raise KeyError('Key {} not found in {}'.format(key, type(self))) + + def __setitem__(self, key, value): + if key.endswith('.'): + self._prefix_registry[key] = value + else: + self._match_registry[key] = value + + def __delitem__(self, key): + if key.endswith('.'): + del self._prefix_registry[key] + else: + del self._match_registry[key] + + def get(self, key, default=None): + """ + Return `self[key]` if it exists, otherwise, return `None` or `default` + if it is specified. + """ + try: + self[key] + except KeyError: + return default + + def update(self, dict_): + """ + Update the mapping with the values in the supplied `dict`. + """ + for key, value in dict_: + self[key] = value + + def keys(self): + """ + Return the keys of the mapping, including both exact matches and + prefix matches. + """ + return self._match_registry.keys() + self._prefix_registry.keys() + + +class EventTransformerRegistry(object): + """ + Registry to track which EventTransformers handle which events. The + EventTransformer must define a `match_key` attribute which contains the + name or prefix of the event names it tracks. Any `match_key` that ends + with a `.` will match all events that share its prefix. A transformer name + without a trailing dot only processes exact matches. + """ + mapping = DottedPathMapping() + + @classmethod + def register(cls, transformer): + """ + Decorator to register an EventTransformer. It must have a `match_key` + class attribute defined. + """ + cls.mapping[transformer.match_key] = transformer + return transformer + + @classmethod + def create_transformer(cls, event): + """ + Create an EventTransformer of the given event. + + If no transformer is registered to handle the event, this raises a + KeyError. + """ + name = event.get(u'name') + return cls.mapping[name](event) + + +class EventTransformer(dict): + """ + Creates a transformer to modify analytics events based on event type. + + To use the transformer, instantiate it using the + `EventTransformer.create_transformer()` classmethod with the event + dictionary as the sole argument, and then call `transformer.transform()` on + the created object to modify the event to the format required for output. + + Custom transformers will want to define some or all of the following values + + Attributes: + + match_key: + This is the name of the event you want to transform. If the name + ends with a `'.'`, it will be treated as a *prefix transformer*. + All other names denote *exact transformers*. + + A *prefix transformer* will handle any event whose name begins with + the name of the prefix transformer. Only the most specific match + will be used, so if a transformer exists with a name of + `'edx.ui.lms.'` and another transformer has the name + `'edx.ui.lms.sequence.'` then an event called + `'edx.ui.lms.sequence.tab_selected'` will be handled by the + `'edx.ui.lms.sequence.'` transformer. + + An *exact transformer* will only handle events whose name matches + name of the transformer exactly. + + Exact transformers always take precedence over prefix transformers. + + Transformers without a name will not be added to the registry, and + cannot be accessed via the `EventTransformer.create_transformer()` + classmethod. + + is_legacy_event: + If an event is a legacy event, it needs to set event_type to the + legacy name for the event, and may need to set certain event fields + to maintain backward compatiblity. If an event needs to provide + legacy support in some contexts, `is_legacy_event` can be defined + as a property to add dynamic behavior. + + Default: False + + legacy_event_type: + If the event is or can be a legacy event, it should define + the legacy value for the event_type field here. + + Processing methods. Override these to provide the behavior needed for your + particular EventTransformer: + + self.process_legacy_fields(): + This method should modify the event payload in any way necessary to + support legacy event types. It will only be run if + `is_legacy_event` returns a True value. + + self.process_event() + This method modifies the event payload unconditionally. It will + always be run. + """ + def __init__(self, *args, **kwargs): + super(EventTransformer, self).__init__(*args, **kwargs) + self.load_payload() + + # Properties to be overridden + + is_legacy_event = False + + @property + def legacy_event_type(self): + """ + Override this as an attribute or property to provide the value for + the event's `event_type`, if it does not match the event's `name`. + """ + raise NotImplementedError + + # Convenience properties + + @property + def name(self): + """ + Returns the event's name. + """ + return self[u'name'] + + @property + def context(self): + """ + Returns the event's context dict. + """ + return self.get(u'context', {}) + + # Transform methods + + def load_payload(self): + """ + Create a data version of self[u'event'] at self.event + """ + if u'event' in self: + if isinstance(self[u'event'], basestring): + self.event = json.loads(self[u'event']) + else: + self.event = self[u'event'] + + def dump_payload(self): + """ + Write self.event back to self[u'event']. + + Keep the same format we were originally given. + """ + if isinstance(self.get(u'event'), basestring): + self[u'event'] = json.dumps(self.event) + else: + self[u'event'] = self.event + + def transform(self): + """ + Transform the event with legacy fields and other necessary + modifications. + """ + if self.is_legacy_event: + self._set_legacy_event_type() + self.process_legacy_fields() + self.process_event() + self.dump_payload() + + def _set_legacy_event_type(self): + """ + Update the event's `event_type` to the value specified by + `self.legacy_event_type`. + """ + self['event_type'] = self.legacy_event_type + + def process_legacy_fields(self): + """ + Override this method to specify how to update event fields to maintain + compatibility with legacy events. + """ + pass + + def process_event(self): + """ + Override this method to make unconditional modifications to event + fields. + """ + pass + + +@EventTransformerRegistry.register +class SequenceTabSelectedEventTransformer(EventTransformer): + """ + Transformer to maintain backward compatiblity with seq_goto events. + """ + + match_key = u'edx.ui.lms.sequence.tab_selected' + is_legacy_event = True + legacy_event_type = u'seq_goto' + + def process_legacy_fields(self): + self.event[u'old'] = self.event[u'current_tab'] + self.event[u'new'] = self.event[u'target_tab'] + + +class _BaseLinearSequenceEventTransformer(EventTransformer): + """ + Common functionality for transforming + `edx.ui.lms.sequence.{next,previous}_selected` events. + """ + + offset = None + + @property + def is_legacy_event(self): + """ + Linear sequence events are legacy events if the origin and target lie + within the same sequence. + """ + return not self.crosses_boundary() + + def process_legacy_fields(self): + """ + Set legacy payload fields: + old: equal to the new current_tab field + new: the tab to which the user is navigating + """ + self.event[u'old'] = self.event[u'current_tab'] + self.event[u'new'] = self.event[u'current_tab'] + self.offset + + def crosses_boundary(self): + """ + Returns true if the navigation takes the focus out of the current + sequence. + """ + raise NotImplementedError + + +@EventTransformerRegistry.register +class NextSelectedEventTransformer(_BaseLinearSequenceEventTransformer): + """ + Transformer to maintain backward compatiblity with seq_next events. + """ + + match_key = u'edx.ui.lms.sequence.next_selected' + offset = 1 + legacy_event_type = u'seq_next' + + def crosses_boundary(self): + """ + Returns true if the navigation moves the focus to the next sequence. + """ + return self.event[u'current_tab'] == self.event[u'tab_count'] + + +@EventTransformerRegistry.register +class PreviousSelectedEventTransformer(_BaseLinearSequenceEventTransformer): + """ + Transformer to maintain backward compatiblity with seq_prev events. + """ + + match_key = u'edx.ui.lms.sequence.previous_selected' + offset = -1 + legacy_event_type = u'seq_prev' + + def crosses_boundary(self): + """ + Returns true if the navigation moves the focus to the previous + sequence. + """ + return self.event[u'current_tab'] == 1 + + +@EventTransformerRegistry.register +class VideoEventTransformer(EventTransformer): + """ + Converts new format video events into the legacy video event format. + + Mobile devices cannot actually emit events that exactly match their + counterparts emitted by the LMS javascript video player. Instead of + attempting to get them to do that, we instead insert a transformer here + that converts the events they *can* easily emit and converts them into the + legacy format. + """ + match_key = u'edx.video.' + + name_to_event_type_map = { + u'edx.video.played': u'play_video', + u'edx.video.paused': u'pause_video', + u'edx.video.stopped': u'stop_video', + u'edx.video.loaded': u'load_video', + u'edx.video.position.changed': u'seek_video', + u'edx.video.seeked': u'seek_video', + u'edx.video.transcript.shown': u'show_transcript', + u'edx.video.transcript.hidden': u'hide_transcript', + } + + is_legacy_event = True + + @property + def legacy_event_type(self): + """ + Return the legacy event_type of the current event + """ + return self.name_to_event_type_map[self.name] + + def transform(self): + """ + Transform the event with necessary modifications if it is one of the + expected types of events. + """ + if self.name in self.name_to_event_type_map: + super(VideoEventTransformer, self).transform() + + def process_event(self): + """ + Modify event fields. + """ + + # Convert edx.video.seeked to edx.video.position.changed because edx.video.seeked was not intended to actually + # ever be emitted. + if self.name == "edx.video.seeked": + self['name'] = "edx.video.position.changed" + + if not self.event: + return + + self.set_id_to_usage_key() + self.capcase_current_time() + + self.convert_seek_type() + self.disambiguate_skip_and_seek() + self.set_page_to_browser_url() + self.handle_ios_seek_bug() + + def set_id_to_usage_key(self): + """ + Validate that the module_id is a valid usage key, and set the id field + accordingly. + """ + if 'module_id' in self.event: + module_id = self.event['module_id'] + try: + usage_key = UsageKey.from_string(module_id) + except InvalidKeyError: + log.warning('Unable to parse module_id "%s"', module_id, exc_info=True) + else: + self.event['id'] = usage_key.html_id() + + del self.event['module_id'] + + def capcase_current_time(self): + """ + Convert the current_time field to currentTime. + """ + if 'current_time' in self.event: + self.event['currentTime'] = self.event.pop('current_time') + + def convert_seek_type(self): + """ + Converts seek_type to seek and converts skip|slide to + onSlideSeek|onSkipSeek. + """ + if 'seek_type' in self.event: + seek_type = self.event['seek_type'] + if seek_type == 'slide': + self.event['type'] = "onSlideSeek" + elif seek_type == 'skip': + self.event['type'] = "onSkipSeek" + del self.event['seek_type'] + + def disambiguate_skip_and_seek(self): + """ + For the Android build that isn't distinguishing between skip/seek. + """ + if 'requested_skip_interval' in self.event: + if abs(self.event['requested_skip_interval']) != 30: + if 'type' in self.event: + self.event['type'] = 'onSlideSeek' + + def set_page_to_browser_url(self): + """ + If `open_in_browser_url` is specified, set the page to the base of the + specified url. + """ + if 'open_in_browser_url' in self.context: + self['page'] = self.context.pop('open_in_browser_url').rpartition('/')[0] + + def handle_ios_seek_bug(self): + """ + Handle seek bug in iOS. + + iOS build 1.0.02 has a bug where it returns a +30 second skip when + it should be returning -30. + """ + if self._build_requests_plus_30_for_minus_30(): + if self._user_requested_plus_30_skip(): + self.event[u'requested_skip_interval'] = -30 + + def _build_requests_plus_30_for_minus_30(self): + """ + Returns True if this build contains the seek bug + """ + if u'application' in self.context: + if all(key in self.context[u'application'] for key in (u'version', u'name')): + app_version = self.context[u'application'][u'version'] + app_name = self.context[u'application'][u'name'] + return app_version == u'1.0.02' and app_name == u'edx.mobileapp.iOS' + return False + + def _user_requested_plus_30_skip(self): + """ + If the user requested a +30 second skip, return True. + """ + + if u'requested_skip_interval' in self.event and u'type' in self.event: + interval = self.event[u'requested_skip_interval'] + action = self.event[u'type'] + return interval == 30 and action == u'onSkipSeek' + else: + return False diff --git a/common/djangoapps/track/views/tests/test_segmentio.py b/common/djangoapps/track/views/tests/test_segmentio.py index 2ee9641e80f51e36a9314689f77436179e160c5f..dfb009282a9a1c182ab027cd2851fcde865e5e9c 100644 --- a/common/djangoapps/track/views/tests/test_segmentio.py +++ b/common/djangoapps/track/views/tests/test_segmentio.py @@ -21,12 +21,8 @@ ENDPOINT = '/segmentio/test/event' USER_ID = 10 MOBILE_SHIM_PROCESSOR = [ - { - 'ENGINE': 'track.shim.LegacyFieldMappingProcessor' - }, - { - 'ENGINE': 'track.shim.VideoEventProcessor' - } + {'ENGINE': 'track.shim.LegacyFieldMappingProcessor'}, + {'ENGINE': 'track.shim.PrefixedEventProcessor'}, ] @@ -411,19 +407,29 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase): assert_event_matches(expected_event, actual_event) @data( - # Verify positive slide case. Verify slide to onSlideSeek. Verify edx.video.seeked emitted from iOS v1.0.02 is changed to edx.video.position.changed. + # Verify positive slide case. Verify slide to onSlideSeek. Verify + # edx.video.seeked emitted from iOS v1.0.02 is changed to + # edx.video.position.changed. (1, 1, "seek_type", "slide", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify negative slide case. Verify slide to onSlideSeek. Verify edx.video.seeked to edx.video.position.changed. + # Verify negative slide case. Verify slide to onSlideSeek. Verify + # edx.video.seeked to edx.video.position.changed. (-2, -2, "seek_type", "slide", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify +30 is changed to -30 which is incorrectly emitted in iOS v1.0.02. Verify skip to onSkipSeek + # Verify +30 is changed to -30 which is incorrectly emitted in iOS + # v1.0.02. Verify skip to onSkipSeek (30, -30, "seek_type", "skip", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify the correct case of -30 is also handled as well. Verify skip to onSkipSeek + # Verify the correct case of -30 is also handled as well. Verify skip + # to onSkipSeek (-30, -30, "seek_type", "skip", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), - # Verify positive slide case where onSkipSeek is changed to onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is changed to edx.video.position.changed. + # Verify positive slide case where onSkipSeek is changed to + # onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is + # changed to edx.video.position.changed. (1, 1, "type", "onSkipSeek", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), - # Verify positive slide case where onSkipSeek is changed to onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is changed to edx.video.position.changed. + # Verify positive slide case where onSkipSeek is changed to + # onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is + # changed to edx.video.position.changed. (-2, -2, "type", "onSkipSeek", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), - # Verify positive skip case where onSkipSeek is not changed and does not become negative. + # Verify positive skip case where onSkipSeek is not changed and does + # not become negative. (30, 30, "type", "onSkipSeek", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), # Verify positive skip case where onSkipSeek is not changed. (-30, -30, "type", "onSkipSeek", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02') diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index 574b67d7911e32e534f5253cea803529c0ef34f0..fd976a4b84f588e5914c6373d99cd165abb9399c 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -132,14 +132,26 @@ class @Sequence @$('.sequence-nav-button').unbind('click') # previous button - first_tab = @position == 1 + is_first_tab = @position == 1 previous_button_class = '.sequence-nav-button.button-previous' - @updateButtonState(previous_button_class, @previous, 'Previous', first_tab, @prevUrl) + @updateButtonState( + previous_button_class, # bound element + @selectPrevious, # action + 'Previous', # label prefix + is_first_tab, # is boundary? + @prevUrl # boundary_url + ) # next button - last_tab = @position >= @contents.length # use inequality in case contents.length is 0 and position is 1. + is_last_tab = @position >= @contents.length # use inequality in case contents.length is 0 and position is 1. next_button_class = '.sequence-nav-button.button-next' - @updateButtonState(next_button_class, @next, 'Next', last_tab, @nextUrl) + @updateButtonState( + next_button_class, # bound element + @selectNext, # action + 'Next', # label prefix + is_last_tab, # is boundary? + @nextUrl # boundary_url + ) render: (new_position) -> if @position != new_position @@ -180,7 +192,7 @@ class @Sequence @el.find('.path').text(@el.find('.nav-item.active').data('path')) - @sr_container.focus(); + @sr_container.focus() goto: (event) => event.preventDefault() @@ -190,7 +202,17 @@ class @Sequence new_position = $(event.currentTarget).data('element') if (1 <= new_position) and (new_position <= @num_contents) - Logger.log "seq_goto", old: @position, new: new_position, id: @id + is_bottom_nav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0 + if is_bottom_nav + widget_placement = 'bottom' + else + widget_placement = 'top' + Logger.log "edx.ui.lms.sequence.tab_selected", # Formerly known as seq_goto + current_tab: @position + target_tab: new_position + tab_count: @num_contents + id: @id + widget_placement: widget_placement # On Sequence change, destroy any existing polling thread # for queued submissions, see ../capa/display.coffee @@ -204,32 +226,43 @@ class @Sequence alert_text = interpolate(alert_template, {tab_name: new_position}, true) alert alert_text - next: (event) => @_change_sequential 'seq_next', event - previous: (event) => @_change_sequential 'seq_prev', event + selectNext: (event) => @_change_sequential 'next', event - # `direction` can be 'seq_prev' or 'seq_next' + selectPrevious: (event) => @_change_sequential 'previous', event + + # `direction` can be 'previous' or 'next' _change_sequential: (direction, event) => # silently abort if direction is invalid. - return unless direction in ['seq_prev', 'seq_next'] + return unless direction in ['previous', 'next'] event.preventDefault() - offset = - seq_next: 1 - seq_prev: -1 - new_position = @position + offset[direction] - Logger.log direction, - old: @position - new: new_position + + analytics_event_name = "edx.ui.lms.sequence.#{direction}_selected" + is_bottom_nav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0 + + if is_bottom_nav + widget_placement = 'bottom' + else + widget_placement = 'top' + + Logger.log analytics_event_name, # Formerly known as seq_next and seq_prev id: @id + current_tab: @position + tab_count: @num_contents + widget_placement: widget_placement - if (direction == "seq_next") and (@position == @contents.length) + if (direction == 'next') and (@position == @contents.length) window.location.href = @nextUrl - else if (direction == "seq_prev") and (@position == 1) + else if (direction == 'previous') and (@position == 1) window.location.href = @prevUrl else # If the bottom nav is used, scroll to the top of the page on change. - if $(event.target).closest('nav[class="sequence-bottom"]').length > 0 + if is_bottom_nav $.scrollTo 0, 150 + offset = + next: 1 + previous: -1 + new_position = @position + offset[direction] @render new_position link_for: (position) -> diff --git a/common/test/acceptance/tests/lms/test_lms_courseware.py b/common/test/acceptance/tests/lms/test_lms_courseware.py index 4f551f378f7456f77887ec52c33f3671d7814e98..91d23f7d28033c0193167f7a6f8d70851623c080 100644 --- a/common/test/acceptance/tests/lms/test_lms_courseware.py +++ b/common/test/acceptance/tests/lms/test_lms_courseware.py @@ -2,10 +2,12 @@ """ End-to-end tests for the LMS. """ + +import json from nose.plugins.attrib import attr -from ..helpers import UniqueCourseTest from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory +from ..helpers import UniqueCourseTest, EventsTestMixin from ...pages.studio.auto_auth import AutoAuthPage from ...pages.lms.create_mode import ModeCreationPage from ...pages.studio.overview import CourseOutlinePage @@ -66,7 +68,7 @@ class CoursewareTest(UniqueCourseTest): Open problem page with assertion. """ self.courseware_page.visit() - self.problem_page = ProblemPage(self.browser) + self.problem_page = ProblemPage(self.browser) # pylint: disable=attribute-defined-outside-init self.assertEqual(self.problem_page.problem_name, 'Test Problem 1') def _create_breadcrumb(self, index): @@ -394,7 +396,7 @@ class ProctoredExamTest(UniqueCourseTest): self.assertTrue(self.course_outline.time_allotted_field_visible()) -class CoursewareMultipleVerticalsTest(UniqueCourseTest): +class CoursewareMultipleVerticalsTest(UniqueCourseTest, EventsTestMixin): """ Test courseware with multiple verticals """ @@ -476,6 +478,87 @@ class CoursewareMultipleVerticalsTest(UniqueCourseTest): self.courseware_page.click_previous_button_on_bottom() self.assert_navigation_state('Test Section 1', 'Test Subsection 1,1', 2, next_enabled=True, prev_enabled=True) + # test UI events emitted by navigation + filter_sequence_ui_event = lambda event: event.get('name', '').startswith('edx.ui.lms.sequence.') + + sequence_ui_events = self.wait_for_events(event_filter=filter_sequence_ui_event, timeout=2) + legacy_events = [ev for ev in sequence_ui_events if ev['event_type'] in {'seq_next', 'seq_prev', 'seq_goto'}] + nonlegacy_events = [ev for ev in sequence_ui_events if ev not in legacy_events] + + self.assertTrue(all('old' in json.loads(ev['event']) for ev in legacy_events)) + self.assertTrue(all('new' in json.loads(ev['event']) for ev in legacy_events)) + self.assertFalse(any('old' in json.loads(ev['event']) for ev in nonlegacy_events)) + self.assertFalse(any('new' in json.loads(ev['event']) for ev in nonlegacy_events)) + + self.assert_events_match( + [ + { + 'event_type': 'seq_next', + 'event': { + 'old': 1, + 'new': 2, + 'current_tab': 1, + 'tab_count': 4, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'seq_goto', + 'event': { + 'old': 2, + 'new': 4, + 'current_tab': 2, + 'target_tab': 4, + 'tab_count': 4, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.next_selected', + 'event': { + 'current_tab': 4, + 'tab_count': 4, + 'widget_placement': 'bottom', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.next_selected', + 'event': { + 'current_tab': 1, + 'tab_count': 1, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.previous_selected', + 'event': { + 'current_tab': 1, + 'tab_count': 1, + 'widget_placement': 'top', + } + }, + { + 'event_type': 'edx.ui.lms.sequence.previous_selected', + 'event': { + 'current_tab': 1, + 'tab_count': 1, + 'widget_placement': 'bottom', + } + }, + { + 'event_type': 'seq_prev', + 'event': { + 'old': 4, + 'new': 3, + 'current_tab': 4, + 'tab_count': 4, + 'widget_placement': 'bottom', + } + }, + ], + sequence_ui_events + ) + def assert_navigation_state( self, section_title, subsection_title, subsection_position, next_enabled, prev_enabled ): diff --git a/lms/envs/common.py b/lms/envs/common.py index 8da0003274e6b88cc8fb8ca4410321dc4f533803..e6a1d7889f06a975de9a700afe06a82ad3f78286 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -625,7 +625,7 @@ EVENT_TRACKING_BACKENDS = { }, 'processors': [ {'ENGINE': 'track.shim.LegacyFieldMappingProcessor'}, - {'ENGINE': 'track.shim.VideoEventProcessor'} + {'ENGINE': 'track.shim.PrefixedEventProcessor'} ] } },