Commit 4a8b0c5e by Gabe Mulley

Make user_track use eventtracking

parent f66c118d
......@@ -280,7 +280,9 @@ BROKER_URL = "{0}://{1}:{2}@{3}/{4}".format(CELERY_BROKER_TRANSPORT,
# Event tracking
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS.update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whitelist'].extend(
AUTH_TOKENS.get("EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST", []))
SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {})
VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', [])
......
......@@ -776,19 +776,42 @@ TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
EVENT_TRACKING_ENABLED = True
EVENT_TRACKING_BACKENDS = {
'logger': {
'ENGINE': 'eventtracking.backends.logger.LoggerBackend',
'tracking_logs': {
'ENGINE': 'eventtracking.backends.routing.RoutingBackend',
'OPTIONS': {
'backends': {
'logger': {
'ENGINE': 'eventtracking.backends.logger.LoggerBackend',
'OPTIONS': {
'name': 'tracking',
'max_event_size': TRACK_MAX_EVENT,
}
}
},
'processors': [
{'ENGINE': 'track.shim.LegacyFieldMappingProcessor'},
{'ENGINE': 'track.shim.VideoEventProcessor'}
]
}
},
'segmentio': {
'ENGINE': 'eventtracking.backends.routing.RoutingBackend',
'OPTIONS': {
'name': 'tracking',
'max_event_size': TRACK_MAX_EVENT,
'backends': {
'segment': {'ENGINE': 'eventtracking.backends.segment.SegmentBackend'}
},
'processors': [
{
'ENGINE': 'eventtracking.processors.whitelist.NameWhitelistProcessor',
'OPTIONS': {
'whitelist': []
}
}
]
}
}
}
EVENT_TRACKING_PROCESSORS = [
{
'ENGINE': 'track.shim.LegacyFieldMappingProcessor'
}
]
EVENT_TRACKING_PROCESSORS = []
#### PASSWORD POLICY SETTINGS #####
......
......@@ -31,7 +31,10 @@ class LegacyFieldMappingProcessor(object):
remove_shim_context(event)
if 'data' in event:
event['event'] = event['data']
if context.get('event_source', '') == 'browser' and isinstance(event['data'], dict):
event['event'] = json.dumps(event['data'])
else:
event['event'] = event['data']
del event['data']
else:
event['event'] = {}
......@@ -103,7 +106,8 @@ class VideoEventProcessor(object):
if name not in NAME_TO_EVENT_TYPE_MAP:
return
# Convert edx.video.seeked to edx.video.positiion.changed
# 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"
......
......@@ -31,22 +31,6 @@ class InMemoryBackend(object):
self.events.append(event)
def unicode_flatten(tree):
"""
Test cases have funny issues where some strings are unicode, and
some are not. This does not cause test failures, but causes test
output diffs to show many more difference than actually occur in the
data. This will convert everything to a common form.
"""
if isinstance(tree, basestring):
return unicode(tree)
elif isinstance(tree, list):
return map(unicode_flatten, list)
elif isinstance(tree, dict):
return dict([(unicode_flatten(key), unicode_flatten(value)) for key, value in tree.iteritems()])
return tree
@freeze_time(FROZEN_TIME)
@override_settings(
EVENT_TRACKING_BACKENDS=IN_MEMORY_BACKEND_CONFIG
......@@ -64,6 +48,14 @@ class EventTrackingTestCase(TestCase):
def setUp(self):
super(EventTrackingTestCase, self).setUp()
self.recreate_tracker()
def recreate_tracker(self):
"""
Re-initialize the tracking system using updated django settings.
Use this if you make use of the @override_settings decorator to customize the tracker configuration.
"""
self.tracker = DjangoTracker()
tracker.register_tracker(self.tracker)
......@@ -83,7 +75,3 @@ class EventTrackingTestCase(TestCase):
def assert_events_emitted(self):
"""Ensure at least one event has been emitted at this point in the test."""
self.assertGreaterEqual(len(self.backend.events), 1)
def assertEqualUnicode(self, tree_a, tree_b):
"""Like assertEqual, but give nicer errors for unicode vs. non-unicode"""
self.assertEqual(unicode_flatten(tree_a), unicode_flatten(tree_b))
......@@ -3,6 +3,7 @@
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
......@@ -13,12 +14,12 @@ LEGACY_SHIM_PROCESSOR = [
]
@override_settings(
EVENT_TRACKING_PROCESSORS=LEGACY_SHIM_PROCESSOR,
)
class LegacyFieldMappingProcessorTestCase(EventTrackingTestCase):
"""Ensure emitted events contain the fields legacy processors expect to find."""
@override_settings(
EVENT_TRACKING_PROCESSORS=LEGACY_SHIM_PROCESSOR,
)
def test_event_field_mapping(self):
data = {sentinel.key: sentinel.value}
......@@ -62,11 +63,8 @@ class LegacyFieldMappingProcessorTestCase(EventTrackingTestCase):
'page': None,
'session': sentinel.session,
}
self.assertEqualUnicode(expected_event, emitted_event)
assert_events_equal(expected_event, emitted_event)
@override_settings(
EVENT_TRACKING_PROCESSORS=LEGACY_SHIM_PROCESSOR,
)
def test_missing_fields(self):
self.tracker.emit(sentinel.name)
......@@ -88,4 +86,4 @@ class LegacyFieldMappingProcessorTestCase(EventTrackingTestCase):
'page': None,
'session': '',
}
self.assertEqualUnicode(expected_event, emitted_event)
assert_events_equal(expected_event, emitted_event)
import datetime
import json
import pytz
......@@ -45,36 +46,28 @@ def user_track(request):
GET or POST call should provide "event_type", "event", and "page" arguments.
"""
try: # TODO: Do the same for many of the optional META parameters
try:
username = request.user.username
except:
username = "anonymous"
name = _get_request_value(request, 'event_type')
data = _get_request_value(request, 'event', {})
page = _get_request_value(request, 'page')
with eventtracker.get_tracker().context('edx.course.browser', contexts.course_context_from_url(page)):
context = eventtracker.get_tracker().resolve_context()
event = {
"username": username,
"session": context.get('session', ''),
"ip": _get_request_header(request, 'REMOTE_ADDR'),
"referer": _get_request_header(request, 'HTTP_REFERER'),
"accept_language": _get_request_header(request, 'HTTP_ACCEPT_LANGUAGE'),
"event_source": "browser",
"event_type": _get_request_value(request, 'event_type'),
"event": _get_request_value(request, 'event'),
"agent": _get_request_header(request, 'HTTP_USER_AGENT'),
"page": page,
"time": datetime.datetime.utcnow(),
"host": _get_request_header(request, 'SERVER_NAME'),
"context": context,
}
if isinstance(data, basestring) and len(data) > 0:
try:
data = json.loads(data)
except ValueError:
pass
# Some duplicated fields are passed into event-tracking via the context by track.middleware.
# Remove them from the event here since they are captured elsewhere.
shim.remove_shim_context(event)
context_override = contexts.course_context_from_url(page)
context_override['username'] = username
context_override['event_source'] = 'browser'
context_override['page'] = page
log_event(event)
with eventtracker.get_tracker().context('edx.course.browser', context_override):
eventtracker.emit(name=name, data=data)
return HttpResponse('success')
......
......@@ -10,6 +10,7 @@ from django.contrib.auth.models import User
from django.test.client import RequestFactory
from django.test.utils import override_settings
from openedx.core.lib.tests.assertions.events import assert_event_matches
from track.middleware import TrackMiddleware
from track.tests import EventTrackingTestCase
from track.views import segmentio
......@@ -227,7 +228,7 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase):
finally:
middleware.process_response(request, None)
self.assertEqualUnicode(self.get_event(), expected_event)
assert_event_matches(expected_event, self.get_event())
def test_invalid_course_id(self):
request = self.create_request(
......@@ -331,6 +332,9 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase):
'code': 'mobile'
}
if name == 'edx.video.loaded':
# We use the same expected payload for all of these types of events, but the load video event is the only
# one that is not actually expected to contain a "current time" field. So we remove it from the expected
# event here.
del input_payload['current_time']
request = self.create_request(
......@@ -355,7 +359,7 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase):
response = segmentio.segmentio_event(request)
self.assertEquals(response.status_code, 200)
expected_event_without_payload = {
expected_event = {
'accept_language': '',
'referer': '',
'username': str(sentinel.username),
......@@ -389,22 +393,22 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase):
},
'received_at': datetime.strptime("2014-08-27T16:33:39.100Z", "%Y-%m-%dT%H:%M:%S.%fZ"),
},
}
expected_payload = {
'currentTime': 132.134456,
'id': 'i4x-foo-bar-baz-some_module',
'code': 'mobile'
'event': {
'currentTime': 132.134456,
'id': 'i4x-foo-bar-baz-some_module',
'code': 'mobile'
}
}
if name == 'edx.video.loaded':
del expected_payload['currentTime']
# We use the same expected payload for all of these types of events, but the load video event is the
# only one that is not actually expected to contain a "current time" field. So we remove it from the
# expected event here.
del expected_event['event']['currentTime']
finally:
middleware.process_response(request, None)
actual_event = dict(self.get_event())
payload = json.loads(actual_event.pop('event'))
self.assertEqualUnicode(actual_event, expected_event_without_payload)
self.assertEqualUnicode(payload, expected_payload)
actual_event = self.get_event()
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.
......@@ -479,7 +483,7 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase):
response = segmentio.segmentio_event(request)
self.assertEquals(response.status_code, 200)
expected_event_without_payload = {
expected_event = {
'accept_language': '',
'referer': '',
'username': str(sentinel.username),
......@@ -513,19 +517,17 @@ class SegmentIOTrackingTestCase(EventTrackingTestCase):
},
'received_at': datetime.strptime("2014-08-27T16:33:39.100Z", "%Y-%m-%dT%H:%M:%S.%fZ"),
},
}
expected_payload = {
"code": "mobile",
"new_time": 89.699177437,
"old_time": 119.699177437,
"type": expected_seek_type,
"requested_skip_interval": expected_skip_interval,
'id': 'i4x-foo-bar-baz-some_module',
'event': {
"code": "mobile",
"new_time": 89.699177437,
"old_time": 119.699177437,
"type": expected_seek_type,
"requested_skip_interval": expected_skip_interval,
'id': 'i4x-foo-bar-baz-some_module',
}
}
finally:
middleware.process_response(request, None)
actual_event = dict(self.get_event())
payload = json.loads(actual_event.pop('event'))
self.assertEqualUnicode(actual_event, expected_event_without_payload)
self.assertEqualUnicode(payload, expected_payload)
actual_event = self.get_event()
assert_event_matches(expected_event, actual_event)
......@@ -28,6 +28,13 @@ class ContentFactory(factory.Factory):
closed = False
votes = {"up_count": 0}
@classmethod
def _adjust_kwargs(cls, **kwargs):
# The discussion code assumes that user_id is a string. This ensures that it always will be.
if 'user_id' in kwargs:
kwargs['user_id'] = str(kwargs['user_id'])
return kwargs
class Thread(ContentFactory):
thread_type = "discussion"
......
......@@ -4,7 +4,7 @@ Auto-auth page (used to automatically log in during testing).
import re
import urllib
from bok_choy.page_object import PageObject
from bok_choy.page_object import PageObject, unguarded
from . import AUTH_BASE_URL
......@@ -15,6 +15,8 @@ class AutoAuthPage(PageObject):
this url will create a user and log them in.
"""
CONTENT_REGEX = r'.+? user (?P<username>\S+) \((?P<email>.+?)\) with password \S+ and user_id (?P<user_id>\d+)$'
def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None, roles=None):
"""
Auto-auth is an end-point for HTTP GET requests.
......@@ -30,6 +32,9 @@ class AutoAuthPage(PageObject):
"""
super(AutoAuthPage, self).__init__(browser)
# This will eventually hold the details about the user account
self._user_info = None
# Create query string parameters if provided
self._params = {}
......@@ -65,14 +70,31 @@ class AutoAuthPage(PageObject):
return url
def is_browser_on_page(self):
return True if self.get_user_info() is not None else False
@unguarded
def get_user_info(self):
"""Parse the auto auth page body to extract relevant details about the user that was logged in."""
message = self.q(css='BODY').text[0]
match = re.search(r'Logged in user ([^$]+) with password ([^$]+) and user_id ([^$]+)$', message)
return True if match else False
match = re.match(self.CONTENT_REGEX, message)
if not match:
return None
else:
user_info = match.groupdict()
user_info['user_id'] = int(user_info['user_id'])
return user_info
@property
def user_info(self):
"""A dictionary containing details about the user account."""
if self._user_info is None:
user_info = self.get_user_info()
if user_info is not None:
self._user_info = self.get_user_info()
return self._user_info
def get_user_id(self):
"""
Finds and returns the user_id
"""
message = self.q(css='BODY').text[0].strip()
match = re.search(r' user_id ([^$]+)$', message)
return match.groups()[0] if match else None
return self.user_info['user_id']
......@@ -2,6 +2,8 @@
"""
End-to-end tests for Student's Profile Page.
"""
from contextlib import contextmanager
from datetime import datetime
from bok_choy.web_app_test import WebAppTest
from nose.plugins.attrib import attr
......@@ -108,43 +110,42 @@ class LearnerProfileTestMixin(EventsTestMixin):
"""
Verifies that the correct view event was captured for the profile page.
"""
self.verify_events_of_type(
requesting_username,
u"edx.user.settings.viewed",
[{
u"user_id": int(profile_user_id),
u"page": u"profile",
u"visibility": unicode(visibility),
}]
)
def assert_event_emitted_num_times(self, profile_user_id, setting, num_times):
"""
Verify a particular user settings change event was emitted a certain
number of times.
"""
# pylint disable=no-member
super(LearnerProfileTestMixin, self).assert_event_emitted_num_times(
self.USER_SETTINGS_CHANGED_EVENT_NAME, self.start_time, profile_user_id, num_times, setting=setting
actual_events = self.wait_for_events(
event_filter={'event_type': 'edx.user.settings.viewed'}, number_of_matches=1)
self.assert_events_match(
[
{
'username': requesting_username,
'event': {
'user_id': int(profile_user_id),
'page': 'profile',
'visibility': unicode(visibility)
}
}
],
actual_events
)
def verify_user_preference_changed_event(self, username, user_id, setting, old_value=None, new_value=None):
"""
Verifies that the correct user preference changed event was recorded.
"""
self.verify_events_of_type(
username,
self.USER_SETTINGS_CHANGED_EVENT_NAME,
[{
u"user_id": long(user_id),
u"table": u"user_api_userpreference",
u"setting": unicode(setting),
u"old": old_value,
u"new": new_value,
u"truncated": [],
}],
expected_referers=["/u/{username}".format(username=username)],
)
@contextmanager
def verify_pref_change_event_during(self, username, user_id, setting, **kwargs):
"""Assert that a single setting changed event is emitted for the user_api_userpreference table."""
expected_event = {
'username': username,
'event': {
'setting': setting,
'user_id': int(user_id),
'table': 'user_api_userpreference',
'truncated': []
}
}
expected_event['event'].update(kwargs)
event_filter = {
'event_type': self.USER_SETTINGS_CHANGED_EVENT_NAME,
}
with self.assert_events_match_during(event_filter=event_filter, expected_events=[expected_event]):
yield
@attr('shard_4')
......@@ -195,12 +196,10 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PRIVATE)
profile_page.privacy = self.PRIVACY_PUBLIC
self.verify_user_preference_changed_event(
username, user_id, "account_privacy",
old_value=self.PRIVACY_PRIVATE, # Note: default value was public, so we first change to private
new_value=self.PRIVACY_PUBLIC,
)
with self.verify_pref_change_event_during(
username, user_id, 'account_privacy', old=self.PRIVACY_PRIVATE, new=self.PRIVACY_PUBLIC
):
profile_page.privacy = self.PRIVACY_PUBLIC
# Reload the page and verify that the profile is now public
self.browser.refresh()
......@@ -220,12 +219,10 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
"""
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
profile_page.privacy = self.PRIVACY_PRIVATE
self.verify_user_preference_changed_event(
username, user_id, "account_privacy",
old_value=None, # Note: no old value as the default preference is public
new_value=self.PRIVACY_PRIVATE,
)
with self.verify_pref_change_event_during(
username, user_id, 'account_privacy', old=None, new=self.PRIVACY_PRIVATE
):
profile_page.privacy = self.PRIVACY_PRIVATE
# Reload the page and verify that the profile is now private
self.browser.refresh()
......@@ -487,13 +484,14 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='image.jpg')
with self.verify_pref_change_event_during(
username, user_id, 'profile_image_uploaded_at', table='auth_userprofile'
):
profile_page.upload_file(filename='image.jpg')
self.assertTrue(profile_page.image_upload_success)
profile_page.visit()
self.assertTrue(profile_page.image_upload_success)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 1)
def test_user_can_see_error_for_exceeding_max_file_size_limit(self):
"""
Scenario: Upload profile image does not work for > 1MB image file.
......@@ -516,7 +514,13 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 0)
self.assert_no_matching_events_were_emitted({
'event_type': self.USER_SETTINGS_CHANGED_EVENT_NAME,
'event': {
'setting': 'profile_image_uploaded_at',
'user_id': int(user_id),
}
})
def test_user_can_see_error_for_file_size_below_the_min_limit(self):
"""
......@@ -540,7 +544,13 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 0)
self.assert_no_matching_events_were_emitted({
'event_type': self.USER_SETTINGS_CHANGED_EVENT_NAME,
'event': {
'setting': 'profile_image_uploaded_at',
'user_id': int(user_id),
}
})
def test_user_can_see_error_for_wrong_file_type(self):
"""
......@@ -567,7 +577,13 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 0)
self.assert_no_matching_events_were_emitted({
'event_type': self.USER_SETTINGS_CHANGED_EVENT_NAME,
'event': {
'setting': 'profile_image_uploaded_at',
'user_id': int(user_id),
}
})
def test_user_can_remove_profile_image(self):
"""
......@@ -586,15 +602,21 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='image.jpg')
with self.verify_pref_change_event_during(
username, user_id, 'profile_image_uploaded_at', table='auth_userprofile'
):
profile_page.upload_file(filename='image.jpg')
self.assertTrue(profile_page.image_upload_success)
self.assertTrue(profile_page.remove_profile_image())
with self.verify_pref_change_event_during(
username, user_id, 'profile_image_uploaded_at', table='auth_userprofile'
):
self.assertTrue(profile_page.remove_profile_image())
self.assertTrue(profile_page.profile_has_default_image)
profile_page.visit()
self.assertTrue(profile_page.profile_has_default_image)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 2)
def test_user_cannot_remove_default_image(self):
"""
Scenario: Remove profile image does not works for default images.
......@@ -623,10 +645,17 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
username, user_id = self.log_in_as_unique_user()
profile_page = self.visit_profile_page(username, privacy=self.PRIVACY_PUBLIC)
self.assert_default_image_has_public_access(profile_page)
profile_page.upload_file(filename='image.jpg')
with self.verify_pref_change_event_during(
username, user_id, 'profile_image_uploaded_at', table='auth_userprofile'
):
profile_page.upload_file(filename='image.jpg')
self.assertTrue(profile_page.image_upload_success)
profile_page.upload_file(filename='image.jpg', wait_for_upload_button=False)
self.assert_event_emitted_num_times(user_id, 'profile_image_uploaded_at', 2)
with self.verify_pref_change_event_during(
username, user_id, 'profile_image_uploaded_at', table='auth_userprofile'
):
profile_page.upload_file(filename='image.jpg', wait_for_upload_button=False)
@attr('shard_4')
......
......@@ -276,22 +276,6 @@ class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest):
# Submit payment
self.fake_payment_page.submit_payment()
# Expect enrollment activated event
self.assert_event_emitted_num_times(
"edx.course.enrollment.activated",
self.start_time,
student_id,
1
)
# Expect that one mode_changed enrollment event fired as part of the upgrade
self.assert_event_emitted_num_times(
"edx.course.enrollment.mode_changed",
self.start_time,
student_id,
1
)
# Proceed to verification
self.payment_and_verification_flow.immediate_verification()
......@@ -329,14 +313,6 @@ class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest):
# Submit payment
self.fake_payment_page.submit_payment()
# Expect enrollment activated event
self.assert_event_emitted_num_times(
"edx.course.enrollment.activated",
self.start_time,
student_id,
1
)
# Navigate to the dashboard
self.dashboard_page.visit()
......@@ -364,24 +340,23 @@ class PayAndVerifyTest(EventsTestMixin, UniqueCourseTest):
# Proceed to the fake payment page
self.upgrade_page.proceed_to_payment()
# Submit payment
self.fake_payment_page.submit_payment()
# Expect that one mode_changed enrollment event fired as part of the upgrade
self.assert_event_emitted_num_times(
"edx.course.enrollment.mode_changed",
self.start_time,
student_id,
1
)
# Expect no enrollment activated event
self.assert_event_emitted_num_times(
"edx.course.enrollment.activated",
self.start_time,
student_id,
0
)
def only_enrollment_events(event):
"""Filter out all non-enrollment events."""
return event['event_type'].startswith('edx.course.enrollment.')
expected_events = [
{
'event_type': 'edx.course.enrollment.mode_changed',
'event': {
'user_id': int(student_id),
'mode': 'verified',
}
}
]
with self.assert_events_match_during(event_filter=only_enrollment_events, expected_events=expected_events):
# Submit payment
self.fake_payment_page.submit_payment()
# Navigate to the dashboard
self.dashboard_page.visit()
......
"""Ensure videos emit proper events"""
import datetime
import json
from ..helpers import EventsTestMixin
from .test_video_module import VideoBaseTest
from openedx.core.lib.tests.assertions.events import assert_event_matches, assert_events_equal
from opaque_keys.edx.keys import UsageKey, CourseKey
class VideoEventsTest(EventsTestMixin, VideoBaseTest):
""" Test video player event emission """
def test_video_control_events(self):
"""
Scenario: Video component is rendered in the LMS in Youtube mode without HTML5 sources
Given the course has a Video component in "Youtube" mode
And I play the video
And I watch 5 seconds of it
And I pause the video
Then a "load_video" event is emitted
And a "play_video" event is emitted
And a "pause_video" event is emitted
"""
def is_video_event(event):
"""Filter out anything other than the video events of interest"""
return event['event_type'] in ('load_video', 'play_video', 'pause_video')
captured_events = []
with self.capture_events(is_video_event, number_of_matches=3, captured_events=captured_events):
self.navigate_to_video()
self.video.click_player_button('play')
self.video.wait_for_position('0:05')
self.video.click_player_button('pause')
for idx, video_event in enumerate(captured_events):
self.assert_payload_contains_ids(video_event)
if idx == 0:
assert_event_matches({'event_type': 'load_video'}, video_event)
elif idx == 1:
assert_event_matches({'event_type': 'play_video'}, video_event)
self.assert_valid_control_event_at_time(video_event, 0)
elif idx == 2:
assert_event_matches({'event_type': 'pause_video'}, video_event)
self.assert_valid_control_event_at_time(video_event, self.video.seconds)
def assert_payload_contains_ids(self, video_event):
"""
Video events should all contain "id" and "code" attributes in their payload.
This function asserts that those fields are present and have correct values.
"""
video_descriptors = self.course_fixture.get_nested_xblocks(category='video')
video_desc = video_descriptors[0]
video_locator = UsageKey.from_string(video_desc.locator)
expected_event = {
'event': {
'id': video_locator.html_id(),
'code': '3_yD_cEKoCk'
}
}
self.assert_events_match([expected_event], [video_event])
def assert_valid_control_event_at_time(self, video_event, time_in_seconds):
"""
Video control events should contain valid ID fields and a valid "currentTime" field.
This function asserts that those fields are present and have correct values.
"""
current_time = json.loads(video_event['event'])['currentTime']
self.assertAlmostEqual(current_time, time_in_seconds, delta=1)
def test_strict_event_format(self):
"""
This test makes a very strong assertion about the fields present in events. The goal of it is to ensure that new
fields are not added to all events mistakenly. It should be the only existing test that is updated when new top
level fields are added to all events.
"""
captured_events = []
with self.capture_events(lambda e: e['event_type'] == 'load_video', captured_events=captured_events):
self.navigate_to_video()
load_video_event = captured_events[0]
# Validate the event payload
self.assert_payload_contains_ids(load_video_event)
# We cannot predict the value of these fields so we make weaker assertions about them
dynamic_string_fields = (
'accept_language',
'agent',
'host',
'ip',
'event',
'session'
)
for field in dynamic_string_fields:
self.assert_field_type(load_video_event, field, basestring)
self.assertIn(field, load_video_event, '{0} not found in the root of the event'.format(field))
del load_video_event[field]
# A weak assertion for the timestamp as well
self.assert_field_type(load_video_event, 'time', datetime.datetime)
del load_video_event['time']
# Note that all unpredictable fields have been deleted from the event at this point
course_key = CourseKey.from_string(self.course_id)
static_fields_pattern = {
'context': {
'course_id': unicode(course_key),
'org_id': course_key.org,
'path': '/event',
'user_id': self.user_info['user_id']
},
'event_source': 'browser',
'event_type': 'load_video',
'username': self.user_info['username'],
'page': self.browser.current_url,
'referer': self.browser.current_url,
'name': 'load_video',
}
assert_events_equal(static_fields_pattern, load_video_event)
def assert_field_type(self, event_dict, field, field_type):
"""Assert that a particular `field` in the `event_dict` has a particular type"""
self.assertIn(field, event_dict, '{0} not found in the root of the event'.format(field))
self.assertTrue(
isinstance(event_dict[field], field_type),
'Expected "{key}" to be a "{field_type}", but it has the value "{value}" of type "{t}"'.format(
key=field,
value=event_dict[field],
t=type(event_dict[field]),
field_type=field_type,
)
)
......@@ -48,6 +48,7 @@ class VideoBaseTest(UniqueCourseTest):
self.tab_nav = TabNavPage(self.browser)
self.course_nav = CourseNavPage(self.browser)
self.course_info_page = CourseInfoPage(self.browser, self.course_id)
self.auth_page = AutoAuthPage(self.browser, course_id=self.course_id)
self.course_fixture = CourseFixture(
self.course_info['org'], self.course_info['number'],
......@@ -58,6 +59,7 @@ class VideoBaseTest(UniqueCourseTest):
self.assets = []
self.verticals = None
self.youtube_configuration = {}
self.user_info = {}
# reset youtube stub server
self.addCleanup(YouTubeStubConfig.reset)
......@@ -125,8 +127,8 @@ class VideoBaseTest(UniqueCourseTest):
def _navigate_to_courseware_video(self):
""" Register for the course and navigate to the video unit """
AutoAuthPage(self.browser, course_id=self.course_id).visit()
self.auth_page.visit()
self.user_info = self.auth_page.user_info
self.course_info_page.visit()
self.tab_nav.go_to_tab('Courseware')
......
......@@ -80,7 +80,7 @@ TRACKING_BACKENDS.update({
}
})
EVENT_TRACKING_BACKENDS.update({
EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update({
'mongo': {
'ENGINE': 'eventtracking.backends.mongodb.MongoBackend',
'OPTIONS': {
......
......@@ -457,7 +457,9 @@ STUDENT_FILEUPLOAD_MAX_SIZE = ENV_TOKENS.get("STUDENT_FILEUPLOAD_MAX_SIZE", STUD
# Event tracking
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS.update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whitelist'].extend(
AUTH_TOKENS.get("EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST", []))
TRACKING_SEGMENTIO_WEBHOOK_SECRET = AUTH_TOKENS.get(
"TRACKING_SEGMENTIO_WEBHOOK_SECRET",
TRACKING_SEGMENTIO_WEBHOOK_SECRET
......
......@@ -587,22 +587,42 @@ TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat', r'^/segm
EVENT_TRACKING_ENABLED = True
EVENT_TRACKING_BACKENDS = {
'logger': {
'ENGINE': 'eventtracking.backends.logger.LoggerBackend',
'tracking_logs': {
'ENGINE': 'eventtracking.backends.routing.RoutingBackend',
'OPTIONS': {
'name': 'tracking',
'max_event_size': TRACK_MAX_EVENT,
'backends': {
'logger': {
'ENGINE': 'eventtracking.backends.logger.LoggerBackend',
'OPTIONS': {
'name': 'tracking',
'max_event_size': TRACK_MAX_EVENT,
}
}
},
'processors': [
{'ENGINE': 'track.shim.LegacyFieldMappingProcessor'},
{'ENGINE': 'track.shim.VideoEventProcessor'}
]
}
}
}
EVENT_TRACKING_PROCESSORS = [
{
'ENGINE': 'track.shim.LegacyFieldMappingProcessor'
},
{
'ENGINE': 'track.shim.VideoEventProcessor'
'segmentio': {
'ENGINE': 'eventtracking.backends.routing.RoutingBackend',
'OPTIONS': {
'backends': {
'segment': {'ENGINE': 'eventtracking.backends.segment.SegmentBackend'}
},
'processors': [
{
'ENGINE': 'eventtracking.processors.whitelist.NameWhitelistProcessor',
'OPTIONS': {
'whitelist': []
}
}
]
}
}
]
}
EVENT_TRACKING_PROCESSORS = []
# Backwards compatibility with ENABLE_SQL_TRACKING_LOGS feature flag.
# In the future, adding the backend to TRACKING_BACKENDS should be enough.
......
"""Assertions related to event validation"""
import json
import pprint
def assert_event_matches(expected, actual, tolerate=None):
"""
Compare two event dictionaries.
Fail if any discrepancies exist, and output the list of all discrepancies. The intent is to produce clearer
error messages than "{ some massive dict } != { some other massive dict }", instead enumerating the keys that
differ. Produces period separated "paths" to keys in the output, so "context.foo" refers to the following
structure:
{
'context': {
'foo': 'bar' # this key, value pair
}
}
The other key difference between this comparison and `assertEquals` is that it supports differing levels of
tolerance for discrepancies. We don't want to litter our tests full of exact match tests because then anytime we
add a field to all events, we have to go update every single test that has a hardcoded complete event structure in
it. Instead we support making partial assertions about structure and content of the event. So if I say my expected
event looks like this:
{
'event_type': 'foo.bar',
'event': {
'user_id': 10
}
}
This method will raise an assertion error if the actual event either does not contain the above fields in their
exact locations in the hierarchy, or if it does contain them but has different values for them. Note that it will
*not* necessarily raise an assertion error if the actual event contains other fields that are not listed in the
expected event. For example, the following event would not raise an assertion error:
{
'event_type': 'foo.bar',
'referer': 'http://example.com'
'event': {
'user_id': 10
}
}
Note that the extra "referer" field is not considered an error by default.
The `tolerate` parameter takes a set that allows you to specify varying degrees of tolerance for some common
eventing related issues. See the `EventMatchTolerates` class for more information about the various flags that are
supported here.
Example output if an error is found:
Unexpected differences found in structs:
* <path>: not found in actual
* <path>: <expected_value> != <actual_value> (expected != actual)
Expected:
{ <expected event }
Actual:
{ <actual event> }
"<path>" is a "." separated string indicating the key that differed. In the examples above "event.user_id" would
refer to the value of the "user_id" field contained within the dictionary referred to by the "event" field in the
root dictionary.
"""
differences = get_event_differences(expected, actual, tolerate=tolerate)
if len(differences) > 0:
debug_info = [
'',
'Expected:',
block_indent(expected),
'Actual:',
block_indent(actual),
'Tolerating:',
block_indent(EventMatchTolerates.default_if_not_defined(tolerate)),
]
differences = ['* ' + d for d in differences]
message_lines = differences + debug_info
raise AssertionError('Unexpected differences found in structs:\n\n' + '\n'.join(message_lines))
class EventMatchTolerates(object):
"""
Represents groups of flags that specify the level of tolerance for deviation between an expected event and an actual
event.
These are common event specific deviations that we don't want to handle with special case logic throughout our
tests.
"""
# Allow the "event" field to be a string, currently this is the case for all browser events.
STRING_PAYLOAD = 'string_payload'
# Allow unexpected fields to exist in the top level event dictionary.
ROOT_EXTRA_FIELDS = 'root_extra_fields'
# Allow unexpected fields to exist in the "context" dictionary. This is where new fields that appear in multiple
# events are most commonly added, so we frequently want to tolerate variation here.
CONTEXT_EXTRA_FIELDS = 'context_extra_fields'
# Allow unexpected fields to exist in the "event" dictionary. Typically in unit tests we don't want to allow this
# type of variance since there are typically only a small number of tests for a particular event type.
PAYLOAD_EXTRA_FIELDS = 'payload_extra_fields'
@classmethod
def default(cls):
"""A reasonable set of tolerated variations."""
# NOTE: "payload_extra_fields" is deliberately excluded from this list since we want to detect erroneously added
# fields in the payload by default.
return {
cls.STRING_PAYLOAD,
cls.ROOT_EXTRA_FIELDS,
cls.CONTEXT_EXTRA_FIELDS,
}
@classmethod
def lenient(cls):
"""Allow all known variations."""
return cls.default() | {
cls.PAYLOAD_EXTRA_FIELDS
}
@classmethod
def strict(cls):
"""Allow no variation at all."""
return frozenset()
@classmethod
def default_if_not_defined(cls, tolerates=None):
"""Use the provided tolerance or provide a default one if None was specified."""
if tolerates is None:
return cls.default()
else:
return tolerates
def assert_events_equal(expected, actual):
"""
Strict comparison of two events.
This asserts that every field in the real event exactly matches the expected event.
"""
assert_event_matches(expected, actual, tolerate=EventMatchTolerates.strict())
def get_event_differences(expected, actual, tolerate=None):
"""Given two events, gather a list of differences between them given some set of tolerated variances."""
tolerate = EventMatchTolerates.default_if_not_defined(tolerate)
# Some events store their payload in a JSON string instead of a dict. Comparing these strings can be problematic
# since the keys may be in different orders, so we parse the string here if we were expecting a dict.
if EventMatchTolerates.STRING_PAYLOAD in tolerate:
expected = parse_event_payload(expected)
actual = parse_event_payload(actual)
def should_strict_compare(path):
"""
We want to be able to vary the degree of strictness we apply depending on the testing context.
Some tests will want to assert that the entire event matches exactly, others will tolerate some variance in the
context or root fields, but not in the payload (for example).
"""
if path == [] and EventMatchTolerates.ROOT_EXTRA_FIELDS in tolerate:
return False
elif path == ['event'] and EventMatchTolerates.PAYLOAD_EXTRA_FIELDS in tolerate:
return False
elif path == ['context'] and EventMatchTolerates.CONTEXT_EXTRA_FIELDS in tolerate:
return False
else:
return True
return compare_structs(expected, actual, should_strict_compare=should_strict_compare)
def block_indent(text, spaces=4):
"""
Given a multi-line string, indent every line of it by the given number of spaces.
If `text` is not a string it is formatted using pprint.pformat.
"""
return '\n'.join([(' ' * spaces) + l for l in pprint.pformat(text).splitlines()])
def parse_event_payload(event):
"""
Given an event, parse the "event" field as a JSON string.
Note that this may simply return the same event unchanged, or return a new copy of the event with the payload
parsed. It will never modify the event in place.
"""
if 'event' in event and isinstance(event['event'], basestring):
event = event.copy()
try:
event['event'] = json.loads(event['event'])
except ValueError:
pass
return event
def compare_structs(expected, actual, should_strict_compare=None, path=None):
"""
Traverse two structures to ensure that the `actual` structure contains all of the elements within the `expected`
one.
Note that this performs a "deep" comparison, descending into dictionaries, lists and ohter collections to ensure
that the structure matches the expectation.
If a particular value is not recognized, it is simply compared using the "!=" operator.
"""
if path is None:
path = []
differences = []
if isinstance(expected, dict) and isinstance(actual, dict):
expected_keys = frozenset(expected.keys())
actual_keys = frozenset(actual.keys())
for key in expected_keys - actual_keys:
differences.append('{0}: not found in actual'.format(_path_to_string(path + [key])))
if should_strict_compare is not None and should_strict_compare(path):
for key in actual_keys - expected_keys:
differences.append('{0}: only defined in actual'.format(_path_to_string(path + [key])))
for key in expected_keys & actual_keys:
child_differences = compare_structs(expected[key], actual[key], should_strict_compare, path + [key])
differences.extend(child_differences)
elif expected != actual:
differences.append('{path}: {a} != {b} (expected != actual)'.format(
path=_path_to_string(path),
a=repr(expected),
b=repr(actual)
))
return differences
def is_matching_event(expected_event, actual_event, tolerate=None):
"""Return True iff the `actual_event` matches the `expected_event` given the tolerances."""
return len(get_event_differences(expected_event, actual_event, tolerate=tolerate)) == 0
def _path_to_string(path):
"""Convert a list of path elements into a single path string."""
return '.'.join(path)
......@@ -30,7 +30,7 @@ git+https://github.com/pmitros/pyfs.git@96e1922348bfe6d99201b9512a9ed946c87b7e0b
-e git+https://github.com/edx/XBlock.git@1934a2978cdd3e2414486c74b76e3040ff1fb138#egg=XBlock
-e git+https://github.com/edx/codejail.git@6b17c33a89bef0ac510926b1d7fea2748b73aadd#egg=codejail
-e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool
-e git+https://github.com/edx/event-tracking.git@0.1.0#egg=event-tracking
-e git+https://github.com/edx/event-tracking.git@0.2.0#egg=event-tracking
-e git+https://github.com/edx/bok-choy.git@82d2f4b72e807b10d112179c0a4abd810a001b82#egg=bok_choy
-e git+https://github.com/edx-solutions/django-splash.git@7579d052afcf474ece1239153cffe1c89935bc4f#egg=django-splash
-e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment