From 0e66baf41f81910b8877a24340044928ab539a14 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Thu, 12 May 2016 12:30:03 -0400 Subject: [PATCH] Add referral tracking for new registrations. ECOM-4325 --- cms/envs/aws.py | 3 ++ cms/envs/common.py | 3 ++ .../migrations/0003_auto_20160516_0938.py | 33 ++++++++++++++ common/djangoapps/student/models.py | 44 +++++++++++++++++++ .../student/tests/test_create_account.py | 19 ++++++++ common/djangoapps/student/tests/tests.py | 25 ++++++++++- common/djangoapps/student/views.py | 14 +++++- lms/envs/aws.py | 2 + lms/envs/common.py | 3 ++ 9 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 common/djangoapps/student/migrations/0003_auto_20160516_0938.py diff --git a/cms/envs/aws.py b/cms/envs/aws.py index abcb74943e..9f172096a6 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -415,3 +415,6 @@ if FEATURES.get('CUSTOM_COURSES_EDX'): # Partner support link for CMS footer PARTNER_SUPPORT_EMAIL = ENV_TOKENS.get('PARTNER_SUPPORT_EMAIL', PARTNER_SUPPORT_EMAIL) + +# Affiliate cookie tracking +AFFILIATE_COOKIE_NAME = ENV_TOKENS.get('AFFILIATE_COOKIE_NAME', AFFILIATE_COOKIE_NAME) diff --git a/cms/envs/common.py b/cms/envs/common.py index 651f64a58f..a27a744a38 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1193,3 +1193,6 @@ USERNAME_PATTERN = r'(?P[\w.@+-]+)' # Partner support link for CMS footer PARTNER_SUPPORT_EMAIL = '' + +# Affiliate cookie tracking +AFFILIATE_COOKIE_NAME = 'affiliate_id' diff --git a/common/djangoapps/student/migrations/0003_auto_20160516_0938.py b/common/djangoapps/student/migrations/0003_auto_20160516_0938.py new file mode 100644 index 0000000000..9d2295ed86 --- /dev/null +++ b/common/djangoapps/student/migrations/0003_auto_20160516_0938.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone +from django.conf import settings +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('student', '0002_auto_20151208_1034'), + ] + + operations = [ + migrations.CreateModel( + name='UserAttribute', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)), + ('name', models.CharField(help_text='Name of this user attribute.', max_length=255)), + ('value', models.CharField(help_text='Value of this user attribute.', max_length=255)), + ('user', models.ForeignKey(related_name='attributes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='userattribute', + unique_together=set([('user', 'name')]), + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 8b00a64da3..0b45b83d5b 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -40,6 +40,7 @@ from django.core.cache import cache from django_countries.fields import CountryField import dogstats_wrapper as dog_stats_api from eventtracking import tracker +from model_utils.models import TimeStampedModel from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey from simple_history.models import HistoricalRecords @@ -2154,3 +2155,46 @@ class EnrollmentRefundConfiguration(ConfigurationModel): def refund_window(self, refund_window): """Set the current refund window to the given timedelta.""" self.refund_window_microseconds = int(refund_window.total_seconds() * 1000000) + + +class UserAttribute(TimeStampedModel): + """ + Record additional metadata about a user, stored as key/value pairs of text. + """ + + class Meta(object): + # Ensure that at most one value exists for a given user/name. + unique_together = (('user', 'name')) + + user = models.ForeignKey(User, related_name='attributes') + name = models.CharField(max_length=255, help_text=_("Name of this user attribute.")) + value = models.CharField(max_length=255, help_text=_("Value of this user attribute.")) + + def __unicode__(self): + """Unicode representation of this attribute. """ + return u"[{username}] {name}: {value}".format( + name=self.name, + value=self.value, + username=self.user.username, + ) + + @classmethod + def set_user_attribute(cls, user, name, value): + """ + Add an name/value pair as an attribute for the given + user. Overwrites any previous value for that name, if it + exists. + """ + cls.objects.filter(user=user, name=name).delete() + cls.objects.create(user=user, name=name, value=value) + + @classmethod + def get_user_attribute(cls, user, name): + """ + Return the attribute value for the given user and name. If no such + value exists, returns None. + """ + try: + return cls.objects.get(user=user, name=name).value + except cls.DoesNotExist: + return None diff --git a/common/djangoapps/student/tests/test_create_account.py b/common/djangoapps/student/tests/test_create_account.py index 7422a02279..8ea636f976 100644 --- a/common/djangoapps/student/tests/test_create_account.py +++ b/common/djangoapps/student/tests/test_create_account.py @@ -19,6 +19,7 @@ from notification_prefs import NOTIFICATION_PREF_KEY from edxmako.tests import mako_middleware_process_request from external_auth.models import ExternalAuthMap import student +from student.models import UserAttribute TEST_CS_URL = 'https://comments.service.test:123/' @@ -278,6 +279,24 @@ class TestCreateAccount(TestCase): else: self.assertIsNone(preference) + @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') + def test_referral_attribution(self): + """ + Verify that a referral attribution is recorded if an affiliate + cookie is present upon a new user's registration. + """ + affiliate_id = 'test-partner' + self.client.cookies[settings.AFFILIATE_COOKIE_NAME] = affiliate_id + user = self.create_account_and_fetch_profile().user + self.assertEqual(UserAttribute.get_user_attribute(user, settings.AFFILIATE_COOKIE_NAME), affiliate_id) + + @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, settings.AFFILIATE_COOKIE_NAME)) + @ddt.ddt class TestCreateAccountValidation(TestCase): diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index cb0b94a5ae..a16101e928 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -24,7 +24,7 @@ from django.test.client import Client from course_modes.models import CourseMode from student.models import ( anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, - unique_id_for_user, LinkedInAddToProfileConfiguration + unique_id_for_user, LinkedInAddToProfileConfiguration, UserAttribute ) from student.views import ( process_survey_link, @@ -1157,3 +1157,26 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): self.assertContains(response, 'This course is 1 of 3 courses in the', count) self.assertContains(response, self.program_name, count * 2) self.assertContains(response, 'View XSeries Details', count) + + +class UserAttributeTests(TestCase): + """Tests for the UserAttribute model.""" + + def setUp(self): + super(UserAttributeTests, self).setUp() + self.user = UserFactory() + self.name = 'test' + self.value = 'test-value' + + def test_get_set_attribute(self): + self.assertIsNone(UserAttribute.get_user_attribute(self.user, self.name)) + UserAttribute.set_user_attribute(self.user, self.name, self.value) + self.assertEqual(UserAttribute.get_user_attribute(self.user, self.name), self.value) + new_value = 'new_value' + UserAttribute.set_user_attribute(self.user, self.name, new_value) + self.assertEqual(UserAttribute.get_user_attribute(self.user, self.name), new_value) + + def test_unicode(self): + UserAttribute.set_user_attribute(self.user, self.name, self.value) + for field in (self.name, self.value, self.user.username): + self.assertIn(field, unicode(UserAttribute.objects.get(user=self.user))) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index eb75876933..c2abd9c004 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -111,7 +111,7 @@ from student.helpers import ( DISABLE_UNENROLL_CERT_STATES, ) from student.cookies import set_logged_in_cookies, delete_logged_in_cookies -from student.models import anonymous_id_for_user +from student.models import anonymous_id_for_user, UserAttribute from shoppingcart.models import DonationConfiguration, CourseRegistrationCode from embargo import api as embargo_api @@ -1815,6 +1815,8 @@ def create_account_with_params(request, params): login(request, new_user) request.session.set_expiry(0) + _record_registration_attribution(request, new_user) + # TODO: there is no error checking here to see that the user actually logged in successfully, # and is not yet an active user. if new_user is not None: @@ -1855,6 +1857,16 @@ def _enroll_user_in_pending_courses(student): ) +def _record_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: + UserAttribute.set_user_attribute(user, settings.AFFILIATE_COOKIE_NAME, affiliate_id) + + @csrf_exempt def create_account(request, post_override=None): """ diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 7b8b52e2ff..b91777d21e 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -812,3 +812,5 @@ API_ACCESS_FROM_EMAIL = ENV_TOKENS.get('API_ACCESS_FROM_EMAIL') # Mobile App Version Upgrade config APP_UPGRADE_CACHE_TIMEOUT = ENV_TOKENS.get('APP_UPGRADE_CACHE_TIMEOUT', APP_UPGRADE_CACHE_TIMEOUT) + +AFFILIATE_COOKIE_NAME = ENV_TOKENS.get('AFFILIATE_COOKIE_NAME', AFFILIATE_COOKIE_NAME) diff --git a/lms/envs/common.py b/lms/envs/common.py index b1f7002099..035ff03915 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2900,3 +2900,6 @@ API_ACCESS_MANAGER_EMAIL = 'api-access@example.com' API_ACCESS_FROM_EMAIL = 'api-requests@example.com' API_DOCUMENTATION_URL = 'http://edx.readthedocs.org/projects/edx-platform-api/en/latest/overview.html' AUTH_DOCUMENTATION_URL = 'http://edx.readthedocs.org/projects/edx-platform-api/en/latest/authentication.html' + +# Affiliate cookie tracking +AFFILIATE_COOKIE_NAME = 'affiliate_id'