Merge pull request #13638 from edx/bderusha/utm-tracking
Add UTM tracking for registrations
This commit is contained in:
@@ -11,7 +11,8 @@ from xmodule.modulestore.django import modulestore
|
||||
from config_models.admin import ConfigurationModelAdmin
|
||||
from student.models import (
|
||||
UserProfile, UserTestGroup, CourseEnrollmentAllowed, DashboardConfiguration, CourseEnrollment, Registration,
|
||||
PendingNameChange, CourseAccessRole, LinkedInAddToProfileConfiguration, UserAttribute, LogoutViewConfiguration
|
||||
PendingNameChange, CourseAccessRole, LinkedInAddToProfileConfiguration, UserAttribute, LogoutViewConfiguration,
|
||||
RegistrationCookieConfiguration
|
||||
)
|
||||
from student.roles import REGISTERED_ACCESS_ROLES
|
||||
|
||||
@@ -184,6 +185,7 @@ admin.site.register(Registration)
|
||||
admin.site.register(PendingNameChange)
|
||||
admin.site.register(DashboardConfiguration, ConfigurationModelAdmin)
|
||||
admin.site.register(LogoutViewConfiguration, ConfigurationModelAdmin)
|
||||
admin.site.register(RegistrationCookieConfiguration, ConfigurationModelAdmin)
|
||||
|
||||
|
||||
# We must first un-register the User model since it may also be registered by the auth app.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('student', '0006_logoutviewconfiguration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RegistrationCookieConfiguration',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
|
||||
('utm_cookie_name', models.CharField(help_text='Name of the UTM cookie', max_length=255)),
|
||||
('affiliate_cookie_name', models.CharField(help_text='Name of the affiliate cookie', max_length=255)),
|
||||
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-change_date',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -2250,6 +2250,28 @@ class EnrollmentRefundConfiguration(ConfigurationModel):
|
||||
self.refund_window_microseconds = int(refund_window.total_seconds() * 1000000)
|
||||
|
||||
|
||||
class RegistrationCookieConfiguration(ConfigurationModel):
|
||||
"""
|
||||
Configuration for registration cookies.
|
||||
"""
|
||||
utm_cookie_name = models.CharField(
|
||||
max_length=255,
|
||||
help_text=_("Name of the UTM cookie")
|
||||
)
|
||||
|
||||
affiliate_cookie_name = models.CharField(
|
||||
max_length=255,
|
||||
help_text=_("Name of the affiliate cookie")
|
||||
)
|
||||
|
||||
def __unicode__(self):
|
||||
"""Unicode representation of this config. """
|
||||
return u"UTM: {utm_name}; AFFILIATE: {affiliate_name}".format(
|
||||
utm_name=self.utm_cookie_name,
|
||||
affiliate_name=self.affiliate_cookie_name
|
||||
)
|
||||
|
||||
|
||||
class UserAttribute(TimeStampedModel):
|
||||
"""
|
||||
Record additional metadata about a user, stored as key/value pairs of text.
|
||||
|
||||
@@ -12,6 +12,7 @@ from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.utils.importlib import import_module
|
||||
import mock
|
||||
import pytz
|
||||
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
@@ -19,7 +20,7 @@ from notification_prefs import NOTIFICATION_PREF_KEY
|
||||
from openedx.core.djangoapps.external_auth.models import ExternalAuthMap
|
||||
import student
|
||||
from student.models import UserAttribute
|
||||
from student.views import REGISTRATION_AFFILIATE_ID
|
||||
from student.views import REGISTRATION_AFFILIATE_ID, REGISTRATION_UTM_PARAMETERS, REGISTRATION_UTM_CREATED_AT
|
||||
|
||||
TEST_CS_URL = 'https://comments.service.test:123/'
|
||||
|
||||
@@ -280,7 +281,7 @@ class TestCreateAccount(TestCase):
|
||||
self.assertIsNone(preference)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_referral_attribution(self):
|
||||
def test_affiliate_referral_attribution(self):
|
||||
"""
|
||||
Verify that a referral attribution is recorded if an affiliate
|
||||
cookie is present upon a new user's registration.
|
||||
@@ -290,12 +291,74 @@ class TestCreateAccount(TestCase):
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
self.assertEqual(UserAttribute.get_user_attribute(user, REGISTRATION_AFFILIATE_ID), affiliate_id)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_utm_referral_attribution(self):
|
||||
"""
|
||||
Verify that a referral attribution is recorded if an affiliate
|
||||
cookie is present upon a new user's registration.
|
||||
"""
|
||||
utm_cookie_name = 'edx.test.utm'
|
||||
with mock.patch('student.models.RegistrationCookieConfiguration.current') as config:
|
||||
instance = config.return_value
|
||||
instance.utm_cookie_name = utm_cookie_name
|
||||
|
||||
timestamp = 1475521816879
|
||||
utm_cookie = {
|
||||
'utm_source': 'test-source',
|
||||
'utm_medium': 'test-medium',
|
||||
'utm_campaign': 'test-campaign',
|
||||
'utm_term': 'test-term',
|
||||
'utm_content': 'test-content',
|
||||
'created_at': timestamp
|
||||
}
|
||||
|
||||
created_at = datetime.fromtimestamp(timestamp / float(1000), tz=pytz.UTC)
|
||||
|
||||
self.client.cookies[utm_cookie_name] = json.dumps(utm_cookie)
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_source')),
|
||||
utm_cookie.get('utm_source')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_medium')),
|
||||
utm_cookie.get('utm_medium')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_campaign')),
|
||||
utm_cookie.get('utm_campaign')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_term')),
|
||||
utm_cookie.get('utm_term')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_content')),
|
||||
utm_cookie.get('utm_content')
|
||||
)
|
||||
self.assertEqual(
|
||||
UserAttribute.get_user_attribute(user, REGISTRATION_UTM_CREATED_AT),
|
||||
str(created_at)
|
||||
)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_no_referral(self):
|
||||
"""Verify that no referral is recorded when a cookie is not present."""
|
||||
self.assertIsNone(self.client.cookies.get(settings.AFFILIATE_COOKIE_NAME)) # pylint: disable=no-member
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_AFFILIATE_ID))
|
||||
utm_cookie_name = 'edx.test.utm'
|
||||
with mock.patch('student.models.RegistrationCookieConfiguration.current') as config:
|
||||
instance = config.return_value
|
||||
instance.utm_cookie_name = utm_cookie_name
|
||||
|
||||
self.assertIsNone(self.client.cookies.get(settings.AFFILIATE_COOKIE_NAME)) # pylint: disable=no-member
|
||||
self.assertIsNone(self.client.cookies.get(utm_cookie_name)) # pylint: disable=no-member
|
||||
user = self.create_account_and_fetch_profile().user
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_AFFILIATE_ID))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_source')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_medium')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_campaign')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_term')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_PARAMETERS.get('utm_content')))
|
||||
self.assertIsNone(UserAttribute.get_user_attribute(user, REGISTRATION_UTM_CREATED_AT))
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -54,7 +54,7 @@ from student.models import (
|
||||
CourseEnrollmentAllowed, UserStanding, LoginFailures,
|
||||
create_comments_service_user, PasswordHistory, UserSignupSource,
|
||||
DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED,
|
||||
LogoutViewConfiguration)
|
||||
LogoutViewConfiguration, RegistrationCookieConfiguration)
|
||||
from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form
|
||||
from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error
|
||||
@@ -133,6 +133,14 @@ ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number d
|
||||
SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated'
|
||||
# Used as the name of the user attribute for tracking affiliate registrations
|
||||
REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id'
|
||||
REGISTRATION_UTM_PARAMETERS = {
|
||||
'utm_source': 'registration_utm_source',
|
||||
'utm_medium': 'registration_utm_medium',
|
||||
'utm_campaign': 'registration_utm_campaign',
|
||||
'utm_term': 'registration_utm_term',
|
||||
'utm_content': 'registration_utm_content',
|
||||
}
|
||||
REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at'
|
||||
# used to announce a registration
|
||||
REGISTER_USER = Signal(providing_args=["user", "profile"])
|
||||
|
||||
@@ -1817,7 +1825,11 @@ def create_account_with_params(request, params):
|
||||
login(request, new_user)
|
||||
request.session.set_expiry(0)
|
||||
|
||||
_record_registration_attribution(request, new_user)
|
||||
try:
|
||||
record_registration_attributions(request, new_user)
|
||||
# Don't prevent a user from registering due to attribution errors.
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception('Error while attributing cookies to user registration.')
|
||||
|
||||
# TODO: there is no error checking here to see that the user actually logged in successfully,
|
||||
# and is not yet an active user.
|
||||
@@ -1859,16 +1871,54 @@ def _enroll_user_in_pending_courses(student):
|
||||
)
|
||||
|
||||
|
||||
def _record_registration_attribution(request, user):
|
||||
def record_affiliate_registration_attribution(request, user):
|
||||
"""
|
||||
Attribute this user's registration to the referring affiliate, if
|
||||
applicable.
|
||||
"""
|
||||
affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME)
|
||||
if user is not None and affiliate_id is not None:
|
||||
if user and affiliate_id:
|
||||
UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id)
|
||||
|
||||
|
||||
def record_utm_registration_attribution(request, user):
|
||||
"""
|
||||
Attribute this user's registration to the latest UTM referrer, if
|
||||
applicable.
|
||||
"""
|
||||
utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name
|
||||
utm_cookie = request.COOKIES.get(utm_cookie_name)
|
||||
if user and utm_cookie:
|
||||
utm = json.loads(utm_cookie)
|
||||
for utm_parameter in REGISTRATION_UTM_PARAMETERS:
|
||||
UserAttribute.set_user_attribute(
|
||||
user,
|
||||
REGISTRATION_UTM_PARAMETERS.get(utm_parameter),
|
||||
utm.get(utm_parameter)
|
||||
)
|
||||
created_at_unixtime = utm.get('created_at')
|
||||
if created_at_unixtime:
|
||||
# We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds.
|
||||
# PYTHON: time.time() => 1475590280.823698
|
||||
# JS: new Date().getTime() => 1475590280823
|
||||
created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC)
|
||||
else:
|
||||
created_at_datetime = None
|
||||
UserAttribute.set_user_attribute(
|
||||
user,
|
||||
REGISTRATION_UTM_CREATED_AT,
|
||||
created_at_datetime
|
||||
)
|
||||
|
||||
|
||||
def record_registration_attributions(request, user):
|
||||
"""
|
||||
Attribute this user's registration based on referrer cookies.
|
||||
"""
|
||||
record_affiliate_registration_attribution(request, user)
|
||||
record_utm_registration_attribution(request, user)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def create_account(request, post_override=None):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user