Commit 8bc78be7 by Bill DeRusha

Merge pull request #723 from edx/bderusha/affiliate-order-attribution

Referral Tracking: Attribute a basket with the affiliate
parents 294850d0 1f9490ee
...@@ -96,6 +96,7 @@ class CouponViewSetTest(CouponMixin, CourseCatalogTestMixin, TestCase): ...@@ -96,6 +96,7 @@ class CouponViewSetTest(CouponMixin, CourseCatalogTestMixin, TestCase):
request.user = self.user request.user = self.user
request.data = self.coupon_data request.data = self.coupon_data
request.site = self.site request.site = self.site
request.COOKIES = {}
response = CouponViewSet().create(request) response = CouponViewSet().create(request)
......
...@@ -8,8 +8,9 @@ Selector = get_class('partner.strategy', 'Selector') ...@@ -8,8 +8,9 @@ Selector = get_class('partner.strategy', 'Selector')
class Basket(AbstractBasket): class Basket(AbstractBasket):
site = models.ForeignKey('sites.Site', verbose_name=_("Site"), null=True, blank=True, default=None, site = models.ForeignKey(
on_delete=models.SET_NULL) 'sites.Site', verbose_name=_("Site"), null=True, blank=True, default=None, on_delete=models.SET_NULL
)
@property @property
def order_number(self): def order_number(self):
......
...@@ -7,6 +7,7 @@ from oscar.test.factories import ProductFactory, RangeFactory, VoucherFactory ...@@ -7,6 +7,7 @@ from oscar.test.factories import ProductFactory, RangeFactory, VoucherFactory
from ecommerce.extensions.basket.utils import get_certificate_type_display_value, prepare_basket from ecommerce.extensions.basket.utils import get_certificate_type_display_value, prepare_basket
from ecommerce.extensions.partner.models import StockRecord from ecommerce.extensions.partner.models import StockRecord
from ecommerce.extensions.test.factories import prepare_voucher from ecommerce.extensions.test.factories import prepare_voucher
from ecommerce.referrals.models import Referral
from ecommerce.tests.factories import SiteConfigurationFactory from ecommerce.tests.factories import SiteConfigurationFactory
from ecommerce.tests.testcases import TestCase from ecommerce.tests.testcases import TestCase
...@@ -21,6 +22,7 @@ class BasketUtilsTests(TestCase): ...@@ -21,6 +22,7 @@ class BasketUtilsTests(TestCase):
def setUp(self): def setUp(self):
super(BasketUtilsTests, self).setUp() super(BasketUtilsTests, self).setUp()
self.request = RequestFactory() self.request = RequestFactory()
self.request.COOKIES = {}
self.request.user = self.create_user() self.request.user = self.create_user()
site_configuration = SiteConfigurationFactory(partner__name='Tester') site_configuration = SiteConfigurationFactory(partner__name='Tester')
self.request.site = site_configuration.site self.request.site = site_configuration.site
...@@ -82,6 +84,34 @@ class BasketUtilsTests(TestCase): ...@@ -82,6 +84,34 @@ class BasketUtilsTests(TestCase):
self.assertEqual(basket.lines.first().product, product2) self.assertEqual(basket.lines.first().product, product2)
self.assertEqual(basket.product_quantity(product2), 1) self.assertEqual(basket.product_quantity(product2), 1)
def test_prepare_basket_affiliate_cookie_lifecycle(self):
""" Verify a basket is returned and referral captured. """
product = ProductFactory()
affiliate_id = 'test_affiliate'
self.request.COOKIES['affiliate_id'] = affiliate_id
basket = prepare_basket(self.request, product)
# test affiliate id from cookie saved in referral
referral = Referral.objects.get(basket_id=basket.id)
self.assertEqual(referral.affiliate_id, affiliate_id)
# update cookie
new_affiliate_id = 'new_affiliate'
self.request.COOKIES['affiliate_id'] = new_affiliate_id
basket = prepare_basket(self.request, product)
# test new affiliate id saved
referral = Referral.objects.get(basket_id=basket.id)
self.assertEqual(referral.affiliate_id, new_affiliate_id)
# expire cookie
del self.request.COOKIES['affiliate_id']
basket = prepare_basket(self.request, product)
# test referral record is deleted when no cookie set
with self.assertRaises(Referral.DoesNotExist):
Referral.objects.get(basket_id=basket.id)
@ddt.data( @ddt.data(
('honor', 'Honor'), ('honor', 'Honor'),
('verified', 'Verified'), ('verified', 'Verified'),
......
import logging import logging
from django.conf import settings
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from oscar.core.loading import get_class, get_model from oscar.core.loading import get_class, get_model
from ecommerce.referrals.models import Referral
Applicator = get_class('offer.utils', 'Applicator') Applicator = get_class('offer.utils', 'Applicator')
Basket = get_model('basket', 'Basket') Basket = get_model('basket', 'Basket')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def prepare_basket(request, product, voucher=None): def prepare_basket(request, product, voucher=None):
""" """
Create or get the basket, add the product, and apply a voucher. Create or get the basket, add the product, apply a voucher, and record referral data.
Existing baskets are merged. The specified product will Existing baskets are merged. The specified product will
be added to the remaining open basket. If a voucher is passed, all existing be added to the remaining open basket. If a voucher is passed, all existing
...@@ -35,6 +39,15 @@ def prepare_basket(request, product, voucher=None): ...@@ -35,6 +39,15 @@ def prepare_basket(request, product, voucher=None):
Applicator().apply(basket, request.user, request) Applicator().apply(basket, request.user, request)
logger.info('Applied Voucher [%s] to basket [%s].', voucher.code, basket.id) logger.info('Applied Voucher [%s] to basket [%s].', voucher.code, basket.id)
affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_KEY)
if affiliate_id:
Referral.objects.update_or_create(
basket=basket,
defaults={'affiliate_id': affiliate_id}
)
else:
Referral.objects.filter(basket=basket).delete()
return basket return basket
......
"""Test Order Utility classes """ """Test Order Utility classes """
import logging
import mock import mock
from django.test.client import RequestFactory from django.test.client import RequestFactory
from oscar.core.loading import get_class from oscar.core.loading import get_class
from oscar.test.factories import create_basket as oscar_create_basket from oscar.test.factories import create_basket as oscar_create_basket
from oscar.test.newfactories import BasketFactory from oscar.test.newfactories import BasketFactory
from testfixtures import LogCapture
from ecommerce.extensions.fulfillment.status import ORDER from ecommerce.extensions.fulfillment.status import ORDER
from ecommerce.referrals.models import Referral
from ecommerce.tests.factories import SiteConfigurationFactory, PartnerFactory from ecommerce.tests.factories import SiteConfigurationFactory, PartnerFactory
from ecommerce.tests.testcases import TestCase from ecommerce.tests.testcases import TestCase
LOGGER_NAME = 'ecommerce.extensions.order.utils'
Country = get_class('address.models', 'Country') Country = get_class('address.models', 'Country')
NoShippingRequired = get_class('shipping.methods', 'NoShippingRequired') NoShippingRequired = get_class('shipping.methods', 'NoShippingRequired')
OrderCreator = get_class('order.utils', 'OrderCreator') OrderCreator = get_class('order.utils', 'OrderCreator')
...@@ -92,6 +97,10 @@ class OrderCreatorTests(TestCase): ...@@ -92,6 +97,10 @@ class OrderCreatorTests(TestCase):
basket.save() basket.save()
return basket return basket
def create_referral(self, basket, affiliate_id):
""" Returns a new Referral associated with the specified basket. """
return Referral.objects.create(basket=basket, affiliate_id=affiliate_id)
def test_create_order_model_default_site(self): def test_create_order_model_default_site(self):
""" """
Verify the create_order_model method associates the order with the default site Verify the create_order_model method associates the order with the default site
...@@ -116,3 +125,45 @@ class OrderCreatorTests(TestCase): ...@@ -116,3 +125,45 @@ class OrderCreatorTests(TestCase):
# Ensure the order has the non-default site # Ensure the order has the non-default site
order = self.create_order_model(basket) order = self.create_order_model(basket)
self.assertEqual(order.site, site) self.assertEqual(order.site, site)
def test_create_order_model_basket_referral(self):
""" Verify the create_order_model method associates the order with the basket's site. """
affiliate_id = 'test affiliate'
# Create the basket and associated referral
basket = self.create_basket(None)
self.create_referral(basket, affiliate_id)
# Ensure the referral is now associated with the order and has the correct affiliate id
order = self.create_order_model(basket)
referral = Referral.objects.get(order_id=order.id)
self.assertEqual(referral.affiliate_id, affiliate_id)
def test_create_order_model_basket_no_referral(self):
""" Verify the create_order_model method logs error if no referral."""
# Create a site config to clean up log messages
site_configuration = SiteConfigurationFactory(site__domain='star.fake', partner__name='star')
site = site_configuration.site
# Create the basket WITHOUT an associated referral
basket = self.create_basket(site)
with LogCapture(LOGGER_NAME, level=logging.DEBUG) as l:
order = self.create_order_model(basket)
message = 'Order [{order_id}] has no referral associated with its basket.'.format(order_id=order.id)
l.check((LOGGER_NAME, 'DEBUG', message))
def test_create_order_model_basket_referral_error(self):
""" Verify the create_order_model method logs error for referral errors. """
# Create a site config to clean up log messages
site_configuration = SiteConfigurationFactory(site__domain='star.fake', partner__name='star')
site = site_configuration.site
# Create the basket WITHOUT an associated referral
basket = self.create_basket(site)
with LogCapture(LOGGER_NAME, level=logging.ERROR) as l:
Referral.objects.get = mock.Mock()
Referral.objects.get.side_effect = Exception
order = self.create_order_model(basket)
message = 'Referral for Order [{order_id}] failed to save.'.format(order_id=order.id)
l.check((LOGGER_NAME, 'ERROR', message))
...@@ -6,6 +6,8 @@ from oscar.apps.order.utils import OrderCreator as OscarOrderCreator ...@@ -6,6 +6,8 @@ from oscar.apps.order.utils import OrderCreator as OscarOrderCreator
from oscar.core.loading import get_model from oscar.core.loading import get_model
from threadlocals.threadlocals import get_current_request from threadlocals.threadlocals import get_current_request
from ecommerce.referrals.models import Referral
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Order = get_model('order', 'Order') Order = get_model('order', 'Order')
...@@ -99,4 +101,14 @@ class OrderCreator(OscarOrderCreator): ...@@ -99,4 +101,14 @@ class OrderCreator(OscarOrderCreator):
order_data.update(extra_order_fields) order_data.update(extra_order_fields)
order = Order(**order_data) order = Order(**order_data)
order.save() order.save()
try:
referral = Referral.objects.get(basket=basket)
referral.order = order
referral.save()
except Referral.DoesNotExist:
logger.debug('Order [%d] has no referral associated with its basket.', order.id)
except Exception: # pylint: disable=broad-except
logger.exception('Referral for Order [%d] failed to save.', order.id)
return order return order
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
('basket', '0006_basket_site'),
('order', '0009_auto_20150709_1205'),
]
operations = [
migrations.CreateModel(
name='Referral',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(default=django.utils.timezone.now, verbose_name='created', editable=False, blank=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(default=django.utils.timezone.now, verbose_name='modified', editable=False, blank=True)),
('affiliate_id', models.CharField(default=None, max_length=255, verbose_name='Affiliate ID')),
('basket', models.OneToOneField(null=True, blank=True, to='basket.Basket')),
('order', models.OneToOneField(null=True, blank=True, to='order.Order')),
],
options={
'ordering': ('-modified', '-created'),
'abstract': False,
'get_latest_by': 'modified',
},
),
]
from __future__ import unicode_literals
import logging
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
logger = logging.getLogger(__name__)
class Referral(TimeStampedModel):
affiliate_id = models.CharField(_('Affiliate ID'), null=False, blank=False, default=None, max_length=255)
basket = models.OneToOneField('basket.Basket', null=True, blank=True)
order = models.OneToOneField('order.Order', null=True, blank=True)
...@@ -263,6 +263,7 @@ LOCAL_APPS = [ ...@@ -263,6 +263,7 @@ LOCAL_APPS = [
'ecommerce.core', 'ecommerce.core',
'ecommerce.courses', 'ecommerce.courses',
'ecommerce.invoice', 'ecommerce.invoice',
'ecommerce.referrals',
# Theming app for customizing visual and behavioral attributes of a site # Theming app for customizing visual and behavioral attributes of a site
'ecommerce.theming', 'ecommerce.theming',
...@@ -526,7 +527,9 @@ EDX_DRF_EXTENSIONS = { ...@@ -526,7 +527,9 @@ EDX_DRF_EXTENSIONS = {
'JWT_PAYLOAD_USER_ATTRIBUTES': ('full_name', 'email', 'tracking_context',), 'JWT_PAYLOAD_USER_ATTRIBUTES': ('full_name', 'email', 'tracking_context',),
} }
# Enrollment codes voucher end datetime used for setting the end dates for vouchers # Enrollment codes voucher end datetime used for setting the end dates for vouchers
# created for the Enrollment code products. # created for the Enrollment code products.
ENROLLMENT_CODE_EXIPRATION_DATE = datetime.datetime.now() + datetime.timedelta(weeks=520) ENROLLMENT_CODE_EXIPRATION_DATE = datetime.datetime.now() + datetime.timedelta(weeks=520)
# Affiliate cookie key
AFFILIATE_COOKIE_KEY = 'affiliate_id'
...@@ -391,6 +391,7 @@ class CouponMixin(object): ...@@ -391,6 +391,7 @@ class CouponMixin(object):
request = RequestFactory() request = RequestFactory()
request.site = self.site request.site = self.site
request.user = factories.UserFactory() request.user = factories.UserFactory()
request.COOKIES = {}
self.basket = prepare_basket(request, coupon) self.basket = prepare_basket(request, coupon)
......
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